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 heightField = new Float32Array(instanceCount);
const compactField = new Uint16Array(instanceCount); const compactField = new Uint16Array(instanceCount);
let lastFrameAt = performance.now(); 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 = () => { const drawOverlay = () => {
if (!viewerEl || !overlayEl) { if (!viewerEl || !overlayEl) {
@@ -623,12 +645,7 @@
renderer.render(scene, camera); renderer.render(scene, camera);
drawOverlay(); drawOverlay();
syncStats();
stats = {
current: summary?.latest ?? null,
max: summary?.max ?? null,
min: summary?.min ?? null
};
}); });
return () => { return () => {

View File

@@ -182,8 +182,7 @@
transition: transition:
opacity var(--fade-ms) cubic-bezier(0.18, 0.88, 0.3, 1), 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), transform var(--enter-ms) cubic-bezier(0.2, 0.9, 0.28, 1),
border-color 460ms ease, border-color 460ms ease;
filter 760ms ease;
transition-delay: calc(var(--panel-index) * 140ms); transition-delay: calc(var(--panel-index) * 140ms);
} }
@@ -300,8 +299,6 @@
stroke-width: 1.3; stroke-width: 1.3;
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
filter: drop-shadow(0 0 2px rgb(0 0 0 / 0.42));
will-change: d;
} }
.series-line.tone-cyan { .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); 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 { .scan-haze {
display: none; display: none;
} }

View File

