diff --git a/src-tauri/program.log2026-04-01 b/src-tauri/program.log2026-04-01
new file mode 100644
index 0000000..e69de29
diff --git a/src/lib/components/CenterStage.svelte b/src/lib/components/CenterStage.svelte
index 5d1fa19..bd0b85c 100644
--- a/src/lib/components/CenterStage.svelte
+++ b/src/lib/components/CenterStage.svelte
@@ -250,7 +250,13 @@
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }}
>
-
+
{/if}
@@ -275,7 +281,13 @@
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
>
-
+
{/if}
diff --git a/src/lib/components/SummaryCurve.svelte b/src/lib/components/SummaryCurve.svelte
index 5c6f2cf..7d95317 100644
--- a/src/lib/components/SummaryCurve.svelte
+++ b/src/lib/components/SummaryCurve.svelte
@@ -4,16 +4,31 @@
export let summary: HudSummary;
export let side: "left" | "right" = "right";
export let panelIndex = 0;
+ export let xValues: number[] | null = null;
+ export let yValues: number[] | null = null;
const viewportWidth = 100;
const viewportHeight = 36;
+ const horizontalInset = 2;
const verticalInset = 2;
+ interface CurveSample {
+ x: number;
+ y: number;
+ }
+
interface PlotPoint {
x: number;
y: number;
}
+ interface AxisTick {
+ value: number;
+ label: string;
+ plotX: number;
+ plotY: number;
+ }
+
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
@@ -26,46 +41,133 @@
return value.toFixed(1);
}
- function resolveBounds(points: number[]): { min: number; max: number } {
- if (points.length === 0) {
+ function formatAxisValue(value: number, axis: "x" | "y"): string {
+ if (!Number.isFinite(value)) {
+ return "--";
+ }
+
+ if (axis === "x") {
+ return String(Math.round(value));
+ }
+
+ if (Math.abs(value) >= 1000) {
+ const compact = Math.round((value / 1000) * 10) / 10;
+ return Number.isInteger(compact) ? `${compact.toFixed(0)}k` : `${compact.toFixed(1)}k`;
+ }
+
+ return Math.abs(value) >= 100 ? Math.round(value).toString() : value.toFixed(1);
+ }
+
+ function resolveDataBounds(values: number[]): { min: number; max: number } {
+ if (values.length === 0) {
return { min: 0, max: 1 };
}
- const min = Math.min(...points);
- const max = Math.max(...points);
+ 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 };
+ }
+
+ const min = Math.min(...values);
+ const max = Math.max(...values);
if (Math.abs(max - min) < 0.001) {
- return { min: min - 1, max: max + 1 };
+ const padding = Math.max(Math.abs(max) * 0.05, 1);
+ return { min: min - padding, max: max + padding };
}
return { min, max };
}
- function convertPoints(rawPoints: number[]): PlotPoint[] {
- if (rawPoints.length === 0) {
+ function mapXToViewport(value: number, bounds: { min: number; max: number }): number {
+ const span = bounds.max - bounds.min;
+ const chartWidth = viewportWidth - horizontalInset * 2;
+ const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
+ const mappedX = horizontalInset + ratio * chartWidth;
+ return Math.round(clamp(mappedX, horizontalInset, viewportWidth - horizontalInset) * 100) / 100;
+ }
+
+ function mapYToViewport(value: number, bounds: { min: number; max: number }): number {
+ const span = bounds.max - bounds.min;
+ const chartHeight = viewportHeight - verticalInset * 2;
+ const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
+ const mappedY = viewportHeight - verticalInset - ratio * chartHeight;
+ return Math.round(clamp(mappedY, verticalInset, viewportHeight - verticalInset) * 100) / 100;
+ }
+
+ function buildSamples(rawYValues: number[], rawXValues: number[]): CurveSample[] {
+ if (!rawYValues.length) {
return [];
}
- if (rawPoints.length === 1) {
+ return rawYValues.map((rawY, index) => {
+ const x = rawXValues[index];
+ const y = Number.isFinite(rawY) ? Number(rawY) : 0;
+ const resolvedX = Number.isFinite(x) ? Number(x) : index + 1;
+ return { x: resolvedX, y };
+ });
+ }
+
+ function convertPoints(
+ samples: CurveSample[],
+ xBounds: { min: number; max: number },
+ yBounds: { min: number; max: number }
+ ): PlotPoint[] {
+ if (samples.length === 0) {
+ return [];
+ }
+
+ if (samples.length === 1) {
return [{ x: viewportWidth / 2, y: viewportHeight / 2 }];
}
- const { min, max } = resolveBounds(rawPoints);
- const span = max - min;
- const chartHeight = viewportHeight - verticalInset * 2;
- const stepX = viewportWidth / (rawPoints.length - 1);
-
- return rawPoints.map((point, index) => {
- const ratio = span <= 0 ? 0.5 : (point - min) / span;
- const y = viewportHeight - verticalInset - ratio * chartHeight;
-
+ return samples.map((sample) => {
return {
- x: Math.round(index * stepX * 100) / 100,
- y: Math.round(clamp(y, verticalInset, viewportHeight - verticalInset) * 100) / 100
+ x: mapXToViewport(sample.x, xBounds),
+ y: mapYToViewport(sample.y, yBounds)
};
});
}
+ function buildYAxisTicks(
+ yScaleBounds: { min: number; max: number },
+ yDataBounds: { min: number; max: number }
+ ): AxisTick[] {
+ const hasRange = Math.abs(yDataBounds.max - yDataBounds.min) >= 0.001;
+ const tickValues = hasRange
+ ? [yDataBounds.max, (yDataBounds.max + yDataBounds.min) / 2, yDataBounds.min]
+ : [yScaleBounds.max, (yScaleBounds.max + yScaleBounds.min) / 2, yScaleBounds.min];
+ return tickValues.map((value) => ({
+ value,
+ label: formatAxisValue(value, "y"),
+ plotX: horizontalInset,
+ plotY: mapYToViewport(value, yScaleBounds)
+ }));
+ }
+
+ function buildXAxisTicks(samples: CurveSample[], xScaleBounds: { min: number; max: number }): AxisTick[] {
+ if (!samples.length) {
+ return [];
+ }
+
+ const first = samples[0].x;
+ const middle = samples[Math.floor((samples.length - 1) / 2)].x;
+ const last = samples[samples.length - 1].x;
+ const tickValues = [first, middle, last];
+ return tickValues.map((value) => ({
+ value,
+ label: formatAxisValue(value, "x"),
+ plotX: mapXToViewport(value, xScaleBounds),
+ plotY: viewportHeight - 1.2
+ }));
+ }
+
function createLinePath(points: PlotPoint[]): string {
if (points.length === 0) {
return "";
@@ -86,14 +188,23 @@
return `${linePath} L ${lastPoint.x} ${viewportHeight} L ${firstPoint.x} ${viewportHeight} Z`;
}
- $: plotPoints = convertPoints(summary.points);
+ $: sourceYValues = yValues && yValues.length ? yValues : summary.points;
+ $: sourceXValues = xValues && xValues.length ? xValues : summary.xValues ?? [];
+ $: samples = buildSamples(sourceYValues, sourceXValues);
+ $: sampleCount = samples.length;
+ $: xScaleBounds = resolveBounds(samples.map((sample) => sample.x));
+ $: yScaleBounds = resolveBounds(samples.map((sample) => sample.y));
+ $: 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(samples, xScaleBounds) : [];
$: latestValue = formatValue(summary.latest);
$: minValue = formatValue(summary.min);
$: maxValue = formatValue(summary.max);
- $: sampleCount = summary.points.length;
{/if}
+
+
+ {#each yAxisTicks as tick, index (`y-${index}`)}
+
+ {tick.label}
+
+ {/each}
+
+ {#each xAxisTicks as tick, index (`x-${index}`)}
+
+ {tick.label}
+
+ {/each}
+
{#if sampleCount === 0}
@@ -312,6 +442,24 @@
filter: drop-shadow(0 0 6px rgb(133 255 68 / 0.3));
}
+ .axis-label {
+ fill: rgb(176 204 222 / 0.88);
+ font-size: 2.8px;
+ font-weight: 500;
+ 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(162 198 220 / 0.84);
+ }
+
+ .x-axis-label {
+ fill: rgb(162 198 220 / 0.9);
+ }
+
.empty-state {
position: absolute;
inset: 0;
diff --git a/src/lib/types/hud.ts b/src/lib/types/hud.ts
index 2576492..b309673 100644
--- a/src/lib/types/hud.ts
+++ b/src/lib/types/hud.ts
@@ -48,6 +48,7 @@ export interface HudPacket {
export interface HudSummary {
label: string;
+ xValues?: number[];
points: number[];
latest: number | null;
min: number | null;
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 4aa1c5a..ad5eb43 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -398,10 +398,12 @@
const safeIndex = clamp(index, 0, replayFrames.length - 1);
const startIndex = Math.max(0, safeIndex - summaryPointsPerSeries + 1);
const points: number[] = [];
+ const frameIds: number[] = [];
for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) {
points.push(replayFrameTotal(replayFrames[cursor]));
+ frameIds.push(cursor + 1);
}
- return buildSummary(points);
+ return buildSummary(points, frameIds);
}
function applyReplayFrame(index: number): void {
@@ -573,6 +575,7 @@
function buildEmptySummary(): HudSummary {
return {
label: "TOTAL",
+ xValues: [],
points: [],
latest: null,
min: null,
@@ -580,13 +583,19 @@
};
}
- function buildSummary(points: number[]): HudSummary {
+ function buildSummary(points: number[], xValues: 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: "TOTAL",
+ xValues: resolvedXValues,
points,
latest: points[points.length - 1],
min: Math.min(...points),
@@ -611,13 +620,20 @@
? summaryValue.points[summaryValue.points.length - 1]
: randomBetween(280, 1600);
const next = Math.round(clamp(previous + randomBetween(-160, 160), 120, 2400) * 10) / 10;
+ const previousXValues =
+ summaryValue.xValues && summaryValue.xValues.length === summaryValue.points.length
+ ? summaryValue.xValues
+ : summaryValue.points.map((_, index) => index + 1);
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);
- return buildSummary(points);
+ xValues.push((xValues[xValues.length - 1] ?? 0) + 1);
+ return buildSummary(points, xValues);
}
function buildInactivePanels(): HudSignalPanel[] {