feat: add observation chart viewport zoom
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user