// ============================================================================ // led.js — LED 点阵滚动文字 // // 读取后端 GetLEDText() 获取要显示的文字,转换为 LED 点阵(数字/字母/符号 // 用内置 5x7 字模,汉字用 Canvas 像素采样),在画布上横向循环滚动。 // ============================================================================ import {SettingsService} from "../bindings/TengTuToolbox/index.js"; // ---- 5x7 点阵字模(每字符 7 行,每行 5 位,高位在左)---- const FONT_5x7 = { '0': ['01110','10001','10011','10101','11001','10001','01110'], '1': ['00100','01100','00100','00100','00100','00100','01110'], '2': ['01110','10001','00001','00010','00100','01000','11111'], '3': ['11110','00001','00001','01110','00001','00001','11110'], '4': ['00010','00110','01010','10010','11111','00010','00010'], '5': ['11111','10000','11110','00001','00001','10001','01110'], '6': ['00110','01000','10000','11110','10001','10001','01110'], '7': ['11111','00001','00010','00100','01000','01000','01000'], '8': ['01110','10001','10001','01110','10001','10001','01110'], '9': ['01110','10001','10001','01111','00001','00010','01100'], 'A': ['01110','10001','10001','11111','10001','10001','10001'], 'B': ['11110','10001','10001','11110','10001','10001','11110'], 'C': ['01110','10001','10000','10000','10000','10001','01110'], 'D': ['11100','10010','10001','10001','10001','10010','11100'], 'E': ['11111','10000','10000','11110','10000','10000','11111'], 'F': ['11111','10000','10000','11110','10000','10000','10000'], 'G': ['01110','10001','10000','10111','10001','10001','01111'], 'H': ['10001','10001','10001','11111','10001','10001','10001'], 'I': ['01110','00100','00100','00100','00100','00100','01110'], 'J': ['00111','00010','00010','00010','00010','10010','01100'], 'K': ['10001','10010','10100','11000','10100','10010','10001'], 'L': ['10000','10000','10000','10000','10000','10000','11111'], 'M': ['10001','11011','10101','10101','10001','10001','10001'], 'N': ['10001','11001','10101','10011','10001','10001','10001'], 'O': ['01110','10001','10001','10001','10001','10001','01110'], 'P': ['11110','10001','10001','11110','10000','10000','10000'], 'Q': ['01110','10001','10001','10001','10101','10010','01101'], 'R': ['11110','10001','10001','11110','10100','10010','10001'], 'S': ['01111','10000','10000','01110','00001','00001','11110'], 'T': ['11111','00100','00100','00100','00100','00100','00100'], 'U': ['10001','10001','10001','10001','10001','10001','01110'], 'V': ['10001','10001','10001','10001','10001','01010','00100'], 'W': ['10001','10001','10001','10101','10101','10101','01010'], 'X': ['10001','10001','01010','00100','01010','10001','10001'], 'Y': ['10001','10001','01010','00100','00100','00100','00100'], 'Z': ['11111','00001','00010','00100','01000','10000','11111'], 'a': ['00000','00000','01110','00001','01111','10001','01111'], 'b': ['10000','10000','11110','10001','10001','10001','11110'], 'c': ['00000','00000','01110','10001','10000','10001','01110'], 'd': ['00001','00001','01111','10001','10001','10001','01111'], 'e': ['00000','00000','01110','10001','11111','10000','01110'], 'f': ['00110','01001','01000','11100','01000','01000','01000'], 'g': ['00000','01111','10001','10001','01111','00001','01110'], 'h': ['10000','10000','11110','10001','10001','10001','10001'], 'i': ['00100','00000','01100','00100','00100','00100','01110'], 'j': ['00010','00000','00110','00010','00010','10010','01100'], 'k': ['10000','10000','10010','10100','11000','10100','10010'], 'l': ['01100','00100','00100','00100','00100','00100','01110'], 'm': ['00000','00000','11010','10101','10101','10101','10101'], 'n': ['00000','00000','11110','10001','10001','10001','10001'], 'o': ['00000','00000','01110','10001','10001','10001','01110'], 'p': ['00000','00000','11110','10001','11110','10000','10000'], 'q': ['00000','00000','01111','10001','01111','00001','00001'], 'r': ['00000','00000','10110','11001','10000','10000','10000'], 's': ['00000','00000','01111','10000','01110','00001','11110'], 't': ['01000','01000','11100','01000','01000','01001','00110'], 'u': ['00000','00000','10001','10001','10001','10011','01101'], 'v': ['00000','00000','10001','10001','10001','01010','00100'], 'w': ['00000','00000','10001','10001','10101','10101','01010'], 'x': ['00000','00000','10001','01010','00100','01010','10001'], 'y': ['00000','00000','10001','10001','01111','00001','01110'], 'z': ['00000','00000','11111','00010','00100','01000','11111'], ' ': ['00000','00000','00000','00000','00000','00000','00000'], '.': ['00000','00000','00000','00000','00000','00110','00110'], ',': ['00000','00000','00000','00000','00000','00110','00100'], ':': ['00000','00110','00110','00000','00110','00110','00000'], ';': ['00000','00110','00110','00000','00110','00100','01000'], '-': ['00000','00000','00000','11111','00000','00000','00000'], '_': ['00000','00000','00000','00000','00000','00000','11111'], '+': ['00000','00100','00100','11111','00100','00100','00000'], '=': ['00000','00000','11111','00000','11111','00000','00000'], '/': ['00001','00010','00010','00100','01000','01000','10000'], '\\':['10000','01000','01000','00100','00010','00010','00001'], '!': ['00100','00100','00100','00100','00100','00000','00100'], '?': ['01110','10001','00001','00010','00100','00000','00100'], '@': ['01110','10001','10111','10101','10111','10000','01110'], '#': ['01010','01010','11111','01010','11111','01010','01010'], '$': ['00100','01111','10100','01110','00101','11110','00100'], '%': ['11001','11010','00010','00100','01000','01011','10011'], '&': ['01100','10010','10100','01000','10101','10010','01101'], '*': ['00000','00100','10101','01110','10101','00100','00000'], '(': ['00010','00100','01000','01000','01000','00100','00010'], ')': ['01000','00100','00010','00010','00010','00100','01000'], '[': ['01110','01000','01000','01000','01000','01000','01110'], ']': ['01110','00010','00010','00010','00010','00010','01110'], '{': ['00010','00100','00100','01000','00100','00100','00010'], '}': ['01000','00100','00100','00010','00100','00100','01000'], '<': ['00010','00100','01000','10000','01000','00100','00010'], '>': ['01000','00100','00010','00001','00010','00100','01000'], '|': ['00100','00100','00100','00100','00100','00100','00100'], '~': ['00000','01000','10101','00010','00000','00000','00000'], '^': ['00100','01010','10001','00000','00000','00000','00000'], '“': ['01010','01010','00100','00100','00100','00100','00100'], '”': ['01010','01010','00100','00100','00100','00100','00100'], '‘': ['00100','01010','01010','00100','00100','00100','00100'], '’': ['00100','01010','01010','00100','00100','00100','00100'], '。': ['00000','00000','00000','00000','00110','00110','00000'], ',': ['00000','00000','00000','00000','00110','00100','01000'], '、': ['00100','00100','00100','00100','00100','00000','00000'], ':': ['00000','00110','00110','00000','00110','00110','00000'], ';': ['00000','00110','00110','00000','00110','00100','01000'], '?': ['01110','10001','00001','00010','00100','00000','00100'], '!': ['00100','00100','00100','00100','00100','00000','00100'], '(': ['00010','00100','01000','01000','01000','00100','00010'], ')': ['01000','00100','00010','00010','00010','00100','01000'], '《': ['00010','00100','01000','10000','01000','00100','00010'], '》': ['01000','00100','00010','00001','00010','00100','01000'], '“': ['00100','01010','01010','00100','00100','00100','00100'], }; // 汉字点阵:用 Canvas 把文字画出来,按 grid 采样。缓存避免重复计算。 const hanziCache = new Map(); // 将 5x7 字模放大 scale 倍(每点变 scale×scale 块),用于提高点阵密度 function scaleGlyph(rows, scale) { const h = rows.length * scale, w = rows[0].length * scale; const out = []; for (let y = 0; y < h; y++) { const line = []; for (let x = 0; x < w; x++) { line.push(rows[Math.floor(y / scale)][Math.floor(x / scale)]); } out.push(line); } return out; } // 将单个字符渲染为点阵数组 [{row, col, on}],返回宽高(宽=列数,高=行数) function charToBitmap(ch) { const key = ch; if (hanziCache.has(key)) return hanziCache.get(key); let rows = null; const upper = ch.toUpperCase(); // 英文/数字/符号:优先用字模,放大 2 倍提高密度(5x7 → 10x14) if (FONT_5x7[ch]) { rows = scaleGlyph(FONT_5x7[ch].map(line => line.split('').map(b => b === '1')), 2); return {w: 10, h: 14, on: rows, char: ch}; } if (FONT_5x7[upper]) { rows = scaleGlyph(FONT_5x7[upper].map(line => line.split('').map(b => b === '1')), 2); return {w: 10, h: 14, on: rows, char: ch}; } // 汉字/其他:Canvas 像素采样为 24x32 点阵(密度是原 12x16 的 2 倍) const gw = 24, gh = 32; const off = document.createElement('canvas'); off.width = gw; off.height = gh; const octx = off.getContext('2d'); octx.fillStyle = '#fff'; octx.font = `${gh * 0.9}px "Microsoft YaHei", "SimHei", sans-serif`; octx.textAlign = 'center'; octx.textBaseline = 'middle'; octx.fillText(ch, gw / 2, gh / 2 + 1); const data = octx.getImageData(0, 0, gw, gh).data; const on = []; for (let y = 0; y < gh; y++) { const line = []; for (let x = 0; x < gw; x++) { line.push(data[(y * gw + x) * 4 + 3] > 100); } on.push(line); } const r = {w: gw, h: gh, on, char: ch}; hanziCache.set(key, r); return r; } // 把整串文字拆成字符点阵序列(含间距),返回 [{char, on, w, h}] function textToSequence(text) { const seq = []; const chars = [...text]; for (const ch of chars) { const bmp = charToBitmap(ch); seq.push({char: ch, on: bmp.on, w: bmp.w, h: bmp.h}); } return seq; } // 计算整行点阵的总宽(列数)与最大高(行数) function layout(seq, gap) { let total = 0, maxH = 0; for (let i = 0; i < seq.length; i++) { total += seq[i].w; if (i < seq.length - 1) total += gap; if (seq[i].h > maxH) maxH = seq[i].h; } return {total, maxH}; } // 主渲染:canvas 上绘制点阵,requestAnimationFrame 横向无缝滚动循环 const canvas = document.getElementById('led-canvas'); const ctx = canvas.getContext('2d'); let LED_COLOR = '#ff4d4d'; // LED 点阵颜色(可从后端读取,可设置) const GAP = 3; // 字符间距(点) const CHAR_GAP = 2; // 字符内点距 const DOT_RADIUS = 0.47; // 点半径占点距比例(接近 0.5 更饱满,圆形 LED) let seq = [], maxRows = 0; let speed = 60; // 像素/秒(CSS 像素) let offsetX = 0; // 当前水平偏移(CSS 像素,负=向左滚) let lastTs = 0; let dpr = 1; // 设备像素比 // 把 #rrggbb 颜色拆成 {r,g,b},方便做发光渐变 function hexToRgb(hex) { const m = /^#?([0-9a-f]{6})$/i.exec(hex || ''); if (!m) return {r: 255, g: 77, b: 77}; const n = parseInt(m[1], 16); return {r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255}; } // 按当前窗口逻辑高度计算点距(点阵单元尺寸) function dotSize(H) { // 上下安全边距(CSS 像素),保证首末行笔划完整显示、不被窗口裁剪 const pad = 6; const avail = Math.max(1, H - 2 * pad); // 点阵总高 = maxRows * (dot + CHAR_GAP),须 <= avail,否则垂直居中后 // baseY 为负、顶部笔划被裁。向下取整保证数学上不溢出。 let dot = Math.floor((avail - CHAR_GAP * maxRows) / maxRows); // 极高 DPI 缩放或窗口过矮导致可用高度不足以容纳满格点阵时, // 不强制抬升 dot(旧的 Math.max(3,...) 会把 dot 抬回 3,使总高溢出、 // baseY 变负、顶部笔划被窗口裁剪),而是允许缩小到最小 1, // 确保 baseY 不为负、点阵完整显示。 if (dot < 1) dot = 1; return dot; } // 整行文字的像素宽度(含字符间与末尾间隙),用于计算无缝循环周期 function totalPxWidth(dot) { let w = 0; for (const item of seq) { w += item.w * (dot + CHAR_GAP) + GAP * dot; // 每字符点阵宽 + 字后间隙 } return w; } // 绘制单个 LED 点:中心高亮 + 外圈光晕,模拟真实 LED 灯珠 function drawLedDot(cx, cy, r, color) { const rgb = hexToRgb(color); // 高亮中心(更亮) ctx.fillStyle = `rgba(${Math.min(255, rgb.r + 60)},${Math.min(255, rgb.g + 60)},${Math.min(255, rgb.b + 60)},1)`; ctx.beginPath(); ctx.arc(cx, cy, r * 0.72, 0, Math.PI * 2); ctx.fill(); // 外圈光晕(径向渐变:中心亮、边缘淡,聚焦发光感) const grad = ctx.createRadialGradient(cx, cy, r * 0.3, cx, cy, r); grad.addColorStop(0, `rgba(${rgb.r},${rgb.g},${rgb.b},0.85)`); grad.addColorStop(0.7, `rgba(${rgb.r},${rgb.g},${rgb.b},0.35)`); grad.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0.05)`); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); } // 绘制一遍文字,起始横坐标为 x0(CSS 像素) function drawText(x0, H, dot) { let x = x0; const cell = dot + CHAR_GAP; // 点阵单元尺寸 const r = dot * DOT_RADIUS; // 圆形点半径 for (const item of seq) { const startX = Math.round(x); // 垂直居中:让整行文字在窗口高度居中 const baseY = (H - item.h * cell + CHAR_GAP) / 2; for (let row = 0; row < item.h; row++) { for (let col = 0; col < item.w; col++) { if (item.on[row][col]) { const cx = startX + col * cell + dot / 2; const cy = baseY + row * cell + dot / 2; drawLedDot(cx, cy, r, LED_COLOR); } } } x += item.w * cell + GAP * dot; } } function renderFrame(ts) { if (!lastTs) lastTs = ts; const dt = (ts - lastTs) / 1000; lastTs = ts; // 统一用 CSS 像素坐标(ctx 已 setTransform(dpr),画布物理尺寸=CSS*dpr) const W = canvas.clientWidth, H = canvas.clientHeight; ctx.clearRect(0, 0, W, H); if (seq.length > 0 && W > 0 && H > 0) { const dot = dotSize(H); const totalW = totalPxWidth(dot); // 整行像素宽 const sep = Math.max(dot * 6, 80); // 两遍文字间的最小间距 const period = Math.max(totalW + sep, W + sep); // 循环周期:至少覆盖一个窗口宽,保证无缝 // 向左滚动,跨过整段周期后回绕,实现连续滚动 offsetX -= speed * dt; if (offsetX <= -period) offsetX += period; // 绘制两遍:第一遍 offsetX,第二遍 offsetX+period,窗口内始终有内容,无缝衔接 drawText(offsetX, H, dot); drawText(offsetX + period, H, dot); } requestAnimationFrame(renderFrame); } // 初始化:读取文字、颜色与速度、布局、设置画布尺寸 async function init() { console.log("LED_NEW_MARKER_2026"); // 验证 vite 是否读取新代码 // 页面已加载完成 => WebView2 控制器必然就绪,通知后端补设透明背景 // (Wails 创建窗口时设置的透明可能因控制器异步未就绪而丢失) SettingsService.SetLEDTransparent(); const [text, color, spd] = await Promise.all([ SettingsService.GetLEDText(), SettingsService.GetLEDColor(), SettingsService.GetLEDSpeed(), ]); LED_COLOR = color || '#ff4d4d'; if (spd > 0) speed = spd; seq = textToSequence(text || 'LED 滚动文字'); maxRows = layout(seq, GAP).maxH; dpr = window.devicePixelRatio || 1; // 设置画布物理尺寸并重置变换(赋值会清空矩阵,需每次重设) const setupCanvas = () => { canvas.width = Math.floor(canvas.clientWidth * dpr); canvas.height = Math.floor(canvas.clientHeight * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后所有绘制用 CSS 像素 }; setupCanvas(); // 窗口尺寸变化时重设画布(同样重设变换,避免 scale 丢失) new ResizeObserver(setupCanvas).observe(canvas); requestAnimationFrame(renderFrame); } // 关闭按钮 document.getElementById('led-close').addEventListener('click', () => { SettingsService.CloseLEDWindow(); }); init();