Optimize realtime charts for tablet
This commit is contained in:
@@ -461,6 +461,28 @@
|
||||
const heightField = new Float32Array(instanceCount);
|
||||
const compactField = new Uint16Array(instanceCount);
|
||||
let lastFrameAt = performance.now();
|
||||
let lastStatsCurrent: number | null = null;
|
||||
let lastStatsMax: number | null = null;
|
||||
let lastStatsMin: number | null = null;
|
||||
|
||||
const syncStats = () => {
|
||||
const nextCurrent = summary?.latest ?? null;
|
||||
const nextMax = summary?.max ?? null;
|
||||
const nextMin = summary?.min ?? null;
|
||||
|
||||
if (nextCurrent === lastStatsCurrent && nextMax === lastStatsMax && nextMin === lastStatsMin) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastStatsCurrent = nextCurrent;
|
||||
lastStatsMax = nextMax;
|
||||
lastStatsMin = nextMin;
|
||||
stats = {
|
||||
current: nextCurrent,
|
||||
max: nextMax,
|
||||
min: nextMin
|
||||
};
|
||||
};
|
||||
|
||||
const drawOverlay = () => {
|
||||
if (!viewerEl || !overlayEl) {
|
||||
@@ -623,12 +645,7 @@
|
||||
|
||||
renderer.render(scene, camera);
|
||||
drawOverlay();
|
||||
|
||||
stats = {
|
||||
current: summary?.latest ?? null,
|
||||
max: summary?.max ?? null,
|
||||
min: summary?.min ?? null
|
||||
};
|
||||
syncStats();
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -182,8 +182,7 @@
|
||||
transition:
|
||||
opacity var(--fade-ms) cubic-bezier(0.18, 0.88, 0.3, 1),
|
||||
transform var(--enter-ms) cubic-bezier(0.2, 0.9, 0.28, 1),
|
||||
border-color 460ms ease,
|
||||
filter 760ms ease;
|
||||
border-color 460ms ease;
|
||||
transition-delay: calc(var(--panel-index) * 140ms);
|
||||
}
|
||||
|
||||
@@ -300,8 +299,6 @@
|
||||
stroke-width: 1.3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 2px rgb(0 0 0 / 0.42));
|
||||
will-change: d;
|
||||
}
|
||||
|
||||
.series-line.tone-cyan {
|
||||
@@ -460,10 +457,6 @@
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--hud-border-strong-rgb) / 0.08), 0 0 8px rgb(var(--hud-glow-rgb) / 0.1);
|
||||
}
|
||||
|
||||
.series-line {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.scan-haze {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -11,22 +11,6 @@
|
||||
export let sessionStartedAt: number = Date.now();
|
||||
export let isRealtime = false;
|
||||
|
||||
let currentTimeSeconds = 0;
|
||||
let timerId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMount(() => {
|
||||
timerId = setInterval(() => {
|
||||
currentTimeSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
|
||||
}, 200);
|
||||
return () => {
|
||||
if (timerId != null) clearInterval(timerId);
|
||||
};
|
||||
});
|
||||
|
||||
$: i18n = locale === "zh-CN"
|
||||
? { now: "当前", min: "最小", max: "最大", waiting: "等待数据" }
|
||||
: { now: "Now", min: "Min", max: "Max", waiting: "Waiting" };
|
||||
|
||||
const viewportWidth = 120;
|
||||
const viewportHeight = 48;
|
||||
const plotInsetLeft = 13;
|
||||
@@ -34,6 +18,8 @@
|
||||
const plotInsetTop = 4;
|
||||
const plotInsetBottom = 9;
|
||||
const fixedYBounds = { min: 0, max: 25 };
|
||||
const maxCanvasDpr = 1.5;
|
||||
const minDrawIntervalMs = 66;
|
||||
|
||||
interface CurveSample {
|
||||
x: number;
|
||||
@@ -45,23 +31,25 @@
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface AxisTick {
|
||||
value: number;
|
||||
label: string;
|
||||
plotX: number;
|
||||
plotY: number;
|
||||
}
|
||||
let canvasEl: HTMLCanvasElement | undefined;
|
||||
let chartStageEl: HTMLDivElement | undefined;
|
||||
let currentTimeSeconds = 0;
|
||||
let timerId: ReturnType<typeof setInterval> | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let drawRequestId: number | null = null;
|
||||
let lastDrawAt = 0;
|
||||
let mounted = false;
|
||||
|
||||
$: i18n = locale === "zh-CN"
|
||||
? { now: "当前", min: "最小", max: "最大", waiting: "等待数据" }
|
||||
: { now: "Now", min: "Min", max: "Max", waiting: "Waiting" };
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function formatValue(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return value.toFixed(1);
|
||||
return value === null ? "--" : value.toFixed(1);
|
||||
}
|
||||
|
||||
function formatAxisValue(value: number, axis: "x" | "y"): string {
|
||||
@@ -73,6 +61,7 @@
|
||||
if (value < 60) {
|
||||
return `${value.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
const mins = Math.floor(value / 60);
|
||||
const secs = value - mins * 60;
|
||||
return `${mins}:${secs.toFixed(0).padStart(2, "0")}`;
|
||||
@@ -81,17 +70,6 @@
|
||||
return `${Math.round(value)} N`;
|
||||
}
|
||||
|
||||
function resolveDataBounds(values: number[]): { min: number; max: number } {
|
||||
if (values.length === 0) {
|
||||
return { min: 0, max: 1 };
|
||||
}
|
||||
|
||||
return {
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values)
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBounds(values: number[]): { min: number; max: number } {
|
||||
if (values.length === 0) {
|
||||
return { min: 0, max: 1 };
|
||||
@@ -108,34 +86,23 @@
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function mapXToViewport(value: number, bounds: { min: number; max: number }): number {
|
||||
const span = bounds.max - bounds.min;
|
||||
const chartWidth = viewportWidth - plotInsetLeft - plotInsetRight;
|
||||
const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
|
||||
const mappedX = plotInsetLeft + ratio * chartWidth;
|
||||
return Math.round(clamp(mappedX, plotInsetLeft, viewportWidth - plotInsetRight) * 100) / 100;
|
||||
}
|
||||
|
||||
function mapYToViewport(value: number, bounds: { min: number; max: number }): number {
|
||||
const span = bounds.max - bounds.min;
|
||||
const chartHeight = viewportHeight - plotInsetTop - plotInsetBottom;
|
||||
const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
|
||||
const mappedY = viewportHeight - plotInsetBottom - ratio * chartHeight;
|
||||
return Math.round(clamp(mappedY, plotInsetTop, viewportHeight - plotInsetBottom) * 100) / 100;
|
||||
}
|
||||
|
||||
function buildSamples(rawYValues: number[], rawXValues: number[]): CurveSample[] {
|
||||
function buildSamples(rawYValues: number[], rawXValues: number[], currentSeconds: number): CurveSample[] {
|
||||
if (!rawYValues.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let previousX = 0;
|
||||
const hasUsableXValues = rawXValues.length === rawYValues.length;
|
||||
const realtimeSpacing = isRealtime
|
||||
? Math.max(currentSeconds / Math.max(rawYValues.length - 1, 1), 0.1)
|
||||
: 1;
|
||||
const realtimeStart = isRealtime ? Math.max(0, currentSeconds - realtimeSpacing * (rawYValues.length - 1)) : 0;
|
||||
let previousX = realtimeStart;
|
||||
|
||||
return rawYValues.map((rawY, index) => {
|
||||
const x = rawXValues[index];
|
||||
const y = Number.isFinite(rawY) ? Number(rawY) : 0;
|
||||
const fallbackX = index === 0 ? 0 : previousX + 1;
|
||||
const resolvedX = Number.isFinite(x) ? Number(x) : fallbackX;
|
||||
const rawX = rawXValues[index];
|
||||
const fallbackX = isRealtime ? realtimeStart + index * realtimeSpacing : index + 1;
|
||||
const resolvedX = hasUsableXValues && Number.isFinite(rawX) ? Number(rawX) : fallbackX;
|
||||
const normalizedX = index === 0 ? resolvedX : Math.max(resolvedX, previousX);
|
||||
previousX = normalizedX;
|
||||
return { x: normalizedX, y };
|
||||
@@ -143,132 +110,274 @@
|
||||
}
|
||||
|
||||
function resolveXScaleBounds(
|
||||
samples: CurveSample[],
|
||||
samplesValue: CurveSample[],
|
||||
currentSeconds: number,
|
||||
realtime: boolean
|
||||
): { min: number; max: number } {
|
||||
if (samples.length === 0) {
|
||||
if (samplesValue.length === 0) {
|
||||
return { min: 0, max: 1 };
|
||||
}
|
||||
|
||||
const values = samples.map((sample) => sample.x);
|
||||
const dataBounds = resolveBounds(values);
|
||||
|
||||
if (!realtime) {
|
||||
return dataBounds;
|
||||
return resolveBounds(samplesValue.map((sample) => sample.x));
|
||||
}
|
||||
|
||||
const firstX = samples[0].x;
|
||||
const lastX = samples[samples.length - 1].x;
|
||||
const firstX = samplesValue[0].x;
|
||||
const lastX = samplesValue[samplesValue.length - 1].x;
|
||||
const axisMax = Math.max(lastX, currentSeconds);
|
||||
const positiveDiffs = samples
|
||||
.slice(1)
|
||||
.map((sample, index) => sample.x - samples[index].x)
|
||||
.filter((diff) => diff > 0);
|
||||
const averageSpacing =
|
||||
positiveDiffs.length > 0 ? positiveDiffs.reduce((sum, diff) => sum + diff, 0) / positiveDiffs.length : 1;
|
||||
const dataSpan = Math.max(lastX - firstX, 0);
|
||||
const windowSpan = Math.max(dataSpan, averageSpacing * Math.max(samples.length - 1, 1), 1);
|
||||
const axisMin = Math.max(0, axisMax - windowSpan);
|
||||
|
||||
const dataSpan = Math.max(lastX - firstX, 1);
|
||||
const axisMin = Math.max(0, axisMax - dataSpan);
|
||||
return resolveBounds([axisMin, axisMax]);
|
||||
}
|
||||
|
||||
function mapXToViewport(value: number, bounds: { min: number; max: number }): number {
|
||||
const span = bounds.max - bounds.min;
|
||||
const chartWidth = viewportWidth - plotInsetLeft - plotInsetRight;
|
||||
const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
|
||||
return clamp(plotInsetLeft + ratio * chartWidth, plotInsetLeft, viewportWidth - plotInsetRight);
|
||||
}
|
||||
|
||||
function mapYToViewport(value: number, bounds: { min: number; max: number }): number {
|
||||
const span = bounds.max - bounds.min;
|
||||
const chartHeight = viewportHeight - plotInsetTop - plotInsetBottom;
|
||||
const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
|
||||
return clamp(viewportHeight - plotInsetBottom - ratio * chartHeight, plotInsetTop, viewportHeight - plotInsetBottom);
|
||||
}
|
||||
|
||||
function convertPoints(
|
||||
samples: CurveSample[],
|
||||
samplesValue: CurveSample[],
|
||||
xBounds: { min: number; max: number },
|
||||
yBounds: { min: number; max: number }
|
||||
): PlotPoint[] {
|
||||
if (samples.length === 0) {
|
||||
if (samplesValue.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (samples.length === 1) {
|
||||
if (samplesValue.length === 1) {
|
||||
return [{ x: viewportWidth / 2, y: viewportHeight / 2 }];
|
||||
}
|
||||
|
||||
return samples.map((sample) => {
|
||||
return {
|
||||
return samplesValue.map((sample) => ({
|
||||
x: mapXToViewport(sample.x, xBounds),
|
||||
y: mapYToViewport(sample.y, yBounds)
|
||||
};
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
function buildYAxisTicks(
|
||||
yScaleBounds: { min: number; max: number },
|
||||
_yDataBounds: { min: number; max: number }
|
||||
): AxisTick[] {
|
||||
function scaleCanvas(context: CanvasRenderingContext2D): { width: number; height: number; dpr: number } | null {
|
||||
if (!canvasEl || !chartStageEl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = chartStageEl.clientWidth;
|
||||
const height = chartStageEl.clientHeight;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, maxCanvasDpr);
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextWidth = Math.round(width * dpr);
|
||||
const nextHeight = Math.round(height * dpr);
|
||||
if (canvasEl.width !== nextWidth || canvasEl.height !== nextHeight) {
|
||||
canvasEl.width = nextWidth;
|
||||
canvasEl.height = nextHeight;
|
||||
canvasEl.style.width = `${width}px`;
|
||||
canvasEl.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
context.setTransform((width * dpr) / viewportWidth, 0, 0, (height * dpr) / viewportHeight, 0, 0);
|
||||
return { width, height, dpr };
|
||||
}
|
||||
|
||||
function drawGrid(context: CanvasRenderingContext2D, yBounds: { min: number; max: number }): void {
|
||||
const tickValues = [25, 20, 15, 10, 5, 0];
|
||||
return tickValues.map((value) => ({
|
||||
value,
|
||||
label: formatAxisValue(value, "y"),
|
||||
plotX: plotInsetLeft - 1.8,
|
||||
plotY: mapYToViewport(value, yScaleBounds)
|
||||
}));
|
||||
|
||||
context.save();
|
||||
context.lineWidth = 0.45;
|
||||
context.strokeStyle = "rgb(128 170 180 / 0.18)";
|
||||
context.fillStyle = "rgb(190 216 220 / 0.78)";
|
||||
context.font = "600 3.2px system-ui, sans-serif";
|
||||
context.textBaseline = "middle";
|
||||
|
||||
for (const tick of tickValues) {
|
||||
const y = mapYToViewport(tick, yBounds);
|
||||
context.beginPath();
|
||||
context.moveTo(plotInsetLeft, y);
|
||||
context.lineTo(viewportWidth - plotInsetRight, y);
|
||||
context.stroke();
|
||||
|
||||
context.textAlign = "right";
|
||||
context.fillText(formatAxisValue(tick, "y"), plotInsetLeft - 1.8, y + 0.6);
|
||||
}
|
||||
|
||||
function buildXAxisTicks(xScaleBounds: { min: number; max: number }): AxisTick[] {
|
||||
if (!Number.isFinite(xScaleBounds.min) || !Number.isFinite(xScaleBounds.max)) {
|
||||
return [];
|
||||
context.restore();
|
||||
}
|
||||
|
||||
const first = xScaleBounds.min;
|
||||
const middle = xScaleBounds.min + (xScaleBounds.max - xScaleBounds.min) / 2;
|
||||
const last = xScaleBounds.max;
|
||||
const tickValues = [first, middle, last];
|
||||
return tickValues.map((value) => ({
|
||||
value,
|
||||
label: formatAxisValue(value, "x"),
|
||||
plotX: mapXToViewport(value, xScaleBounds),
|
||||
plotY: viewportHeight - 0.9
|
||||
}));
|
||||
function drawXAxis(context: CanvasRenderingContext2D, xBounds: { min: number; max: number }): void {
|
||||
const first = xBounds.min;
|
||||
const middle = xBounds.min + (xBounds.max - xBounds.min) / 2;
|
||||
const last = xBounds.max;
|
||||
const ticks = [first, middle, last];
|
||||
|
||||
context.save();
|
||||
context.fillStyle = "rgb(190 216 220 / 0.82)";
|
||||
context.font = "600 3.2px system-ui, sans-serif";
|
||||
context.textBaseline = "alphabetic";
|
||||
|
||||
ticks.forEach((tick, index) => {
|
||||
const x = mapXToViewport(tick, xBounds);
|
||||
context.textAlign = index === 0 ? "left" : index === ticks.length - 1 ? "right" : "center";
|
||||
context.fillText(formatAxisValue(tick, "x"), x, viewportHeight - 0.9);
|
||||
});
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function createLinePath(points: PlotPoint[]): string {
|
||||
if (points.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
||||
}
|
||||
|
||||
function createAreaPath(points: PlotPoint[]): string {
|
||||
function drawArea(context: CanvasRenderingContext2D, points: PlotPoint[]): void {
|
||||
if (points.length < 2) {
|
||||
return "";
|
||||
return;
|
||||
}
|
||||
|
||||
const linePath = createLinePath(points);
|
||||
const firstPoint = points[0];
|
||||
const lastPoint = points[points.length - 1];
|
||||
const gradient = context.createLinearGradient(0, plotInsetTop, 0, viewportHeight - plotInsetBottom);
|
||||
gradient.addColorStop(0, "rgb(62 232 255 / 0.28)");
|
||||
gradient.addColorStop(1, "rgb(62 232 255 / 0.02)");
|
||||
|
||||
return `${linePath} L ${lastPoint.x} ${viewportHeight - plotInsetBottom} L ${firstPoint.x} ${viewportHeight - plotInsetBottom} Z`;
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.moveTo(firstPoint.x, firstPoint.y);
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
context.lineTo(points[index].x, points[index].y);
|
||||
}
|
||||
context.lineTo(lastPoint.x, viewportHeight - plotInsetBottom);
|
||||
context.lineTo(firstPoint.x, viewportHeight - plotInsetBottom);
|
||||
context.closePath();
|
||||
context.fillStyle = gradient;
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawLine(context: CanvasRenderingContext2D, points: PlotPoint[]): void {
|
||||
if (!points.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.lineWidth = 1.35;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.strokeStyle = "rgb(62 232 255 / 0.96)";
|
||||
context.beginPath();
|
||||
context.moveTo(points[0].x, points[0].y);
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
context.lineTo(points[index].x, points[index].y);
|
||||
}
|
||||
context.stroke();
|
||||
|
||||
const lastPoint = points[points.length - 1];
|
||||
context.fillStyle = "rgb(133 255 68 / 0.98)";
|
||||
context.beginPath();
|
||||
context.arc(lastPoint.x, lastPoint.y, 1.7, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawCanvas(): void {
|
||||
if (!canvasEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = canvasEl.getContext("2d", { alpha: true });
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaled = scaleCanvas(context);
|
||||
if (!scaled) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, viewportWidth, viewportHeight);
|
||||
|
||||
if (sampleCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawGrid(context, yScaleBounds);
|
||||
drawArea(context, plotPoints);
|
||||
drawLine(context, plotPoints);
|
||||
drawXAxis(context, xScaleBounds);
|
||||
}
|
||||
|
||||
function scheduleDraw(): void {
|
||||
if (!mounted || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (drawRequestId != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawRequestId = window.requestAnimationFrame((timestamp) => {
|
||||
drawRequestId = null;
|
||||
|
||||
if (lastDrawAt > 0 && timestamp - lastDrawAt < minDrawIntervalMs) {
|
||||
scheduleDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
lastDrawAt = timestamp;
|
||||
drawCanvas();
|
||||
});
|
||||
}
|
||||
|
||||
$: sourceYValues = yValues && yValues.length ? yValues : summary.points;
|
||||
$: sourceXValues = xValues && xValues.length ? xValues : summary.xValues ?? [];
|
||||
$: samples = (() => {
|
||||
const base = buildSamples(sourceYValues, sourceXValues);
|
||||
if (isRealtime && base.length > 0 && currentTimeSeconds > 0) {
|
||||
const lastSample = base[base.length - 1];
|
||||
base[base.length - 1] = { ...lastSample, x: Math.max(lastSample.x, currentTimeSeconds) };
|
||||
}
|
||||
return base;
|
||||
})();
|
||||
$: samples = buildSamples(sourceYValues, sourceXValues, currentTimeSeconds);
|
||||
$: sampleCount = samples.length;
|
||||
$: xScaleBounds = resolveXScaleBounds(samples, currentTimeSeconds, isRealtime);
|
||||
$: yScaleBounds = fixedYBounds;
|
||||
$: xDataBounds = resolveDataBounds(samples.map((sample) => sample.x));
|
||||
$: yDataBounds = resolveDataBounds(samples.map((sample) => sample.y));
|
||||
$: plotPoints = convertPoints(samples, xScaleBounds, yScaleBounds);
|
||||
$: linePath = createLinePath(plotPoints);
|
||||
$: areaPath = createAreaPath(plotPoints);
|
||||
$: lastPoint = plotPoints.length > 0 ? plotPoints[plotPoints.length - 1] : null;
|
||||
$: yAxisTicks = sampleCount > 0 ? buildYAxisTicks(yScaleBounds, yDataBounds) : [];
|
||||
$: xAxisTicks = sampleCount > 0 ? buildXAxisTicks(xScaleBounds) : [];
|
||||
$: latestValue = formatValue(summary.latest);
|
||||
$: minValue = formatValue(summary.min);
|
||||
$: maxValue = formatValue(summary.max);
|
||||
$: {
|
||||
sampleCount;
|
||||
plotPoints;
|
||||
xScaleBounds;
|
||||
locale;
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
currentTimeSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
|
||||
scheduleDraw();
|
||||
|
||||
timerId = setInterval(() => {
|
||||
if (!isRealtime) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTimeSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
|
||||
}, 500);
|
||||
|
||||
if (chartStageEl) {
|
||||
resizeObserver = new ResizeObserver(() => scheduleDraw());
|
||||
resizeObserver.observe(chartStageEl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (timerId != null) clearInterval(timerId);
|
||||
if (drawRequestId != null) window.cancelAnimationFrame(drawRequestId);
|
||||
resizeObserver?.disconnect();
|
||||
timerId = null;
|
||||
drawRequestId = null;
|
||||
resizeObserver = null;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<article
|
||||
@@ -290,52 +399,8 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="chart-stage">
|
||||
<svg viewBox="0 0 {viewportWidth} {viewportHeight}" preserveAspectRatio="none" role="img" aria-label={summary.label}>
|
||||
<defs>
|
||||
<linearGradient id="summary-fill" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgb(62 232 255 / 0.28)" />
|
||||
<stop offset="100%" stop-color="rgb(62 232 255 / 0.02)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g class="grid-lines" aria-hidden="true">
|
||||
{#each yAxisTicks as tick (`grid-${tick.value}`)}
|
||||
<line x1={plotInsetLeft} y1={tick.plotY} x2={viewportWidth - plotInsetRight} y2={tick.plotY}></line>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
{#if areaPath}
|
||||
<path d={areaPath} class="summary-area"></path>
|
||||
{/if}
|
||||
|
||||
{#if linePath}
|
||||
<path d={linePath} class="summary-line"></path>
|
||||
{/if}
|
||||
|
||||
{#if lastPoint}
|
||||
<circle cx={lastPoint.x} cy={lastPoint.y} r="1.7" class="summary-dot"></circle>
|
||||
{/if}
|
||||
|
||||
<g class="axis-labels" aria-hidden="true">
|
||||
{#each yAxisTicks as tick, index (`y-${index}`)}
|
||||
<text class="axis-label y-axis-label" x={tick.plotX} y={tick.plotY + 1.1} text-anchor="end">
|
||||
{tick.label}
|
||||
</text>
|
||||
{/each}
|
||||
|
||||
{#each xAxisTicks as tick, index (`x-${index}`)}
|
||||
<text
|
||||
class="axis-label x-axis-label"
|
||||
x={tick.plotX}
|
||||
y={tick.plotY}
|
||||
text-anchor={index === 0 ? "start" : index === xAxisTicks.length - 1 ? "end" : "middle"}
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
<div class="chart-stage" bind:this={chartStageEl}>
|
||||
<canvas class="summary-canvas" bind:this={canvasEl} aria-label={summary.label}></canvas>
|
||||
|
||||
{#if sampleCount === 0}
|
||||
<div class="empty-state">
|
||||
@@ -389,8 +454,7 @@
|
||||
transition:
|
||||
opacity var(--fade-ms) cubic-bezier(0.18, 0.88, 0.3, 1),
|
||||
transform var(--enter-ms) cubic-bezier(0.2, 0.9, 0.28, 1),
|
||||
border-color 460ms ease,
|
||||
filter 760ms ease;
|
||||
border-color 460ms ease;
|
||||
transition-delay: calc(var(--panel-index) * 140ms);
|
||||
}
|
||||
|
||||
@@ -480,53 +544,12 @@
|
||||
radial-gradient(circle at 50% 0, rgb(var(--hud-glow-rgb) / 0.09), transparent 45%);
|
||||
}
|
||||
|
||||
svg {
|
||||
.summary-canvas {
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.grid-lines line {
|
||||
stroke: rgb(var(--hud-border-strong-rgb) / 0.16);
|
||||
stroke-width: 0.45;
|
||||
}
|
||||
|
||||
.summary-area {
|
||||
fill: url(#summary-fill);
|
||||
}
|
||||
|
||||
.summary-line {
|
||||
fill: none;
|
||||
stroke: rgb(var(--hud-cyan-rgb) / 0.96);
|
||||
stroke-width: 1.35;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 4px rgb(var(--hud-cyan-rgb) / 0.22));
|
||||
}
|
||||
|
||||
.summary-dot {
|
||||
fill: rgb(var(--hud-lime-rgb) / 0.98);
|
||||
filter: drop-shadow(0 0 6px rgb(var(--hud-lime-rgb) / 0.3));
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
fill: rgb(var(--hud-text-main-rgb) / 0.88);
|
||||
font-size: 3.2px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-shadow:
|
||||
0 1px 0 rgb(0 0 0 / 0.46),
|
||||
0 0 4px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
.y-axis-label {
|
||||
fill: rgb(var(--hud-text-dim-rgb) / 0.84);
|
||||
}
|
||||
|
||||
.x-axis-label {
|
||||
fill: rgb(var(--hud-text-dim-rgb) / 0.9);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -641,14 +664,6 @@
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--hud-border-strong-rgb) / 0.08), 0 0 8px rgb(var(--hud-glow-rgb) / 0.1);
|
||||
}
|
||||
|
||||
.summary-line {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.summary-dot {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.chart-stage {
|
||||
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.78), rgb(var(--hud-surface-deep-rgb) / 0.88));
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
const pointsPerSeries = 28;
|
||||
const summaryPointsPerSeries = 42;
|
||||
const signalRenderTickMs = 1200;
|
||||
const hudRealtimeRenderMs = 33;
|
||||
const replayDefaultFrameMs = 40;
|
||||
const showSignalPanels = false;
|
||||
const mockToneCycle: SignalTone[] = ["cyan", "lime", "orange", "violet", "gold", "rose"];
|
||||
@@ -285,6 +286,9 @@
|
||||
} | null = null;
|
||||
let devkitStatusTimer: number | null = null;
|
||||
let sessionStartedAt: number = Date.now();
|
||||
let pendingHudPacket: HudPacket | null = null;
|
||||
let hudFrameRequestId: number | null = null;
|
||||
let lastHudRenderAt = 0;
|
||||
|
||||
$: uiCopy = copyByLocale[locale];
|
||||
$: configLinks = buildConfigLinks(
|
||||
@@ -810,12 +814,10 @@
|
||||
const safeIndex = clamp(index, 0, replayFrames.length - 1);
|
||||
const startIndex = Math.max(0, safeIndex - summaryPointsPerSeries + 1);
|
||||
const points: number[] = [];
|
||||
const xSeconds: number[] = [];
|
||||
for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) {
|
||||
points.push(replayFrameTotal(replayFrames[cursor]));
|
||||
xSeconds.push(replayFrames[cursor].dtsMs / 1000);
|
||||
}
|
||||
return buildSummary(points, xSeconds);
|
||||
return buildSummary(points);
|
||||
}
|
||||
|
||||
function applyReplayFrame(index: number): void {
|
||||
@@ -828,7 +830,9 @@
|
||||
replayHasDisplayedFrame = true;
|
||||
replayProgress = replayFrames.length > 1 ? safeIndex / (replayFrames.length - 1) : 1;
|
||||
pressureMatrix = frameValuesToMatrix(replayFrames[safeIndex].values);
|
||||
if (signalPanels.length > 0) {
|
||||
signalPanels = buildInactivePanels();
|
||||
}
|
||||
summary = buildReplaySummaryAt(safeIndex);
|
||||
hasSignalData = true;
|
||||
}
|
||||
@@ -987,7 +991,6 @@
|
||||
function buildEmptySummary(): HudSummary {
|
||||
return {
|
||||
label: "Resultant Force",
|
||||
xValues: [],
|
||||
points: [],
|
||||
latest: null,
|
||||
min: null,
|
||||
@@ -1007,19 +1010,13 @@
|
||||
return shouldHideSummary(summaryValue.points) ? buildEmptySummary() : summaryValue;
|
||||
}
|
||||
|
||||
function buildSummary(points: number[], xValues: number[] = []): HudSummary {
|
||||
function buildSummary(points: number[]): HudSummary {
|
||||
if (points.length === 0) {
|
||||
return buildEmptySummary();
|
||||
}
|
||||
|
||||
const resolvedXValues = points.map((_, index) => {
|
||||
const x = xValues[index];
|
||||
return Number.isFinite(x) ? Number(x) : index + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
label: "Resultant Force",
|
||||
xValues: resolvedXValues,
|
||||
points,
|
||||
latest: points[points.length - 1],
|
||||
min: Math.min(...points),
|
||||
@@ -1044,21 +1041,13 @@
|
||||
? summaryValue.points[summaryValue.points.length - 1]
|
||||
: randomBetween(280, 1600);
|
||||
const next = Math.round(clamp(previous + randomBetween(-160, 160), 120, 2400) * 10) / 10;
|
||||
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
|
||||
const previousXValues =
|
||||
summaryValue.xValues && summaryValue.xValues.length === summaryValue.points.length
|
||||
? summaryValue.xValues
|
||||
: summaryValue.points.map((_, index) => nowSeconds);
|
||||
const points =
|
||||
summaryValue.points.length >= summaryPointsPerSeries
|
||||
? summaryValue.points.slice(1)
|
||||
: summaryValue.points.slice();
|
||||
const xValues =
|
||||
previousXValues.length >= summaryPointsPerSeries ? previousXValues.slice(1) : previousXValues.slice();
|
||||
|
||||
points.push(next);
|
||||
xValues.push(nowSeconds);
|
||||
return buildSummary(points, xValues);
|
||||
return buildSummary(points);
|
||||
}
|
||||
|
||||
function buildInactivePanels(): HudSignalPanel[] {
|
||||
@@ -1069,23 +1058,66 @@
|
||||
if (replayHasData) {
|
||||
return;
|
||||
}
|
||||
signalPanels = showSignalPanels ? packet.panels : buildInactivePanels();
|
||||
if (packet.summary.points.length > 0) {
|
||||
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
|
||||
const pointCount = packet.summary.points.length;
|
||||
const spacing =
|
||||
pointCount > 1 ? Math.min(1.2, nowSeconds / Math.max(pointCount - 1, 1)) : 0;
|
||||
const startX = Math.max(0, nowSeconds - spacing * Math.max(pointCount - 1, 0));
|
||||
const xValues = packet.summary.points.map((_, index) => Math.round((startX + index * spacing) * 10) / 10);
|
||||
summary = { ...packet.summary, xValues };
|
||||
} else {
|
||||
summary = packet.summary;
|
||||
if (showSignalPanels) {
|
||||
signalPanels = packet.panels;
|
||||
} else if (signalPanels.length > 0) {
|
||||
signalPanels = buildInactivePanels();
|
||||
}
|
||||
summary = packet.summary;
|
||||
pressureMatrix = packet.pressureMatrix;
|
||||
hasSignalData = signalPanels.length > 0 || packet.summary.points.length > 0;
|
||||
}
|
||||
|
||||
function getFrameClock(): number {
|
||||
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function cancelPendingHudPacket(): void {
|
||||
pendingHudPacket = null;
|
||||
if (hudFrameRequestId != null && typeof window !== "undefined") {
|
||||
window.cancelAnimationFrame(hudFrameRequestId);
|
||||
hudFrameRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHudPacketFlush(): void {
|
||||
if (hudFrameRequestId != null || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
hudFrameRequestId = window.requestAnimationFrame(flushPendingHudPacket);
|
||||
}
|
||||
|
||||
function flushPendingHudPacket(timestamp: number = getFrameClock()): void {
|
||||
hudFrameRequestId = null;
|
||||
|
||||
if (!pendingHudPacket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = timestamp - lastHudRenderAt;
|
||||
if (lastHudRenderAt > 0 && elapsed < hudRealtimeRenderMs) {
|
||||
scheduleHudPacketFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
const packet = pendingHudPacket;
|
||||
pendingHudPacket = null;
|
||||
lastHudRenderAt = timestamp;
|
||||
applyPacket(packet);
|
||||
}
|
||||
|
||||
function enqueueHudPacket(packet: HudPacket): void {
|
||||
if (replayHasData) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingHudPacket = packet;
|
||||
scheduleHudPacketFlush();
|
||||
}
|
||||
|
||||
function clearHudPanels(): void {
|
||||
cancelPendingHudPacket();
|
||||
hasSignalData = false;
|
||||
signalPanels = buildInactivePanels();
|
||||
summary = buildEmptySummary();
|
||||
@@ -1906,7 +1938,7 @@
|
||||
void checkForAppUpdate();
|
||||
void pollDevKitStatus();
|
||||
devkitStatusTimer = window.setInterval(() => void pollDevKitStatus(), 3000);
|
||||
void startTauriHudStream(applyPacket)
|
||||
void startTauriHudStream(enqueueHudPacket)
|
||||
.then((unlisten) => {
|
||||
if (disposed) {
|
||||
unlisten();
|
||||
@@ -1936,11 +1968,12 @@
|
||||
console.error("Failed to listen for devkit_pzt_angle:", error);
|
||||
});
|
||||
} else {
|
||||
stopMockFeed = startMockFeed(applyPacket);
|
||||
stopMockFeed = startMockFeed(enqueueHudPacket);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelPendingHudPacket();
|
||||
pauseReplayPlayback();
|
||||
stopMockFeed?.();
|
||||
unlistenHudStream?.();
|
||||
|
||||
Reference in New Issue
Block a user