led.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. // ============================================================================
  2. // led.js — LED 点阵滚动文字
  3. //
  4. // 读取后端 GetLEDText() 获取要显示的文字,转换为 LED 点阵(数字/字母/符号
  5. // 用内置 5x7 字模,汉字用 Canvas 像素采样),在画布上横向循环滚动。
  6. // ============================================================================
  7. import {SettingsService} from "../bindings/TengTuToolbox/index.js";
  8. // ---- 5x7 点阵字模(每字符 7 行,每行 5 位,高位在左)----
  9. const FONT_5x7 = {
  10. '0': ['01110','10001','10011','10101','11001','10001','01110'],
  11. '1': ['00100','01100','00100','00100','00100','00100','01110'],
  12. '2': ['01110','10001','00001','00010','00100','01000','11111'],
  13. '3': ['11110','00001','00001','01110','00001','00001','11110'],
  14. '4': ['00010','00110','01010','10010','11111','00010','00010'],
  15. '5': ['11111','10000','11110','00001','00001','10001','01110'],
  16. '6': ['00110','01000','10000','11110','10001','10001','01110'],
  17. '7': ['11111','00001','00010','00100','01000','01000','01000'],
  18. '8': ['01110','10001','10001','01110','10001','10001','01110'],
  19. '9': ['01110','10001','10001','01111','00001','00010','01100'],
  20. 'A': ['01110','10001','10001','11111','10001','10001','10001'],
  21. 'B': ['11110','10001','10001','11110','10001','10001','11110'],
  22. 'C': ['01110','10001','10000','10000','10000','10001','01110'],
  23. 'D': ['11100','10010','10001','10001','10001','10010','11100'],
  24. 'E': ['11111','10000','10000','11110','10000','10000','11111'],
  25. 'F': ['11111','10000','10000','11110','10000','10000','10000'],
  26. 'G': ['01110','10001','10000','10111','10001','10001','01111'],
  27. 'H': ['10001','10001','10001','11111','10001','10001','10001'],
  28. 'I': ['01110','00100','00100','00100','00100','00100','01110'],
  29. 'J': ['00111','00010','00010','00010','00010','10010','01100'],
  30. 'K': ['10001','10010','10100','11000','10100','10010','10001'],
  31. 'L': ['10000','10000','10000','10000','10000','10000','11111'],
  32. 'M': ['10001','11011','10101','10101','10001','10001','10001'],
  33. 'N': ['10001','11001','10101','10011','10001','10001','10001'],
  34. 'O': ['01110','10001','10001','10001','10001','10001','01110'],
  35. 'P': ['11110','10001','10001','11110','10000','10000','10000'],
  36. 'Q': ['01110','10001','10001','10001','10101','10010','01101'],
  37. 'R': ['11110','10001','10001','11110','10100','10010','10001'],
  38. 'S': ['01111','10000','10000','01110','00001','00001','11110'],
  39. 'T': ['11111','00100','00100','00100','00100','00100','00100'],
  40. 'U': ['10001','10001','10001','10001','10001','10001','01110'],
  41. 'V': ['10001','10001','10001','10001','10001','01010','00100'],
  42. 'W': ['10001','10001','10001','10101','10101','10101','01010'],
  43. 'X': ['10001','10001','01010','00100','01010','10001','10001'],
  44. 'Y': ['10001','10001','01010','00100','00100','00100','00100'],
  45. 'Z': ['11111','00001','00010','00100','01000','10000','11111'],
  46. 'a': ['00000','00000','01110','00001','01111','10001','01111'],
  47. 'b': ['10000','10000','11110','10001','10001','10001','11110'],
  48. 'c': ['00000','00000','01110','10001','10000','10001','01110'],
  49. 'd': ['00001','00001','01111','10001','10001','10001','01111'],
  50. 'e': ['00000','00000','01110','10001','11111','10000','01110'],
  51. 'f': ['00110','01001','01000','11100','01000','01000','01000'],
  52. 'g': ['00000','01111','10001','10001','01111','00001','01110'],
  53. 'h': ['10000','10000','11110','10001','10001','10001','10001'],
  54. 'i': ['00100','00000','01100','00100','00100','00100','01110'],
  55. 'j': ['00010','00000','00110','00010','00010','10010','01100'],
  56. 'k': ['10000','10000','10010','10100','11000','10100','10010'],
  57. 'l': ['01100','00100','00100','00100','00100','00100','01110'],
  58. 'm': ['00000','00000','11010','10101','10101','10101','10101'],
  59. 'n': ['00000','00000','11110','10001','10001','10001','10001'],
  60. 'o': ['00000','00000','01110','10001','10001','10001','01110'],
  61. 'p': ['00000','00000','11110','10001','11110','10000','10000'],
  62. 'q': ['00000','00000','01111','10001','01111','00001','00001'],
  63. 'r': ['00000','00000','10110','11001','10000','10000','10000'],
  64. 's': ['00000','00000','01111','10000','01110','00001','11110'],
  65. 't': ['01000','01000','11100','01000','01000','01001','00110'],
  66. 'u': ['00000','00000','10001','10001','10001','10011','01101'],
  67. 'v': ['00000','00000','10001','10001','10001','01010','00100'],
  68. 'w': ['00000','00000','10001','10001','10101','10101','01010'],
  69. 'x': ['00000','00000','10001','01010','00100','01010','10001'],
  70. 'y': ['00000','00000','10001','10001','01111','00001','01110'],
  71. 'z': ['00000','00000','11111','00010','00100','01000','11111'],
  72. ' ': ['00000','00000','00000','00000','00000','00000','00000'],
  73. '.': ['00000','00000','00000','00000','00000','00110','00110'],
  74. ',': ['00000','00000','00000','00000','00000','00110','00100'],
  75. ':': ['00000','00110','00110','00000','00110','00110','00000'],
  76. ';': ['00000','00110','00110','00000','00110','00100','01000'],
  77. '-': ['00000','00000','00000','11111','00000','00000','00000'],
  78. '_': ['00000','00000','00000','00000','00000','00000','11111'],
  79. '+': ['00000','00100','00100','11111','00100','00100','00000'],
  80. '=': ['00000','00000','11111','00000','11111','00000','00000'],
  81. '/': ['00001','00010','00010','00100','01000','01000','10000'],
  82. '\\':['10000','01000','01000','00100','00010','00010','00001'],
  83. '!': ['00100','00100','00100','00100','00100','00000','00100'],
  84. '?': ['01110','10001','00001','00010','00100','00000','00100'],
  85. '@': ['01110','10001','10111','10101','10111','10000','01110'],
  86. '#': ['01010','01010','11111','01010','11111','01010','01010'],
  87. '$': ['00100','01111','10100','01110','00101','11110','00100'],
  88. '%': ['11001','11010','00010','00100','01000','01011','10011'],
  89. '&': ['01100','10010','10100','01000','10101','10010','01101'],
  90. '*': ['00000','00100','10101','01110','10101','00100','00000'],
  91. '(': ['00010','00100','01000','01000','01000','00100','00010'],
  92. ')': ['01000','00100','00010','00010','00010','00100','01000'],
  93. '[': ['01110','01000','01000','01000','01000','01000','01110'],
  94. ']': ['01110','00010','00010','00010','00010','00010','01110'],
  95. '{': ['00010','00100','00100','01000','00100','00100','00010'],
  96. '}': ['01000','00100','00100','00010','00100','00100','01000'],
  97. '<': ['00010','00100','01000','10000','01000','00100','00010'],
  98. '>': ['01000','00100','00010','00001','00010','00100','01000'],
  99. '|': ['00100','00100','00100','00100','00100','00100','00100'],
  100. '~': ['00000','01000','10101','00010','00000','00000','00000'],
  101. '^': ['00100','01010','10001','00000','00000','00000','00000'],
  102. '“': ['01010','01010','00100','00100','00100','00100','00100'],
  103. '”': ['01010','01010','00100','00100','00100','00100','00100'],
  104. '‘': ['00100','01010','01010','00100','00100','00100','00100'],
  105. '’': ['00100','01010','01010','00100','00100','00100','00100'],
  106. '。': ['00000','00000','00000','00000','00110','00110','00000'],
  107. ',': ['00000','00000','00000','00000','00110','00100','01000'],
  108. '、': ['00100','00100','00100','00100','00100','00000','00000'],
  109. ':': ['00000','00110','00110','00000','00110','00110','00000'],
  110. ';': ['00000','00110','00110','00000','00110','00100','01000'],
  111. '?': ['01110','10001','00001','00010','00100','00000','00100'],
  112. '!': ['00100','00100','00100','00100','00100','00000','00100'],
  113. '(': ['00010','00100','01000','01000','01000','00100','00010'],
  114. ')': ['01000','00100','00010','00010','00010','00100','01000'],
  115. '《': ['00010','00100','01000','10000','01000','00100','00010'],
  116. '》': ['01000','00100','00010','00001','00010','00100','01000'],
  117. '“': ['00100','01010','01010','00100','00100','00100','00100'],
  118. };
  119. // 汉字点阵:用 Canvas 把文字画出来,按 grid 采样。缓存避免重复计算。
  120. const hanziCache = new Map();
  121. // 将 5x7 字模放大 scale 倍(每点变 scale×scale 块),用于提高点阵密度
  122. function scaleGlyph(rows, scale) {
  123. const h = rows.length * scale, w = rows[0].length * scale;
  124. const out = [];
  125. for (let y = 0; y < h; y++) {
  126. const line = [];
  127. for (let x = 0; x < w; x++) {
  128. line.push(rows[Math.floor(y / scale)][Math.floor(x / scale)]);
  129. }
  130. out.push(line);
  131. }
  132. return out;
  133. }
  134. // 将单个字符渲染为点阵数组 [{row, col, on}],返回宽高(宽=列数,高=行数)
  135. function charToBitmap(ch) {
  136. const key = ch;
  137. if (hanziCache.has(key)) return hanziCache.get(key);
  138. let rows = null;
  139. const upper = ch.toUpperCase();
  140. // 英文/数字/符号:优先用字模,放大 2 倍提高密度(5x7 → 10x14)
  141. if (FONT_5x7[ch]) {
  142. rows = scaleGlyph(FONT_5x7[ch].map(line => line.split('').map(b => b === '1')), 2);
  143. return {w: 10, h: 14, on: rows, char: ch};
  144. }
  145. if (FONT_5x7[upper]) {
  146. rows = scaleGlyph(FONT_5x7[upper].map(line => line.split('').map(b => b === '1')), 2);
  147. return {w: 10, h: 14, on: rows, char: ch};
  148. }
  149. // 汉字/其他:Canvas 像素采样为 24x32 点阵(密度是原 12x16 的 2 倍)
  150. const gw = 24, gh = 32;
  151. const off = document.createElement('canvas');
  152. off.width = gw; off.height = gh;
  153. const octx = off.getContext('2d');
  154. octx.fillStyle = '#fff';
  155. octx.font = `${gh * 0.9}px "Microsoft YaHei", "SimHei", sans-serif`;
  156. octx.textAlign = 'center';
  157. octx.textBaseline = 'middle';
  158. octx.fillText(ch, gw / 2, gh / 2 + 1);
  159. const data = octx.getImageData(0, 0, gw, gh).data;
  160. const on = [];
  161. for (let y = 0; y < gh; y++) {
  162. const line = [];
  163. for (let x = 0; x < gw; x++) {
  164. line.push(data[(y * gw + x) * 4 + 3] > 100);
  165. }
  166. on.push(line);
  167. }
  168. const r = {w: gw, h: gh, on, char: ch};
  169. hanziCache.set(key, r);
  170. return r;
  171. }
  172. // 把整串文字拆成字符点阵序列(含间距),返回 [{char, on, w, h}]
  173. function textToSequence(text) {
  174. const seq = [];
  175. const chars = [...text];
  176. for (const ch of chars) {
  177. const bmp = charToBitmap(ch);
  178. seq.push({char: ch, on: bmp.on, w: bmp.w, h: bmp.h});
  179. }
  180. return seq;
  181. }
  182. // 计算整行点阵的总宽(列数)与最大高(行数)
  183. function layout(seq, gap) {
  184. let total = 0, maxH = 0;
  185. for (let i = 0; i < seq.length; i++) {
  186. total += seq[i].w;
  187. if (i < seq.length - 1) total += gap;
  188. if (seq[i].h > maxH) maxH = seq[i].h;
  189. }
  190. return {total, maxH};
  191. }
  192. // 主渲染:canvas 上绘制点阵,requestAnimationFrame 横向无缝滚动循环
  193. const canvas = document.getElementById('led-canvas');
  194. const ctx = canvas.getContext('2d');
  195. let LED_COLOR = '#ff4d4d'; // LED 点阵颜色(可从后端读取,可设置)
  196. const GAP = 3; // 字符间距(点)
  197. const CHAR_GAP = 2; // 字符内点距
  198. const DOT_RADIUS = 0.47; // 点半径占点距比例(接近 0.5 更饱满,圆形 LED)
  199. let seq = [], maxRows = 0;
  200. let speed = 60; // 像素/秒(CSS 像素)
  201. let offsetX = 0; // 当前水平偏移(CSS 像素,负=向左滚)
  202. let lastTs = 0;
  203. let dpr = 1; // 设备像素比
  204. // 把 #rrggbb 颜色拆成 {r,g,b},方便做发光渐变
  205. function hexToRgb(hex) {
  206. const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
  207. if (!m) return {r: 255, g: 77, b: 77};
  208. const n = parseInt(m[1], 16);
  209. return {r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255};
  210. }
  211. // 按当前窗口逻辑高度计算点距(点阵单元尺寸)
  212. function dotSize(H) {
  213. // 上下安全边距(CSS 像素),保证首末行笔划完整显示、不被窗口裁剪
  214. const pad = 6;
  215. const avail = Math.max(1, H - 2 * pad);
  216. // 点阵总高 = maxRows * (dot + CHAR_GAP),须 <= avail,否则垂直居中后
  217. // baseY 为负、顶部笔划被裁。向下取整保证数学上不溢出。
  218. let dot = Math.floor((avail - CHAR_GAP * maxRows) / maxRows);
  219. // 极高 DPI 缩放或窗口过矮导致可用高度不足以容纳满格点阵时,
  220. // 不强制抬升 dot(旧的 Math.max(3,...) 会把 dot 抬回 3,使总高溢出、
  221. // baseY 变负、顶部笔划被窗口裁剪),而是允许缩小到最小 1,
  222. // 确保 baseY 不为负、点阵完整显示。
  223. if (dot < 1) dot = 1;
  224. return dot;
  225. }
  226. // 整行文字的像素宽度(含字符间与末尾间隙),用于计算无缝循环周期
  227. function totalPxWidth(dot) {
  228. let w = 0;
  229. for (const item of seq) {
  230. w += item.w * (dot + CHAR_GAP) + GAP * dot; // 每字符点阵宽 + 字后间隙
  231. }
  232. return w;
  233. }
  234. // 绘制单个 LED 点:中心高亮 + 外圈光晕,模拟真实 LED 灯珠
  235. function drawLedDot(cx, cy, r, color) {
  236. const rgb = hexToRgb(color);
  237. // 高亮中心(更亮)
  238. ctx.fillStyle = `rgba(${Math.min(255, rgb.r + 60)},${Math.min(255, rgb.g + 60)},${Math.min(255, rgb.b + 60)},1)`;
  239. ctx.beginPath();
  240. ctx.arc(cx, cy, r * 0.72, 0, Math.PI * 2);
  241. ctx.fill();
  242. // 外圈光晕(径向渐变:中心亮、边缘淡,聚焦发光感)
  243. const grad = ctx.createRadialGradient(cx, cy, r * 0.3, cx, cy, r);
  244. grad.addColorStop(0, `rgba(${rgb.r},${rgb.g},${rgb.b},0.85)`);
  245. grad.addColorStop(0.7, `rgba(${rgb.r},${rgb.g},${rgb.b},0.35)`);
  246. grad.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0.05)`);
  247. ctx.fillStyle = grad;
  248. ctx.beginPath();
  249. ctx.arc(cx, cy, r, 0, Math.PI * 2);
  250. ctx.fill();
  251. }
  252. // 绘制一遍文字,起始横坐标为 x0(CSS 像素)
  253. function drawText(x0, H, dot) {
  254. let x = x0;
  255. const cell = dot + CHAR_GAP; // 点阵单元尺寸
  256. const r = dot * DOT_RADIUS; // 圆形点半径
  257. for (const item of seq) {
  258. const startX = Math.round(x);
  259. // 垂直居中:让整行文字在窗口高度居中
  260. const baseY = (H - item.h * cell + CHAR_GAP) / 2;
  261. for (let row = 0; row < item.h; row++) {
  262. for (let col = 0; col < item.w; col++) {
  263. if (item.on[row][col]) {
  264. const cx = startX + col * cell + dot / 2;
  265. const cy = baseY + row * cell + dot / 2;
  266. drawLedDot(cx, cy, r, LED_COLOR);
  267. }
  268. }
  269. }
  270. x += item.w * cell + GAP * dot;
  271. }
  272. }
  273. function renderFrame(ts) {
  274. if (!lastTs) lastTs = ts;
  275. const dt = (ts - lastTs) / 1000;
  276. lastTs = ts;
  277. // 统一用 CSS 像素坐标(ctx 已 setTransform(dpr),画布物理尺寸=CSS*dpr)
  278. const W = canvas.clientWidth, H = canvas.clientHeight;
  279. ctx.clearRect(0, 0, W, H);
  280. if (seq.length > 0 && W > 0 && H > 0) {
  281. const dot = dotSize(H);
  282. const totalW = totalPxWidth(dot); // 整行像素宽
  283. const sep = Math.max(dot * 6, 80); // 两遍文字间的最小间距
  284. const period = Math.max(totalW + sep, W + sep); // 循环周期:至少覆盖一个窗口宽,保证无缝
  285. // 向左滚动,跨过整段周期后回绕,实现连续滚动
  286. offsetX -= speed * dt;
  287. if (offsetX <= -period) offsetX += period;
  288. // 绘制两遍:第一遍 offsetX,第二遍 offsetX+period,窗口内始终有内容,无缝衔接
  289. drawText(offsetX, H, dot);
  290. drawText(offsetX + period, H, dot);
  291. }
  292. requestAnimationFrame(renderFrame);
  293. }
  294. // 初始化:读取文字、颜色与速度、布局、设置画布尺寸
  295. async function init() {
  296. console.log("LED_NEW_MARKER_2026"); // 验证 vite 是否读取新代码
  297. // 页面已加载完成 => WebView2 控制器必然就绪,通知后端补设透明背景
  298. // (Wails 创建窗口时设置的透明可能因控制器异步未就绪而丢失)
  299. SettingsService.SetLEDTransparent();
  300. const [text, color, spd] = await Promise.all([
  301. SettingsService.GetLEDText(),
  302. SettingsService.GetLEDColor(),
  303. SettingsService.GetLEDSpeed(),
  304. ]);
  305. LED_COLOR = color || '#ff4d4d';
  306. if (spd > 0) speed = spd;
  307. seq = textToSequence(text || 'LED 滚动文字');
  308. maxRows = layout(seq, GAP).maxH;
  309. dpr = window.devicePixelRatio || 1;
  310. // 设置画布物理尺寸并重置变换(赋值会清空矩阵,需每次重设)
  311. const setupCanvas = () => {
  312. canvas.width = Math.floor(canvas.clientWidth * dpr);
  313. canvas.height = Math.floor(canvas.clientHeight * dpr);
  314. ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后所有绘制用 CSS 像素
  315. };
  316. setupCanvas();
  317. // 窗口尺寸变化时重设画布(同样重设变换,避免 scale 丢失)
  318. new ResizeObserver(setupCanvas).observe(canvas);
  319. requestAnimationFrame(renderFrame);
  320. }
  321. // 关闭按钮
  322. document.getElementById('led-close').addEventListener('click', () => {
  323. SettingsService.CloseLEDWindow();
  324. });
  325. init();