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