From 8a51629edbe682de8a0fd30a82894e0420ac6c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E8=96=84=E4=BA=91?= Date: Fri, 7 Aug 2026 10:05:43 +0800 Subject: [PATCH] feat: add observation chart viewport zoom --- .../Web/app.css | 8 +- .../Web/app.js | 135 +++++++++++++++++- .../dashboard.dom.test.mjs | 135 ++++++++++++++++++ 3 files changed, 271 insertions(+), 7 deletions(-) diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css index 9cf1062..1120262 100644 --- a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css @@ -23,7 +23,13 @@ main { max-width: 1500px; margin: auto; padding: 22px 30px 36px; } #occupancy-grid, #world-overlay { position: absolute; inset: 0; width: 100%; height: 100%; } #world-overlay { pointer-events: none; } main > section[hidden] { display: none !important; } #ls-st:not([hidden]), #kinematics:not([hidden]) { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 18px; } -.chart { min-height: 280px; border: 1px solid var(--grid); padding: 8px; } .chart svg { display: block; width: 100%; height: 244px; } +.chart { position: relative; min-height: 280px; border: 1px solid var(--grid); padding: 8px; } .chart svg { display: block; width: 100%; height: 244px; touch-action: none; } +.chart-tools { position: absolute; top: 6px; right: 6px; display: flex; gap: 6px; } +.chart-tools button { width: 26px; height: 26px; border: 1px solid var(--grid); background: #fff; color: #59636d; cursor: pointer; font: 12px/1 sans-serif; } +.chart-tools button:hover { border-color: #8d959d; color: #20252b; } +.chart-zoom-box { fill: rgba(23, 105, 170, .08); stroke: #1769aa; stroke-width: .8; } +.chart.is-fullscreen { position: fixed; inset: 0; z-index: 100; min-height: 100vh; padding: 18px; overflow: auto; background: var(--paper); } +.chart.is-fullscreen svg { height: calc(100vh - 110px); min-height: 320px; } .chart-title { font-size: 14px; font-weight: 600; } .chart-note { min-height: 20px; color: #66707a; font-size: 12px; } .chart-grid { stroke: var(--grid); stroke-width: .55; } .chart-axis { stroke: #59636d; stroke-width: .8; } .chart-data { fill: none; stroke-width: 1.1; vector-effect: non-scaling-stroke; } .chart-solid { stroke: var(--current-trajectory); } .chart-dashed { stroke: var(--previous-trajectory); stroke-dasharray: 5 4; } .chart-limit { stroke: var(--failure); stroke-dasharray: 3 3; } diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js index 4062da1..c2ab419 100644 --- a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js @@ -2,7 +2,9 @@ "use strict"; const token = new URLSearchParams(window.location.search).get("token") || ""; const svgNs = "http://www.w3.org/2000/svg"; - const state = { staticSnapshot: null, frame: null, history: [], refreshRateHz: 10, ended: false, activeTab: "overview", redrawEnabled: {} }; + const chartWidth = 640, chartHeight = 244, chartLeft = 58, chartRight = 16, chartTop = 8, chartBottom = 36; + const chartPlotWidth = chartWidth - chartLeft - chartRight, chartPlotHeight = chartHeight - chartTop - chartBottom; + const state = { staticSnapshot: null, frame: null, history: [], refreshRateHz: 10, ended: false, activeTab: "overview", redrawEnabled: {}, chartViewports: {} }; const liveState = document.getElementById("live-state"); const occupancyCanvas = document.getElementById("occupancy-grid"); const overlay = document.getElementById("world-overlay"); @@ -117,13 +119,134 @@ const abs = Math.abs(value); const precision = abs >= 100 ? 1 : abs >= 1 ? 2 : 4; return Number(value.toFixed(precision)).toString(); } - function renderChart(id, chart) { - const host = document.getElementById(id); if (!host) return; host.replaceChildren(); const title = element("div", "chart-title", chart ? chart.chineseTitle : id); const noteText = id === "jerk-t" ? "末点后无时间区间" : chart ? chart.noteChinese : "等待当前周期数据"; const note = element("div", "chart-note", noteText); host.append(title, note); + function fullChartDomain(chart) { const all = chart ? safeArray(chart.series).flatMap(series => safeArray(series.points)) : []; const xDomain = niceDomain(all.map(point => point.x)), yDomain = niceDomain(all.map(point => point.y)); - const domain = { x0: xDomain.min, x1: xDomain.max, y0: yDomain.min, y1: yDomain.max }; - const width = 640, height = 244, left = 58, right = 16, top = 8, bottom = 36; - const plotWidth = width - left - right, plotHeight = height - top - bottom; + return { x0: xDomain.min, x1: xDomain.max, y0: yDomain.min, y1: yDomain.max }; + } + function currentChartDomain(id, chart) { + const viewport = state.chartViewports[id]; + if (viewport && finite(viewport.x0) && finite(viewport.x1) && finite(viewport.y0) && finite(viewport.y1) && viewport.x1 > viewport.x0 && viewport.y1 > viewport.y0) return viewport; + return fullChartDomain(chart); + } + function chartClientGeometry(host) { + const svgNode = host.querySelector("svg"); + const rect = svgNode && typeof svgNode.getBoundingClientRect === "function" ? svgNode.getBoundingClientRect() : null; + const usable = rect && rect.width > 0 && rect.height > 0 ? rect : null; + const width = usable ? usable.width : chartWidth, height = usable ? usable.height : chartHeight; + const left = usable ? usable.left : 0, top = usable ? usable.top : 0; + const scaleX = width / chartWidth, scaleY = height / chartHeight; + return { left, top, scaleX, scaleY, plotWidth: width - (chartLeft + chartRight) * scaleX, plotHeight: height - (chartTop + chartBottom) * scaleY }; + } + function clientToData(host, domain, clientX, clientY) { + const geometry = chartClientGeometry(host); + if (!(geometry.plotWidth > 0) || !(geometry.plotHeight > 0)) return { x: (domain.x0 + domain.x1) / 2, y: (domain.y0 + domain.y1) / 2 }; + const x = domain.x0 + (clientX - geometry.left - chartLeft * geometry.scaleX) * (domain.x1 - domain.x0) / geometry.plotWidth; + const y = domain.y1 - (clientY - geometry.top - chartTop * geometry.scaleY) * (domain.y1 - domain.y0) / geometry.plotHeight; + return { x, y }; + } + function dataToChartPoint(domain, point) { + return { + x: chartLeft + (point.x - domain.x0) * chartPlotWidth / (domain.x1 - domain.x0), + y: chartTop + (domain.y1 - point.y) * chartPlotHeight / (domain.y1 - domain.y0) + }; + } + function updateZoomBox(host, id, start, current) { + const graph = host.querySelector("svg"); + if (!graph) return; + graph.querySelectorAll(".chart-zoom-box").forEach(node => node.remove()); + if (!start || !current) return; + const domain = currentChartDomain(id, chartById(id)); + const a = dataToChartPoint(domain, clientToData(host, domain, start.startX, start.startY)); + const b = dataToChartPoint(domain, clientToData(host, domain, current.clientX, current.clientY)); + const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y), width = Math.abs(a.x - b.x), height = Math.abs(a.y - b.y); + graph.append(svg("rect", { x, y, width, height, class: "chart-zoom-box" })); + } + function installChartInteractions(host, id) { + if (host.__chartInteractionsInstalled) return; + host.__chartInteractionsInstalled = true; + host.addEventListener("click", event => { + const button = event.target.closest && event.target.closest("[data-action]"); + if (!button) return; + const action = button.dataset.action; + if (action === "reset") { + delete state.chartViewports[id]; + renderChart(id, chartById(id)); + } else if (action === "fullscreen") { + document.querySelectorAll(".chart.is-fullscreen").forEach(node => node.classList.remove("is-fullscreen")); + if (typeof host.requestFullscreen === "function") { try { host.requestFullscreen(); } catch (error) { /* fall back to CSS fullscreen */ } } + host.classList.add("is-fullscreen"); + } + }); + let drag = null; + host.addEventListener("wheel", event => { + const chart = chartById(id); + if (!chart || (event.target.closest && event.target.closest("button"))) return; + event.preventDefault(); + const domain = currentChartDomain(id, chart); + const anchor = clientToData(host, domain, event.clientX, event.clientY); + const factor = event.deltaY > 0 ? 1.2 : 1 / 1.2; + const next = { + x0: anchor.x - (anchor.x - domain.x0) * factor, + x1: anchor.x + (domain.x1 - anchor.x) * factor, + y0: anchor.y - (anchor.y - domain.y0) * factor, + y1: anchor.y + (domain.y1 - anchor.y) * factor + }; + if (next.x1 > next.x0 && next.y1 > next.y0) { + state.chartViewports[id] = next; + renderChart(id, chartById(id)); + } + }, { passive: false }); + host.addEventListener("pointerdown", event => { + if (!chartById(id) || event.button !== 0 || (event.target.closest && event.target.closest("button"))) return; + drag = { startX: event.clientX, startY: event.clientY, pointerId: event.pointerId }; + event.preventDefault(); + if (typeof host.setPointerCapture === "function") host.setPointerCapture(event.pointerId); + }); + host.addEventListener("pointermove", event => { + if (!drag || event.pointerId !== drag.pointerId) return; + updateZoomBox(host, id, drag, { clientX: event.clientX, clientY: event.clientY }); + }); + host.addEventListener("pointerup", event => { + if (!drag || event.pointerId !== drag.pointerId) return; + const chart = chartById(id); + const distance = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY); + if (chart && distance >= 4) { + const domain = currentChartDomain(id, chart); + const start = clientToData(host, domain, drag.startX, drag.startY); + const end = clientToData(host, domain, event.clientX, event.clientY); + const next = { + x0: Math.min(start.x, end.x), + x1: Math.max(start.x, end.x), + y0: Math.min(start.y, end.y), + y1: Math.max(start.y, end.y) + }; + if (next.x1 > next.x0 && next.y1 > next.y0) state.chartViewports[id] = next; + } + drag = null; + renderChart(id, chartById(id)); + }); + host.addEventListener("pointercancel", () => { drag = null; renderChart(id, chartById(id)); }); + } + function renderChart(id, chart) { + const host = document.getElementById(id); if (!host) return; host.replaceChildren(); const title = element("div", "chart-title", chart ? chart.chineseTitle : id); const noteText = id === "jerk-t" ? "末点后无时间区间" : chart ? chart.noteChinese : "等待当前周期数据"; const note = element("div", "chart-note", noteText); host.append(title, note); + const tools = element("div", "chart-tools"); + const resetButton = element("button", "", "↺"); + resetButton.type = "button"; + resetButton.dataset.action = "reset"; + resetButton.title = "重置视图"; + resetButton.setAttribute("aria-label", "重置视图"); + const fullscreenButton = element("button", "", "⛶"); + fullscreenButton.type = "button"; + fullscreenButton.dataset.action = "fullscreen"; + fullscreenButton.title = "全屏"; + fullscreenButton.setAttribute("aria-label", "全屏"); + tools.append(resetButton, fullscreenButton); + host.append(tools); + installChartInteractions(host, id); + const domain = currentChartDomain(id, chart); + const width = chartWidth, height = chartHeight, left = chartLeft, right = chartRight, top = chartTop, bottom = chartBottom; + const plotWidth = chartPlotWidth, plotHeight = chartPlotHeight; const sx = value => left + (value - domain.x0) * plotWidth / (domain.x1 - domain.x0); const sy = value => height - bottom - (value - domain.y0) * plotHeight / (domain.y1 - domain.y0); const graph = svg("svg", { viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": chart ? chart.chineseTitle : id }); for (let tick = 0; tick <= 4; tick += 1) { const x = left + tick * plotWidth / 4, y = top + tick * plotHeight / 4; diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationWebDom/dashboard.dom.test.mjs b/ClumsyPilot/tests/TrajectoryPlanningVisualizationWebDom/dashboard.dom.test.mjs index c28cb10..dd486cc 100644 --- a/ClumsyPilot/tests/TrajectoryPlanningVisualizationWebDom/dashboard.dom.test.mjs +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationWebDom/dashboard.dom.test.mjs @@ -234,6 +234,47 @@ function loadApp({ staticSnapshot = makeStaticSnapshot(), dynamicSnapshot = make return dom; } +function deepFreeze(value) { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + Object.values(value).forEach(deepFreeze); + } + return value; +} + +function dispatchPointer(target, type, clientX, clientY, options = {}) { + const window = target.ownerDocument.defaultView; + const EventCtor = window.PointerEvent || window.MouseEvent; + target.dispatchEvent(new EventCtor(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + button: 0, + clientX, + clientY, + ...options + })); +} + +function dispatchWheel(target, clientX, clientY, deltaY) { + const window = target.ownerDocument.defaultView; + const EventCtor = window.WheelEvent || window.MouseEvent; + target.dispatchEvent(new EventCtor("wheel", { + bubbles: true, + cancelable: true, + clientX, + clientY, + deltaY + })); +} + +function chartSnapshot(host) { + return { + ticks: [...host.querySelectorAll(".axis-tick-x")].map(tick => tick.textContent).join("|"), + path: host.querySelector("path.chart-data")?.getAttribute("d") || "" + }; +} + test("tab switching hides inactive sections despite CSS grid rules", () => { const dom = loadApp(); const { document, getComputedStyle } = dom.window; @@ -331,3 +372,97 @@ test("test mode exposes frozen hooks without fetch or SSE", () => { } assert.equal("state" in window.__trajectoryVisualizationTestHooks, false); }); + +test("wheel and box zoom change ST ticks and path without mutating frozen snapshot", () => { + const dynamicSnapshot = deepFreeze(makeDynamicSnapshot()); + const serializedBefore = JSON.stringify(dynamicSnapshot); + const dom = loadApp({ dynamicSnapshot }); + const { document } = dom.window; + document.querySelector('button[data-tab="ls-st"]').click(); + const st = document.querySelector("#st"); + const svg = st.querySelector("svg"); + const before = chartSnapshot(st); + + dispatchPointer(svg, "pointerdown", 400, 100); + dispatchPointer(svg, "pointermove", 360, 80); + dispatchPointer(svg, "pointerup", 360, 80); + + const afterBox = chartSnapshot(st); + assert.notEqual(afterBox.ticks, before.ticks); + assert.notEqual(afterBox.path, before.path); + + dispatchWheel(st.querySelector("svg"), 300, 100, -120); + + const afterWheel = chartSnapshot(st); + assert.notEqual(afterWheel.ticks, afterBox.ticks); + assert.notEqual(afterWheel.path, afterBox.path); + assert.equal(JSON.stringify(dynamicSnapshot), serializedBefore); +}); + +test("box zoom ignores drags shorter than 4 CSS pixels", () => { + const dom = loadApp(); + const { document } = dom.window; + document.querySelector('button[data-tab="ls-st"]').click(); + const st = document.querySelector("#st"); + const svg = st.querySelector("svg"); + const before = chartSnapshot(st); + + dispatchPointer(svg, "pointerdown", 200, 100); + dispatchPointer(svg, "pointermove", 202, 102); + dispatchPointer(svg, "pointerup", 202, 102); + + assert.deepEqual(chartSnapshot(st), before); +}); + +test("reset restores the original chart data domain", () => { + const dom = loadApp(); + const { document } = dom.window; + document.querySelector('button[data-tab="ls-st"]').click(); + const st = document.querySelector("#st"); + const before = chartSnapshot(st); + + dispatchWheel(st.querySelector("svg"), 300, 100, -120); + + const zoomed = chartSnapshot(st); + assert.notEqual(zoomed.ticks, before.ticks); + + st.querySelector('button[data-action="reset"]').click(); + assert.deepEqual(chartSnapshot(st), before); +}); + +test("fullscreen requests only the selected chart host", () => { + const dom = loadApp(); + const { document, Element } = dom.window; + const requested = []; + Element.prototype.requestFullscreen = function requestFullscreen() { + requested.push(this.id); + }; + document.querySelector('button[data-tab="ls-st"]').click(); + const st = document.querySelector("#st"); + const ls = document.querySelector("#ls"); + + st.querySelector('button[data-action="fullscreen"]').click(); + + assert.deepEqual(requested, ["st"]); + assert.equal(st.classList.contains("is-fullscreen"), true); + assert.equal(ls.classList.contains("is-fullscreen"), false); +}); + +test("new SSE frame keeps an active viewport and leaves its snapshot unchanged", () => { + const first = deepFreeze(makeDynamicSnapshot()); + const dom = loadApp({ dynamicSnapshot: first }); + const { document } = dom.window; + document.querySelector('button[data-tab="ls-st"]').click(); + const st = document.querySelector("#st"); + dispatchWheel(st.querySelector("svg"), 300, 100, -120); + const zoomed = chartSnapshot(st); + + const second = JSON.parse(JSON.stringify(first)); + second.sequence = 2; + deepFreeze(second); + const serializedSecond = JSON.stringify(second); + dom.window.__trajectoryVisualizationTestHooks.receiveFrame({ snapshot: second, history: [] }); + + assert.deepEqual(chartSnapshot(st), zoomed); + assert.equal(JSON.stringify(second), serializedSecond); +});