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 @@
-
+
+