Optimize realtime charts for tablet

This commit is contained in:
lenn
2026-05-12 18:38:22 +08:00
parent 360b57e3e2
commit 69bd3d1d8e
4 changed files with 338 additions and 280 deletions

View File

@@ -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 () => {

View File

@@ -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;
}

View File

@@ -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));
}

View File

@@ -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?.();