Files
DCU_DesktopController/js/motor/scope.js
T
cbb 261caf50b7 refactor(motor): P1 拆分 main.js + 收敛连接/总线状态机
R4 外提 scope/ota/seq/conn-poll;R5 waitVersion/queryNodeIdParam 下沉
transport;R6 ecat-bus 单点 bootstrap;R7 toast-stack + utils 去重。
main.js 4647→~1894 行。?v= 已 bump 20260924a。
2026-09-21 02:26:39 +08:00

321 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}