diff --git a/src/components/interactive-chart.tsx b/src/components/interactive-chart.tsx index 54e0ecf..2d8342d 100644 --- a/src/components/interactive-chart.tsx +++ b/src/components/interactive-chart.tsx @@ -123,6 +123,7 @@ function InteractiveChartSurface({ {(chartHeight) => ( {...(interactive ? INTERACTION_PROPS : NON_INTERACTIVE_PROPS)} + animate={false} ariaDescription={ariaDescription} ariaLabel={ariaLabel} className={className} diff --git a/src/components/session-chart.tsx b/src/components/session-chart.tsx index e5f09c7..1922c49 100644 --- a/src/components/session-chart.tsx +++ b/src/components/session-chart.tsx @@ -1,6 +1,6 @@ import { useSelector } from '@tanstack/react-store'; import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { evenlySample, valueRange } from '../lib/arrays'; +import { valueRange } from '../lib/arrays'; import { CHART_PLOT_MIDDLE, resistanceChartMaximum, roundedChartMaximum } from '../lib/chart'; import { CHART_MODE } from '../lib/chart-mode'; import { CONTROL_MODE } from '../lib/control-mode'; @@ -16,6 +16,7 @@ import { STANDARD_METRIC_KEYS, } from '../lib/metric-presentation'; import { MIN_RESISTANCE } from '../lib/resistance'; +import { sampleSessionChartHistory } from '../lib/session-chart-sampling'; import { convertElevation, convertSpeed, @@ -27,8 +28,6 @@ import { preferencesStore } from '../stores/preferences-store'; import type { ChartMode, ControlMode, MetricSample, RoutePoint, SpeedUnit } from '../types'; import { InteractiveLineChart, type InteractiveLineDatum } from './interactive-chart'; -const MAXIMUM_RENDERED_CHART_SAMPLES = 2000; - function chartControlsEdgeBackground( direction: 'left' | 'right', sessionControls: boolean @@ -72,7 +71,7 @@ function sessionChartRows({ ? 'No reading' : `${value.toFixed(decimals)}${unit ? ` ${unit}` : ''}`; return { - key: `${x}-${index}`, + key: String(x), label: `${formatChartSeconds(x)} ยท ${label}: ${formattedValue}`, value, x, @@ -186,10 +185,7 @@ export function SessionChart({ (history.some((sample) => sample.gear !== undefined) ? CONTROL_MODE.GEAR : CONTROL_MODE.RESISTANCE); - const chartHistory = useMemo( - () => evenlySample(history, MAXIMUM_RENDERED_CHART_SAMPLES), - [history] - ); + const chartHistory = useMemo(() => sampleSessionChartHistory(history), [history]); const series = useMemo(() => { const speedValues = chartHistory.map((sample) => convertSpeed(sample.speed, speedUnit)); const routeElevations = route.map((point) => convertElevation(point.elevation, speedUnit)); diff --git a/src/lib/arrays.ts b/src/lib/arrays.ts index 3304e24..828c02c 100644 --- a/src/lib/arrays.ts +++ b/src/lib/arrays.ts @@ -1,13 +1,3 @@ -export function evenlySample(values: T[], limit: number): T[] { - if (values.length <= limit) { - return values; - } - return Array.from({ length: limit }, (_, index) => { - const sourceIndex = Math.round((index * (values.length - 1)) / (limit - 1)); - return values[sourceIndex] as T; - }); -} - export function sortedIndexAtOrAfter( values: readonly T[], target: number, diff --git a/src/lib/session-chart-sampling.ts b/src/lib/session-chart-sampling.ts new file mode 100644 index 0000000..af4f059 --- /dev/null +++ b/src/lib/session-chart-sampling.ts @@ -0,0 +1,121 @@ +import type { MetricSample } from '../types'; + +export const MAXIMUM_RENDERED_CHART_SAMPLES = 2000; + +const CHART_SAMPLE_FIELDS = [ + 'speed', + 'power', + 'cadence', + 'heartRate', + 'gear', + 'resistance', + 'grade', + 'elevation', +] as const satisfies readonly (keyof MetricSample)[]; +const MAXIMUM_POINTS_PER_BUCKET = 2 + CHART_SAMPLE_FIELDS.length * 2; + +type ChartSampleField = (typeof CHART_SAMPLE_FIELDS)[number]; + +interface NumericExtrema { + maximumIndex: number; + maximumValue: number; + minimumIndex: number; + minimumValue: number; +} + +function nextPowerOfTwo(value: number): number { + return 2 ** Math.ceil(Math.log2(Math.max(1, value))); +} + +function chartSampleBucketSize(length: number): number { + if (length <= MAXIMUM_RENDERED_CHART_SAMPLES) { + return 1; + } + let bucketSize = nextPowerOfTwo( + Math.ceil((length * MAXIMUM_POINTS_PER_BUCKET) / MAXIMUM_RENDERED_CHART_SAMPLES) + ); + while ( + Math.ceil(length / bucketSize) * MAXIMUM_POINTS_PER_BUCKET > + MAXIMUM_RENDERED_CHART_SAMPLES + ) { + bucketSize *= 2; + } + return bucketSize; +} + +function updateExtrema( + extrema: Map, + field: ChartSampleField, + index: number, + value: number +): void { + const current = extrema.get(field); + if (!current) { + extrema.set(field, { + maximumIndex: index, + maximumValue: value, + minimumIndex: index, + minimumValue: value, + }); + return; + } + if (value < current.minimumValue) { + current.minimumIndex = index; + current.minimumValue = value; + } + if (value > current.maximumValue) { + current.maximumIndex = index; + current.maximumValue = value; + } +} + +function bucketSampleIndices( + history: readonly MetricSample[], + bucketStart: number, + bucketEnd: number +): number[] { + const selectedIndices = new Set([bucketStart, bucketEnd - 1]); + const extrema = new Map(); + for (let index = bucketStart; index < bucketEnd; index += 1) { + const sample = history[index]; + if (!sample) { + continue; + } + for (const field of CHART_SAMPLE_FIELDS) { + const value = sample[field]; + if (value !== undefined) { + updateExtrema(extrema, field, index, value); + } + } + } + for (const { maximumIndex, minimumIndex } of extrema.values()) { + selectedIndices.add(minimumIndex); + selectedIndices.add(maximumIndex); + } + return [...selectedIndices].sort((left, right) => left - right); +} + +/** + * Produces a bounded first/min/max/last envelope for a streaming session. + * + * Fixed power-of-two buckets keep every completed bucket immutable as a live + * session grows. That gives the chart stable path geometry while retaining the + * extrema that determine each metric's useful scale. + */ +export function sampleSessionChartHistory(history: readonly MetricSample[]): MetricSample[] { + if (history.length <= MAXIMUM_RENDERED_CHART_SAMPLES) { + return [...history]; + } + const bucketSize = chartSampleBucketSize(history.length); + const sampled: MetricSample[] = []; + for (let bucketStart = 0; bucketStart < history.length; bucketStart += bucketSize) { + const bucketEnd = Math.min(history.length, bucketStart + bucketSize); + for (const index of bucketSampleIndices(history, bucketStart, bucketEnd)) { + const sample = history[index]; + if (sample) { + sampled.push(sample); + } + } + } + return sampled; +} diff --git a/tests/session-chart-sampling.test.ts b/tests/session-chart-sampling.test.ts new file mode 100644 index 0000000..aaa64fa --- /dev/null +++ b/tests/session-chart-sampling.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; +import { + MAXIMUM_RENDERED_CHART_SAMPLES, + sampleSessionChartHistory, +} from '../src/lib/session-chart-sampling'; +import type { MetricSample } from '../src/types'; + +function sampleAt(elapsedSeconds: number, power = elapsedSeconds): MetricSample { + return { + cadence: 70 + (elapsedSeconds % 30), + elapsedSeconds, + elevation: elapsedSeconds % 100, + gear: 8 + (elapsedSeconds % 12), + grade: (elapsedSeconds % 20) - 10, + heartRate: 110 + (elapsedSeconds % 50), + power, + resistance: elapsedSeconds % 100, + speed: 20 + (elapsedSeconds % 15), + }; +} + +function testPower(index: number): number { + if (index === 728) { + return 1200; + } + if (index === 729) { + return 0; + } + return 180; +} + +describe('session chart sampling', () => { + test('keeps every metric extrema while bounding a long session', () => { + const history = Array.from({ length: 3555 }, (_, index) => + sampleAt(index + 1, testPower(index)) + ); + const sampled = sampleSessionChartHistory(history); + + expect(sampled.length).toBeLessThanOrEqual(MAXIMUM_RENDERED_CHART_SAMPLES); + expect(sampled[0]).toEqual(history[0]); + expect(sampled.at(-1)).toEqual(history.at(-1)); + expect(Math.max(...sampled.map((sample) => sample.power))).toBe(1200); + expect(Math.min(...sampled.map((sample) => sample.power))).toBe(0); + expect(Math.max(...sampled.map((sample) => sample.cadence))).toBe( + Math.max(...history.map((sample) => sample.cadence)) + ); + expect(Math.min(...sampled.map((sample) => sample.grade ?? 0))).toBe( + Math.min(...history.map((sample) => sample.grade ?? 0)) + ); + }); + + test('does not rewrite completed chart buckets as live samples arrive', () => { + const history = Array.from({ length: 3600 }, (_, index) => sampleAt(index + 1)); + const nextHistory = [...history, sampleAt(3601, 950)]; + const before = sampleSessionChartHistory(history); + const after = sampleSessionChartHistory(nextHistory); + const activeBucketStart = 3585; + + expect( + after + .filter((sample) => sample.elapsedSeconds < activeBucketStart) + .map((sample) => sample.elapsedSeconds) + ).toEqual( + before + .filter((sample) => sample.elapsedSeconds < activeBucketStart) + .map((sample) => sample.elapsedSeconds) + ); + expect(Math.max(...after.map((sample) => sample.power))).toBe( + Math.max(...before.map((sample) => sample.power)) + ); + expect(after.at(-1)?.elapsedSeconds).toBe(3601); + }); +});