From aa1e14d4cbd0866263282f480dda73be06a3af86 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Mon, 21 Sep 2026 03:08:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(motor):=20=E7=A4=BA=E6=B3=A2=E5=99=A8?= =?UTF-8?q?=E5=A4=96=E6=8F=90=20scope.js=EF=BC=88P1=20=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E7=AC=AC=201=20=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从完好 main.js 机械外提示波器 IIFE 为 createScopeSys; STATUS 契约 offlineOverlay/statusLive 保持不动。 UI 验收:overlay display none、电机数据在、无控制台报错。 --- .gitignore | 3 + js/common/toast-stack.js | 27 ---- js/motor/main.js | 306 +-------------------------------------- js/motor/scope.js | 277 +++++++++++++++++++++++++++++++++++ pages/motor.html | 3 +- 5 files changed, 284 insertions(+), 332 deletions(-) delete mode 100644 js/common/toast-stack.js create mode 100644 js/motor/scope.js diff --git a/.gitignore b/.gitignore index 321ec70..e1aa45c 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ scripts/test_ver_*.py *.swp Thumbs.db desktop.ini + +# ±¾µØÏֳ¡±¸·ݣ¨±£Áô¿ÉÓÃǰ¶˿ìÕգ© +/_backup/ diff --git a/js/common/toast-stack.js b/js/common/toast-stack.js deleted file mode 100644 index 8f19457..0000000 --- a/js/common/toast-stack.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * toast-stack.js — 电机控制台堆叠 toast(#toastContainer) - * - * motor 页不用 common/toast.js 的单 toast 条,而是左下角堆叠条。 - * setQuietMode 从 utils.js re-export,保证全仓只有一份实现。 - */ -"use strict"; - -import { setQuietMode } from "./utils.js"; - -export { setQuietMode }; - -/** @description 创建电机页 toast 工厂。默认容器 #toastContainer。 */ -export function createToastStack(containerSelector = "#toastContainer") { - const $ = (s) => document.querySelector(s); - return function toast(msg, type = "ok") { - const host = $(containerSelector); - if (!host) return; - const el = document.createElement("div"); - el.className = `toast ${type}`; - const ledCls = type === "ok" ? "led-on" : type === "warn" ? "led-warn" : "led-err"; - el.innerHTML = ``; - el.lastElementChild.textContent = msg; - host.appendChild(el); - setTimeout(() => { el.classList.add("out"); setTimeout(() => el.remove(), 220); }, 3000); - }; -} diff --git a/js/motor/main.js b/js/motor/main.js index ed70332..6d46413 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -50,6 +50,7 @@ import { defaultRefreshMap, normalizeRefreshMap, pickRefresh, minRefreshFor, isUnthrottled } from "./refresh-rate.js"; +import { createScopeSys } from "./scope.js?v=20260924d"; /* ─── 控制模式定义 ─── */ const MODES = { @@ -4254,311 +4255,8 @@ refreshRateInput.addEventListener("input", () => { }); /* ═══════════════════════ 示波器系统 ═══════════════════════ */ -const scopeSys = (() => { - 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); +const scopeSys = createScopeSys({ state, motorKey, clampNum, $ }); - 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 }; -})(); /* ═══════════════════════ 初始化 ═══════════════════════ */ /* 传输必须最先建:updateConnUi / renderStatusPanel 等都会读 state.tp。 */ diff --git a/js/motor/scope.js b/js/motor/scope.js new file mode 100644 index 0000000..432e410 --- /dev/null +++ b/js/motor/scope.js @@ -0,0 +1,277 @@ +/** + * scope.js — 电机控制台示波器(三相大图 + 迷你卡片波形) + * 从 main.js 示波器 IIFE 机械外提;行为不变。 + * createScopeSys(ctx) 注入 state / motorKey / clampNum / $。 + * 返回 { reset }:切电机/切通道时清历史。 + */ +"use strict"; + +/** @param {{state:any, motorKey:(m:any)=>string, clampNum:(v:number,lo:number,hi:number)=>number, $:(s:string)=>Element|null}} ctx */ +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; + 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); + + 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; + } + function fit(canvas, c2d) { + 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; + } + c2d.setTransform(dpr, 0, 0, dpr, 0, 0); + return s; + } + function grid(c2d, w, h) { + c2d.strokeStyle = "rgba(255,255,255,0.05)"; + c2d.lineWidth = 1; + for (let i = 1; i < 4; i++) { const y = h / 4 * i; c2d.beginPath(); c2d.moveTo(0, y); c2d.lineTo(w, y); c2d.stroke(); } + for (let i = 1; i < 6; i++) { const x = w / 6 * i; c2d.beginPath(); c2d.moveTo(x, 0); c2d.lineTo(x, h); c2d.stroke(); } + c2d.strokeStyle = "rgba(255,255,255,0.13)"; + c2d.beginPath(); + c2d.moveTo(0, h / 2); c2d.lineTo(w, h / 2); c2d.stroke(); + } + const scratchY = new Float32Array(BUF); + const okAt = (i) => valid[(head + i) % BUF] === 1; + function drawTrace(c2d, 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) => { + c2d.beginPath(); + c2d.moveTo(xOf(a), scratchY[a]); + for (let i = a + 1; i <= b; i++) c2d.lineTo(xOf(i), scratchY[i]); + }; + c2d.lineJoin = "round"; c2d.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); + c2d.lineTo(xOf(end), base); c2d.lineTo(xOf(start), base); c2d.closePath(); + c2d.fillStyle = color + "16"; + c2d.fill(); + } + linePath(start, end); + c2d.setLineDash(solid ? [] : [5, 4]); + if (solid) { + c2d.globalAlpha = 0.16; c2d.lineWidth = lw * 3.2; c2d.stroke(); + c2d.globalAlpha = 1; + } + c2d.lineWidth = lw; c2d.stroke(); + start = end; + } + } + function trace(c2d, buf, color, range, w, h, lw, fill) { + const mid = h / 2, gain = (h / 2 - 4) / range; + drawTrace(c2d, buf, color, (v) => mid - Math.max(-range, Math.min(range, v)) * gain, mid, w, lw, fill); + } + function traceUni(c2d, buf, color, min, max, w, h, lw, fill) { + const span = max - min; + drawTrace(c2d, 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); + } + + const MINIS = { + angle: { el: $("#miniAngle"), buf: hist.angle, auto: true, unit: "°", 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: "°C", color: () => C.v }, + motTemp: { el: $("#miniMotTemp"), buf: hist.motTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "°C", color: () => C.w }, + }; + 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)); + + 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] }; + } + 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] }; + } + const span = hi - lo; + const pad = span * 0.1 || 1; + let min = lo - pad, max = hi + pad; + 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 }; + } + + 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(c2d, m, w, h) { + 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); + c2d.save(); + c2d.font = "8px " + MONO; + c2d.textBaseline = "middle"; + for (const t of m.ticks) { + const y = yOf(t); + c2d.strokeStyle = "rgba(226,233,242,0.35)"; + c2d.lineWidth = 1; + c2d.beginPath(); c2d.moveTo(0, y); c2d.lineTo(5, y); c2d.stroke(); + c2d.fillStyle = "rgba(226,233,242,0.5)"; + c2d.fillText(fmtTick(Math.round(t)), 7, y); + } + if (m.unit) { + c2d.textBaseline = "top"; + c2d.fillStyle = "rgba(226,233,242,0.35)"; + c2d.fillText(m.unit, w - c2d.measureText(m.unit).width - 4, 3); + } + c2d.restore(); + } + + function drawMini(m) { + const s = fit(m.el, m.ctx); + if (!s) return; + const w = s.w, h = s.h; + 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")); + }); + + 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 { + 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; + 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 }; +} diff --git a/pages/motor.html b/pages/motor.html index 4cf48dd..124e9c7 100644 --- a/pages/motor.html +++ b/pages/motor.html @@ -430,7 +430,8 @@ - + +