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} + + {#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[] {