From 69bd3d1d8e027db67dab021f96fc2edf6402b841 Mon Sep 17 00:00:00 2001 From: lenn Date: Tue, 12 May 2026 18:38:22 +0800 Subject: [PATCH] Optimize realtime charts for tablet --- .../components/PressureMatrixViewer.svelte | 29 +- src/lib/components/SignalChart.svelte | 9 +- src/lib/components/SummaryCurve.svelte | 479 +++++++++--------- src/routes/+page.svelte | 101 ++-- 4 files changed, 338 insertions(+), 280 deletions(-) diff --git a/src/lib/components/PressureMatrixViewer.svelte b/src/lib/components/PressureMatrixViewer.svelte index ad52a11..aefa20b 100644 --- a/src/lib/components/PressureMatrixViewer.svelte +++ b/src/lib/components/PressureMatrixViewer.svelte @@ -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 () => { diff --git a/src/lib/components/SignalChart.svelte b/src/lib/components/SignalChart.svelte index db3576a..3d6e63e 100644 --- a/src/lib/components/SignalChart.svelte +++ b/src/lib/components/SignalChart.svelte @@ -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; } diff --git a/src/lib/components/SummaryCurve.svelte b/src/lib/components/SummaryCurve.svelte index 143e9e7..c09bd22 100644 --- a/src/lib/components/SummaryCurve.svelte +++ b/src/lib/components/SummaryCurve.svelte @@ -11,22 +11,6 @@ export let sessionStartedAt: number = Date.now(); export let isRealtime = false; - let currentTimeSeconds = 0; - let timerId: ReturnType | 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 | 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 { - x: mapXToViewport(sample.x, xBounds), - y: mapYToViewport(sample.y, yBounds) - }; - }); + 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) - })); - } - function buildXAxisTicks(xScaleBounds: { min: number; max: number }): AxisTick[] { - if (!Number.isFinite(xScaleBounds.min) || !Number.isFinite(xScaleBounds.max)) { - return []; + 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); } - 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 - })); + context.restore(); } - function createLinePath(points: PlotPoint[]): string { - if (points.length === 0) { - return ""; - } + 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]; - return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" "); + 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 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; + }; + });
-
- - - - - - - - - - - {#if areaPath} - - {/if} - - {#if linePath} - - {/if} - - {#if lastPoint} - - {/if} - - - +
+ {#if sampleCount === 0}
@@ -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)); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d179c4c..b4518ed 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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); - signalPanels = buildInactivePanels(); + 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?.();