@@ -11,22 +11,6 @@
export let sessionStartedAt: number = Date.now(); export let sessionStartedAt: number = Date.now();
export let isRealtime = false; 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 viewportWidth = 120;
const viewportHeight = 48; const viewportHeight = 48;
const plotInsetLeft = 13; const plotInsetLeft = 13;
@@ -34,6 +18,8 @@
const plotInsetTop = 4; const plotInsetTop = 4;
const plotInsetBottom = 9; const plotInsetBottom = 9;
const fixedYBounds = { min: 0, max: 25 }; const fixedYBounds = { min: 0, max: 25 };
const maxCanvasDpr = 1.5;
const minDrawIntervalMs = 66;
interface CurveSample { interface CurveSample {
x: number; x: number;
@@ -45,23 +31,25 @@
y: number; y: number;
} }
interface AxisTick { let canvasEl: HTMLCanvasElement | undefined;
value: number; let chartStageEl: HTMLDivElement | undefined;
label: string; let currentTimeSeconds = 0;
plotX: number; let timerId: ReturnType<typeof setInterval> | null = null;
plotY: number; 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 { function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
function formatValue(value: number | null): string { function formatValue(value: number | null): string {
if (value === null) { return value === null ? "--" : value.toFixed(1);
return "--";
}
return value.toFixed(1);
} }
function formatAxisValue(value: number, axis: "x" | "y"): string { function formatAxisValue(value: number, axis: "x" | "y"): string {
@@ -73,6 +61,7 @@
if (value < 60) { if (value < 60) {
return `${value.toFixed(1)}s`; return `${value.toFixed(1)}s`;
} }
const mins = Math.floor(value / 60); const mins = Math.floor(value / 60);
const secs = value - mins * 60; const secs = value - mins * 60;
return `${mins}:${secs.toFixed(0).padStart(2, "0")}`; return `${mins}:${secs.toFixed(0).padStart(2, "0")}`;
@@ -81,17 +70,6 @@
return `${Math.round(value)} N`; 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 } { function resolveBounds(values: number[]): { min: number; max: number } {
if (values.length === 0) { if (values.length === 0) {
return { min: 0, max: 1 }; return { min: 0, max: 1 };
@@ -108,34 +86,23 @@
return { min, max }; return { min, max };
} }
function mapXToViewport(value: number, bounds: { min: number; max: number }): number { function buildSamples(rawYValues: number[], rawXValues: number[], currentSeconds: number): CurveSample[] {
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[] {
if (!rawYValues.length) { if (!rawYValues.length) {
return []; 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) => { return rawYValues.map((rawY, index) => {
const x = rawXValues[index];
const y = Number.isFinite(rawY) ? Number(rawY) : 0; const y = Number.isFinite(rawY) ? Number(rawY) : 0;
const fallbackX = index === 0 ? 0 : previousX + 1; const rawX = rawXValues[index];
const resolvedX = Number.isFinite(x) ? Number(x) : fallbackX; 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); const normalizedX = index === 0 ? resolvedX : Math.max(resolvedX, previousX);
previousX = normalizedX; previousX = normalizedX;
return { x: normalizedX, y }; return { x: normalizedX, y };
@@ -143,132 +110,274 @@
} }
function resolveXScaleBounds( function resolveXScaleBounds(
samples: CurveSample[], samplesValue: CurveSample[],
currentSeconds: number, currentSeconds: number,
realtime: boolean realtime: boolean
): { min: number; max: number } { ): { min: number; max: number } {
if (samples.length === 0) { if (samplesValue.length === 0) {
return { min: 0, max: 1 }; return { min: 0, max: 1 };
} }
const values = samples.map((sample) => sample.x);
const dataBounds = resolveBounds(values);
if (!realtime) { if (!realtime) {
return dataBounds; return resolveBounds(samplesValue.map((sample) => sample.x));
} }
const firstX = samples[0].x; const firstX = samplesValue[0].x;
const lastX = samples[samples.length - 1].x; const lastX = samplesValue[samplesValue.length - 1].x;
const axisMax = Math.max(lastX, currentSeconds); const axisMax = Math.max(lastX, currentSeconds);
const positiveDiffs = samples const dataSpan = Math.max(lastX - firstX, 1);
.slice(1) const axisMin = Math.max(0, axisMax - dataSpan);
.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);
return resolveBounds([axisMin, axisMax]); 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( function convertPoints(
samples: CurveSample[], samplesValue: CurveSample[],
xBounds: { min: number; max: number }, xBounds: { min: number; max: number },
yBounds: { min: number; max: number } yBounds: { min: number; max: number }
): PlotPoint[] { ): PlotPoint[] {
if (samples.length === 0) { if (samplesValue.length === 0) {
return []; return [];
} }
if (samples.length === 1) { if (samplesValue.length === 1) {
return [{ x: viewportWidth / 2, y: viewportHeight / 2 }]; return [{ x: viewportWidth / 2, y: viewportHeight / 2 }];
} }
return samples.map((sample) => { return samplesValue.map((sample) => ({
return {
x: mapXToViewport(sample.x, xBounds), x: mapXToViewport(sample.x, xBounds),
y: mapYToViewport(sample.y, yBounds) y: mapYToViewport(sample.y, yBounds)
}; }));
});
} }
function buildYAxisTicks( function scaleCanvas(context: CanvasRenderingContext2D): { width: number; height: number; dpr: number } | null {
yScaleBounds: { min: number; max: number }, if (!canvasEl || !chartStageEl) {
_yDataBounds: { min: number; max: number } return null;
): AxisTick[] { }
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]; const tickValues = [25, 20, 15, 10, 5, 0];
return tickValues.map((value) => ({
value, context.save();
label: formatAxisValue(value, "y"), context.lineWidth = 0.45;
plotX: plotInsetLeft - 1.8, context.strokeStyle = "rgb(128 170 180 / 0.18)";
plotY: mapYToViewport(value, yScaleBounds) 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[] { context.restore();
if (!Number.isFinite(xScaleBounds.min) || !Number.isFinite(xScaleBounds.max)) {
return [];
} }
const first = xScaleBounds.min; function drawXAxis(context: CanvasRenderingContext2D, xBounds: { min: number; max: number }): void {
const middle = xScaleBounds.min + (xScaleBounds.max - xScaleBounds.min) / 2; const first = xBounds.min;
const last = xScaleBounds.max; const middle = xBounds.min + (xBounds.max - xBounds.min) / 2;
const tickValues = [first, middle, last]; const last = xBounds.max;
return tickValues.map((value) => ({ const ticks = [first, middle, last];
value,
label: formatAxisValue(value, "x"), context.save();
plotX: mapXToViewport(value, xScaleBounds), context.fillStyle = "rgb(190 216 220 / 0.82)";
plotY: viewportHeight - 0.9 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 { function drawArea(context: CanvasRenderingContext2D, points: PlotPoint[]): void {
if (points.length === 0) {
return "";
}
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
}
function createAreaPath(points: PlotPoint[]): string {
if (points.length < 2) { if (points.length < 2) {
return ""; return;
} }
const linePath = createLinePath(points);
const firstPoint = points[0]; const firstPoint = points[0];
const lastPoint = points[points.length - 1]; 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; $: sourceYValues = yValues && yValues.length ? yValues : summary.points;
$: sourceXValues = xValues && xValues.length ? xValues : summary.xValues ?? []; $: sourceXValues = xValues && xValues.length ? xValues : summary.xValues ?? [];
$: samples = (() => { $: samples = buildSamples(sourceYValues, sourceXValues, currentTimeSeconds);
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;
})();
$: sampleCount = samples.length; $: sampleCount = samples.length;
$: xScaleBounds = resolveXScaleBounds(samples, currentTimeSeconds, isRealtime); $: xScaleBounds = resolveXScaleBounds(samples, currentTimeSeconds, isRealtime);
$: yScaleBounds = fixedYBounds; $: yScaleBounds = fixedYBounds;
$: xDataBounds = resolveDataBounds(samples.map((sample) => sample.x));
$: yDataBounds = resolveDataBounds(samples.map((sample) => sample.y));
$: plotPoints = convertPoints(samples, xScaleBounds, yScaleBounds); $: 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); $: latestValue = formatValue(summary.latest);
$: minValue = formatValue(summary.min); $: minValue = formatValue(summary.min);
$: maxValue = formatValue(summary.max); $: 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> </script>
<article <article
@@ -290,52 +399,8 @@
</div> </div>
</header> </header>
<div class="chart-stage"> <div class="chart-stage" bind:this={chartStageEl}>
<svg viewBox="0 0 {viewportWidth} {viewportHeight}" preserveAspectRatio="none" role="img" aria-label={summary.label}> <canvas class="summary-canvas" bind:this={canvasEl} aria-label={summary.label}></canvas>
<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>
{#if sampleCount === 0} {#if sampleCount === 0}
<div class="empty-state"> <div class="empty-state">
@@ -389,8 +454,7 @@
transition: transition:
opacity var(--fade-ms) cubic-bezier(0.18, 0.88, 0.3, 1), 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), transform var(--enter-ms) cubic-bezier(0.2, 0.9, 0.28, 1),
border-color 460ms ease, border-color 460ms ease;
filter 760ms ease;
transition-delay: calc(var(--panel-index) * 140ms); 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%); radial-gradient(circle at 50% 0, rgb(var(--hud-glow-rgb) / 0.09), transparent 45%);
} }
svg { .summary-canvas {
display: block; display: block;
inline-size: 100%; inline-size: 100%;
block-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 { .empty-state {
position: absolute; position: absolute;
inset: 0; 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); 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 { .chart-stage {
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.78), rgb(var(--hud-surface-deep-rgb) / 0.88)); 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 pointsPerSeries = 28;
const summaryPointsPerSeries = 42; const summaryPointsPerSeries = 42;
const signalRenderTickMs = 1200; const signalRenderTickMs = 1200;
const hudRealtimeRenderMs = 33;
const replayDefaultFrameMs = 40; const replayDefaultFrameMs = 40;
const showSignalPanels = false; const showSignalPanels = false;
const mockToneCycle: SignalTone[] = ["cyan", "lime", "orange", "violet", "gold", "rose"]; const mockToneCycle: SignalTone[] = ["cyan", "lime", "orange", "violet", "gold", "rose"];
@@ -285,6 +286,9 @@
} | null = null; } | null = null;
let devkitStatusTimer: number | null = null; let devkitStatusTimer: number | null = null;
let sessionStartedAt: number = Date.now(); let sessionStartedAt: number = Date.now();
let pendingHudPacket: HudPacket | null = null;
let hudFrameRequestId: number | null = null;
let lastHudRenderAt = 0;
$: uiCopy = copyByLocale[locale]; $: uiCopy = copyByLocale[locale];
$: configLinks = buildConfigLinks( $: configLinks = buildConfigLinks(
@@ -810,12 +814,10 @@
const safeIndex = clamp(index, 0, replayFrames.length - 1); const safeIndex = clamp(index, 0, replayFrames.length - 1);
const startIndex = Math.max(0, safeIndex - summaryPointsPerSeries + 1); const startIndex = Math.max(0, safeIndex - summaryPointsPerSeries + 1);
const points: number[] = []; const points: number[] = [];
const xSeconds: number[] = [];
for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) { for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) {
points.push(replayFrameTotal(replayFrames[cursor])); points.push(replayFrameTotal(replayFrames[cursor]));
xSeconds.push(replayFrames[cursor].dtsMs / 1000);
} }
return buildSummary(points, xSeconds); return buildSummary(points);
} }
function applyReplayFrame(index: number): void { function applyReplayFrame(index: number): void {
@@ -828,7 +830,9 @@
replayHasDisplayedFrame = true; replayHasDisplayedFrame = true;
replayProgress = replayFrames.length > 1 ? safeIndex / (replayFrames.length - 1) : 1; replayProgress = replayFrames.length > 1 ? safeIndex / (replayFrames.length - 1) : 1;
pressureMatrix = frameValuesToMatrix(replayFrames[safeIndex].values); pressureMatrix = frameValuesToMatrix(replayFrames[safeIndex].values);
if (signalPanels.length > 0) {
signalPanels = buildInactivePanels(); signalPanels = buildInactivePanels();
}
summary = buildReplaySummaryAt(safeIndex); summary = buildReplaySummaryAt(safeIndex);
hasSignalData = true; hasSignalData = true;
} }
@@ -987,7 +991,6 @@
function buildEmptySummary(): HudSummary { function buildEmptySummary(): HudSummary {
return { return {
label: "Resultant Force", label: "Resultant Force",
xValues: [],
points: [], points: [],
latest: null, latest: null,
min: null, min: null,
@@ -1007,19 +1010,13 @@
return shouldHideSummary(summaryValue.points) ? buildEmptySummary() : summaryValue; return shouldHideSummary(summaryValue.points) ? buildEmptySummary() : summaryValue;
} }
function buildSummary(points: number[], xValues: number[] = []): HudSummary { function buildSummary(points: number[]): HudSummary {
if (points.length === 0) { if (points.length === 0) {
return buildEmptySummary(); return buildEmptySummary();
} }
const resolvedXValues = points.map((_, index) => {
const x = xValues[index];
return Number.isFinite(x) ? Number(x) : index + 1;
});
return { return {
label: "Resultant Force", label: "Resultant Force",
xValues: resolvedXValues,
points, points,
latest: points[points.length - 1], latest: points[points.length - 1],
min: Math.min(...points), min: Math.min(...points),
@@ -1044,21 +1041,13 @@
? summaryValue.points[summaryValue.points.length - 1] ? summaryValue.points[summaryValue.points.length - 1]
: randomBetween(280, 1600); : randomBetween(280, 1600);
const next = Math.round(clamp(previous + randomBetween(-160, 160), 120, 2400) * 10) / 10; 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 = const points =
summaryValue.points.length >= summaryPointsPerSeries summaryValue.points.length >= summaryPointsPerSeries
? summaryValue.points.slice(1) ? summaryValue.points.slice(1)
: summaryValue.points.slice(); : summaryValue.points.slice();
const xValues =
previousXValues.length >= summaryPointsPerSeries ? previousXValues.slice(1) : previousXValues.slice();
points.push(next); points.push(next);
xValues.push(nowSeconds); return buildSummary(points);
return buildSummary(points, xValues);
} }
function buildInactivePanels(): HudSignalPanel[] { function buildInactivePanels(): HudSignalPanel[] {
@@ -1069,23 +1058,66 @@
if (replayHasData) { if (replayHasData) {
return; return;
} }
signalPanels = showSignalPanels ? packet.panels : buildInactivePanels(); if (showSignalPanels) {
if (packet.summary.points.length > 0) { signalPanels = packet.panels;
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10; } else if (signalPanels.length > 0) {
const pointCount = packet.summary.points.length; signalPanels = buildInactivePanels();
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;
} }
summary = packet.summary;
pressureMatrix = packet.pressureMatrix; pressureMatrix = packet.pressureMatrix;
hasSignalData = signalPanels.length > 0 || packet.summary.points.length > 0; 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 { function clearHudPanels(): void {
cancelPendingHudPacket();
hasSignalData = false; hasSignalData = false;
signalPanels = buildInactivePanels(); signalPanels = buildInactivePanels();
summary = buildEmptySummary(); summary = buildEmptySummary();
@@ -1906,7 +1938,7 @@
void checkForAppUpdate(); void checkForAppUpdate();
void pollDevKitStatus(); void pollDevKitStatus();
devkitStatusTimer = window.setInterval(() => void pollDevKitStatus(), 3000); devkitStatusTimer = window.setInterval(() => void pollDevKitStatus(), 3000);
void startTauriHudStream(applyPacket) void startTauriHudStream(enqueueHudPacket)
.then((unlisten) => { .then((unlisten) => {
if (disposed) { if (disposed) {
unlisten(); unlisten();
@@ -1936,11 +1968,12 @@
console.error("Failed to listen for devkit_pzt_angle:", error); console.error("Failed to listen for devkit_pzt_angle:", error);
}); });
} else { } else {
stopMockFeed = startMockFeed(applyPacket); stopMockFeed = startMockFeed(enqueueHudPacket);
} }
return () => { return () => {
disposed = true; disposed = true;
cancelPendingHudPacket();
pauseReplayPlayback(); pauseReplayPlayback();
stopMockFeed?.(); stopMockFeed?.();
unlistenHudStream?.(); unlistenHudStream?.();