/** * scope.js — 电机控制台示波器(波形缓冲 + canvas 绘制) * 从 main.js 外提的示波器 IIFE,逻辑行为不变。 * createScopeSys(ctx) 注入 state / motorKey / clampNum / $ 依赖。 */ "use strict"; /** * @param {{ state: object, motorKey: (m:any)=>string, clampNum:(v:number,lo:number,hi:number)=>number, $:(s:string)=>Element|null }} ctx * @returns {{ reset: () => void }} */ export function createScopeSys(ctx) { const { state, motorKey, clampNum, $ } = ctx; const BUF = 300; const dprOf = () => window.devicePixelRatio || 1; const hist = { u: new Float32Array(BUF), v: new Float32Array(BUF), w: new Float32Array(BUF), angle: new Float32Array(BUF), velocity: new Float32Array(BUF), current: new Float32Array(BUF), mcuTemp: new Float32Array(BUF), motTemp: new Float32Array(BUF), }; let head = 0; /* 样本有效性:1 = 来自在线电机;0 = 离线补零(绘制为虚线段) */ const valid = new Uint8Array(BUF); function colors() { const cs = getComputedStyle(document.documentElement); return { u: cs.getPropertyValue("--seed-primary").trim() || "#22d3ee", v: "#34d399", w: cs.getPropertyValue("--seed-accent").trim() || "#f59e0b", }; } let C = colors(); setInterval(() => { C = colors(); }, 2000); /* 尺寸缓存:fit 每帧调一次 getBoundingClientRect = 每秒 160 次强制同步布局。 * 改成 ResizeObserver 只在真变化时量一次;borderBoxSize 与原来的 * getBoundingClientRect 同口径(迷你波形的 canvas 有 1px 边框, * 用 contentRect 会让几何悄悄缩 2px)。 */ const sizes = new WeakMap(); let sizeObs = null; function cssSize(canvas) { let s = sizes.get(canvas); if (s) return s; const r = canvas.getBoundingClientRect(); s = { w: r.width, h: r.height }; sizes.set(canvas, s); if (!sizeObs) { sizeObs = new ResizeObserver((entries) => { for (const e of entries) { const t = sizes.get(e.target); if (!t) continue; const b = e.borderBoxSize && e.borderBoxSize[0]; t.w = b ? b.inlineSize : e.contentRect.width; t.h = b ? b.blockSize : e.contentRect.height; } }); } sizeObs.observe(canvas); return s; } /** 对齐 backing store 与 CSS 尺寸;返回 CSS 尺寸,宽度为 0(未显示)时返回 null。 */ function fit(canvas, ctx) { const dpr = dprOf(); const s = cssSize(canvas); if (s.w === 0) return null; const cw = Math.round(s.w * dpr), ch = Math.round(s.h * dpr); if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return s; } function grid(ctx, w, h) { ctx.strokeStyle = "rgba(255,255,255,0.05)"; ctx.lineWidth = 1; for (let i = 1; i < 4; i++) { const y = h / 4 * i; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } for (let i = 1; i < 6; i++) { const x = w / 6 * i; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } ctx.strokeStyle = "rgba(255,255,255,0.13)"; ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke(); } /* 分段描线:valid 样本连成实线(发光 + 可选基线填充);无数据样本(离线 * 补零段)画虚线、无发光无填充 —— 数据停更一眼可辨。相邻样本任一无效, * 该连线即按虚线处理;边界点同时属于两段,段间不脱节。 */ /* 每帧 new 300 个点对象是 160Hz 下最大的分配源(一次全量重绘 ≈2400 个对象, * 也正是工作集一路涨到几 GB 的原因)。改成把 y 写进复用的 scratch 数组, * x 因为等间距直接算出来,不再存。 */ const scratchY = new Float32Array(BUF); const okAt = (i) => valid[(head + i) % BUF] === 1; function drawTrace(ctx, buf, color, yOf, base, w, lw, fill) { const xOf = (i) => i / (BUF - 1) * w; for (let i = 0; i < BUF; i++) scratchY[i] = yOf(buf[(head + i) % BUF]); const linePath = (a, b) => { ctx.beginPath(); ctx.moveTo(xOf(a), scratchY[a]); for (let i = a + 1; i <= b; i++) ctx.lineTo(xOf(i), scratchY[i]); }; ctx.lineJoin = "round"; ctx.strokeStyle = color; let start = 0; while (start < BUF - 1) { const solid = okAt(start) && okAt(start + 1); let end = start + 1; while (end + 1 < BUF && (okAt(end) && okAt(end + 1)) === solid) end++; if (solid && fill) { linePath(start, end); ctx.lineTo(xOf(end), base); ctx.lineTo(xOf(start), base); ctx.closePath(); ctx.fillStyle = color + "16"; ctx.fill(); } linePath(start, end); ctx.setLineDash(solid ? [] : [5, 4]); /* 发光:同一条路径先描一道宽而淡的底。原来的 shadowBlur 是逐像素模糊, * 视觉上两者等价,成本差一个量级。 */ if (solid) { ctx.globalAlpha = 0.16; ctx.lineWidth = lw * 3.2; ctx.stroke(); ctx.globalAlpha = 1; } ctx.lineWidth = lw; ctx.stroke(); start = end; } } function trace(ctx, buf, color, range, w, h, lw, fill) { const mid = h / 2, gain = (h / 2 - 4) / range; drawTrace(ctx, buf, color, (v) => mid - Math.max(-range, Math.min(range, v)) * gain, mid, w, lw, fill); } function traceUni(ctx, buf, color, min, max, w, h, lw, fill) { const span = max - min; drawTrace(ctx, buf, color, (v) => h - 4 - Math.max(0, Math.min(1, (v - min) / span)) * (h - 8), h, w, lw, fill); } /* 大三相示波器 */ const big = $("#scopeCanvas"); const bigCtx = big.getContext("2d"); function drawBig() { const s = fit(big, bigCtx); if (!s) return; const w = s.w, h = s.h; bigCtx.clearRect(0, 0, w, h); grid(bigCtx, w, h); trace(bigCtx, hist.u, C.u, 5000, w, h, 1.6, false); trace(bigCtx, hist.v, C.v, 5000, w, h, 1.6, false); trace(bigCtx, hist.w, C.w, 5000, w, h, 1.6, false); } /* 迷你示波器(每卡片独立) * angle 用 auto: true 自适应 Y 轴(角度可能 >360 多圈累计或为负, * 固定 0~360 会让波形贴顶/贴底或越界不可见)。 * 其余卡片量纲固定(velocity/current 对称范围,temp 单极性),保留 uni/range。 */ const MINIS = { angle: { el: $("#miniAngle"), buf: hist.angle, auto: true, unit: "\u00B0", color: () => C.u }, velocity: { el: $("#miniVelocity"), buf: hist.velocity, auto: true, unit: "deg/s", color: () => C.v }, current: { el: $("#miniCurrent"), buf: hist.current, auto: true, unit: "mA", color: () => C.w }, mcuTemp: { el: $("#miniMcuTemp"), buf: hist.mcuTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.v }, motTemp: { el: $("#miniMotTemp"), buf: hist.motTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.w }, }; /* ctx 与宿主卡片引用都在初始化时取一次:卡片 DOM 是静态的, * 原来每帧每个 mini 都要 querySelector 一遍(6 个查询 × 30 次/s)。 */ const MINI_LIST = Object.entries(MINIS).map(([sig, m]) => { m.ctx = m.el.getContext("2d"); m.card = document.querySelector(`[data-signal="${sig}"]`); return m; }); const MONO = '"Cascadia Code","Fira Code",Consolas,monospace'; const fmtTick = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "k" : String(v)); /* 自适应 Y 轴:扫 buf 取实际数据范围,加 10% padding,生成 3 个 nice ticks。 * 用于 angle/velocity/current(各自范围不确定,固定轴会让波形拟合成直线或越界)。 * 空缓冲/全零时用小范围居中显示,避免空白卡片无刻度。 */ function autoScale(buf) { let lo = Infinity, hi = -Infinity, hasNonZero = false; for (let i = 0; i < buf.length; i++) { const v = buf[i]; if (!Number.isFinite(v)) continue; if (v < lo) lo = v; if (v > hi) hi = v; if (v !== 0) hasNonZero = true; } /* 无有效数据或全零:小范围居中,等有数据后自然扩展 */ if (!hasNonZero || !Number.isFinite(lo) || !Number.isFinite(hi)) { return { min: -1, max: 1, ticks: [1, 0, -1] }; } /* 平坦线(lo===hi):围绕该值拓展小范围 */ if (lo === hi) { const c = lo; const pad = Math.abs(c) * 0.2 || 1; return { min: c - pad, max: c + pad, ticks: [c + pad, c, c - pad] }; } /* 留 10% padding,避免波形贴顶/贴底 */ const span = hi - lo; const pad = span * 0.1 || 1; let min = lo - pad, max = hi + pad; /* nice 化:把范围对齐到 1/2/5 整数倍刻度,ticks 取三等分点 */ const niceStep = niceStepSize(max - min, 3); min = Math.floor(min / niceStep) * niceStep; max = Math.ceil(max / niceStep) * niceStep; const ticks = [max, (max + min) / 2, min]; return { min, max, ticks }; } /* 计算 nice 刻度步长:让 (max-min)/step ≈ targetTicks,并对齐到 1/2/5×10^k。 */ function niceStepSize(range, targetTicks) { const rawStep = range / targetTicks; const mag = Math.pow(10, Math.floor(Math.log10(rawStep))); const norm = rawStep / mag; let step; if (norm < 1.5) step = 1; else if (norm < 3) step = 2; else if (norm < 7) step = 5; else step = 10; return step * mag; } function drawYScale(ctx, m, w, h) { /* auto 模式:drawMini 已把 m.uni/ticks 算好填入;其余模式用静态配置 */ const yOf = m.uni ? (v) => h - 4 - Math.max(0, Math.min(1, (v - m.uni.min) / (m.uni.max - m.uni.min))) * (h - 8) : (v) => h / 2 - Math.max(-m.range, Math.min(m.range, v)) * ((h / 2 - 4) / m.range); ctx.save(); ctx.font = "8px " + MONO; ctx.textBaseline = "middle"; for (const t of m.ticks) { const y = yOf(t); ctx.strokeStyle = "rgba(226,233,242,0.35)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(5, y); ctx.stroke(); ctx.fillStyle = "rgba(226,233,242,0.5)"; ctx.fillText(fmtTick(Math.round(t)), 7, y); } if (m.unit) { ctx.textBaseline = "top"; ctx.fillStyle = "rgba(226,233,242,0.35)"; ctx.fillText(m.unit, w - ctx.measureText(m.unit).width - 4, 3); } ctx.restore(); } function drawMini(m) { const s = fit(m.el, m.ctx); if (!s) return; const w = s.w, h = s.h; /* auto 模式:每帧根据 buf 实际数据重算 Y 轴范围 + ticks */ if (m.auto) { const sc = autoScale(m.buf); m.uni = { min: sc.min, max: sc.max }; m.ticks = sc.ticks; } m.ctx.clearRect(0, 0, w, h); grid(m.ctx, w, h); if (m.uni) traceUni(m.ctx, m.buf, m.color(), m.uni.min, m.uni.max, w, h, 1.8, true); else trace(m.ctx, m.buf, m.color(), m.range, w, h, 1.8, true); drawYScale(m.ctx, m, w, h); } /* 点击卡片 → 只切换自己的波形 */ document.querySelectorAll(".big-reading, .temp-block").forEach((card) => { card.addEventListener("click", () => card.classList.toggle("open")); }); /* 采样:用真实解码的 STATUS 数据填充历史缓冲(1Hz 数据 → 平滑插值)。 * node×channel 双键:只采当前通道的记录,避免波形混入其他通道同号电机数据。 */ function sample() { const n = state.selectedNode; const motor = n != null ? state.motors.find((m) => m.node === n && m.ch === state.ch) : null; const d = motor ? state.decoded.get(motorKey(motor)) : null; const online = d && motor && motor.online; valid[head] = online ? 1 : 0; if (!online) { hist.u[head] = hist.v[head] = hist.w[head] = 0; hist.angle[head] = hist.velocity[head] = hist.current[head] = 0; hist.mcuTemp[head] = hist.motTemp[head] = 0; } else { /* 三相电流:用解码的 U/V/W 瞬时值(mA) */ const targets = { u: d.uCurrent, v: d.vCurrent, w: d.wCurrent }; for (const ch of ["u", "v", "w"]) { const prev = hist[ch][(head + BUF - 1) % BUF]; hist[ch][head] = prev + (clampNum(targets[ch], -5000, 5000) - prev) * 0.5; } const pA = hist.angle[(head + BUF - 1) % BUF]; hist.angle[head] = pA + (d.rtAngle - pA) * 0.4; const pV = hist.velocity[(head + BUF - 1) % BUF]; hist.velocity[head] = pV + (d.rtVelocity - pV) * 0.4; const pC = hist.current[(head + BUF - 1) % BUF]; hist.current[head] = pC + (d.rtCurrent - pC) * 0.4; const pMcu = hist.mcuTemp[(head + BUF - 1) % BUF]; hist.mcuTemp[head] = pMcu + (d.mcuTemp - pMcu) * 0.1; const pMot = hist.motTemp[(head + BUF - 1) % BUF]; hist.motTemp[head] = pMot + (d.motTemp - pMot) * 0.1; } head = (head + 1) % BUF; } let lastSample = 0; /* 只有写入了新样本才重绘:采样节流在 33ms(≈30Hz),而 rAF 跟着显示器跑到 * 160Hz —— 中间那 ~130 帧画的是同一份数据,像素一模一样,纯属白烧 GPU 进程。 */ let dirty = true; function loop(ts) { if (ts - lastSample > 33) { sample(); lastSample = ts; dirty = true; } if (dirty) { dirty = false; drawBig(); for (const m of MINI_LIST) { if (m.card && m.card.classList.contains("open")) drawMini(m); } } requestAnimationFrame(loop); } requestAnimationFrame(loop); /* 清空历史(切电机/切通道时调用):缓冲清零 + 全部标记无效,波形回到 * “无数据”虚线态,避免新旧电机的数据混在同一条曲线里慢慢冲刷。 */ function reset() { for (const key of Object.keys(hist)) hist[key].fill(0); valid.fill(0); } return { reset }; return { reset }; }