diff --git a/src/app/bridge/rendererExposeBridge.ts b/src/app/bridge/rendererExposeBridge.ts index cc4fdaacc..752ac6a8c 100644 --- a/src/app/bridge/rendererExposeBridge.ts +++ b/src/app/bridge/rendererExposeBridge.ts @@ -17,10 +17,12 @@ import type { ChromiumFlagsBridge, ChromiumFlagsType, TelemetryInspectorBridge, + RendererPerfBridge, } from '@irdashies/types'; import { isRendererPerfMetricsEnabled, recordTelemetryCallback, + recordRendererMeasure, } from '../rendererPerfMetrics'; import { RENDERER_DATA_SUBSCRIPTION_BRIDGE, @@ -29,6 +31,16 @@ import { import { createSubscriptionBridgeClient, defineBridge } from './defineBridge'; export function exposeBridge() { + if (isRendererPerfMetricsEnabled()) { + defineBridge('rendererPerfBridge', { + recordMeasure: (name, durationMs) => { + if (!isRendererPerfMetricsEnabled()) return; + if (name !== 'trackMapAnimationFrame') return; + if (!Number.isFinite(durationMs) || durationMs < 0) return; + recordRendererMeasure(name, durationMs); + }, + }); + } const rendererDataSubscriptions = createSubscriptionBridgeClient( RENDERER_DATA_SUBSCRIPTION_BRIDGE diff --git a/src/app/rendererPerfMetrics.ts b/src/app/rendererPerfMetrics.ts index 2cce893dc..a23539bcc 100644 --- a/src/app/rendererPerfMetrics.ts +++ b/src/app/rendererPerfMetrics.ts @@ -1,5 +1,8 @@ import { ipcRenderer } from 'electron'; -import type { RendererPerfSample } from '@irdashies/types'; +import type { + RendererPerfMeasureName, + RendererPerfSample, +} from '@irdashies/types'; import { FixedSampleBuffer } from '../shared/performanceSamples'; import { readRendererPerfArguments } from './perfRendererArguments'; @@ -7,6 +10,7 @@ export const PERF_RENDERER_LOG_PREFIX = '[PerfRenderer:JSON] '; let telemetryCallbackTimes: FixedSampleBuffer | undefined; let channelCallbackTimes: FixedSampleBuffer | undefined; +let trackMapAnimationFrameTimes: FixedSampleBuffer | undefined; export function isRendererPerfMetricsEnabled(): boolean { return telemetryCallbackTimes !== undefined; @@ -20,6 +24,15 @@ export function recordChannelCallback(durationMs: number): void { channelCallbackTimes?.add(durationMs); } +export function recordRendererMeasure( + name: RendererPerfMeasureName, + durationMs: number +): void { + if (name === 'trackMapAnimationFrame') { + trackMapAnimationFrameTimes?.add(durationMs); + } +} + export function startRendererPerfMetrics(): void { const config = readRendererPerfArguments(); if (!config.enabled) return; @@ -34,6 +47,8 @@ export function startRendererPerfMetrics(): void { telemetryCallbackTimes = callbackTimes; const channelTimes = new FixedSampleBuffer(4096); channelCallbackTimes = channelTimes; + const trackMapFrameTimes = new FixedSampleBuffer(4096); + trackMapAnimationFrameTimes = trackMapFrameTimes; let intervalStart = performance.now(); let previousFrameTime = 0; let framesOver25Ms = 0; @@ -60,6 +75,7 @@ export function startRendererPerfMetrics(): void { previousFrameTime = 0; callbackTimes.reset(); channelTimes.reset(); + trackMapFrameTimes.reset(); framesOver25Ms = 0; framesOver50Ms = 0; return; @@ -77,6 +93,7 @@ export function startRendererPerfMetrics(): void { frameTimeMs: stats, telemetryCallbackMs: callbackTimes.summarize(), channelCallbackMs: channelTimes.summarize(), + trackMapAnimationFrameMs: trackMapFrameTimes.summarize(), telemetryWakeups: callbackTimes.summarize().count, channelWakeups: channelTimes.summarize().count, framesOver25Ms, @@ -93,6 +110,7 @@ export function startRendererPerfMetrics(): void { frameTimes.reset(); callbackTimes.reset(); channelTimes.reset(); + trackMapFrameTimes.reset(); framesOver25Ms = 0; framesOver50Ms = 0; }, reportIntervalMs); diff --git a/src/frontend/components/TrackMap/FlatTrackMapCanvas.tsx b/src/frontend/components/TrackMap/FlatTrackMapCanvas.tsx index 02991e0ec..7aee340c1 100644 --- a/src/frontend/components/TrackMap/FlatTrackMapCanvas.tsx +++ b/src/frontend/components/TrackMap/FlatTrackMapCanvas.tsx @@ -1,7 +1,9 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { TrackDriver, TrackDrawing } from './TrackCanvas'; import { getColor, getTailwindStyle } from '@irdashies/utils/colors'; import { useCarIdxOffTrack } from '@irdashies/context'; +import { progressToFlatX, useProgressAnimation } from './useProgressAnimation'; +import { getCachedTextVisualOffset } from './trackDrawingUtils'; export interface FlatTrackMapCanvasProps { trackDrawing: TrackDrawing; @@ -74,6 +76,18 @@ export const FlatTrackMapCanvas = ({ return colors; }, [drivers, isMultiClass, highlightColor]); + const orderedDrivers = useMemo( + () => + drivers + .map((entry, interpolationIndex) => ({ + ...entry, + interpolationIndex, + textMetricsCache: { font: '', text: '', visualOffset: 0 }, + })) + .sort((a, b) => Number(a.isPlayer) - Number(b.isPlayer)), + [drivers] + ); + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -121,7 +135,7 @@ export const FlatTrackMapCanvas = ({ }; }, []); - useLayoutEffect(() => { + useProgressAnimation(drivers, (progressValues, count) => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!canvas || !ctx || canvasSize.width === 0) return; @@ -193,93 +207,88 @@ export const FlatTrackMapCanvas = ({ // Draw drivers // Apply scale factor to match curved track map proportions - [...drivers] - .sort((a, b) => Number(a.isPlayer) - Number(b.isPlayer)) - .forEach(({ driver, progress, isPlayer, classPosition }) => { - let color = driverColors[driver.CarIdx]; - if (!color) return; - - const x = HORIZONTAL_PADDING + progress * usableWidth; - const radius = - (isPlayer ? playerCircleSize : driverCircleSize) * circleScale; - const fontSize = radius * (trackmapFontSize / 100); - const originalColor = color.fill; - const livePosition = - driverLivePositions[driver.CarIdx] ?? classPosition; - - // highlight leader? - if (!isPlayer && invertLeaderColor && livePosition === 1) { - color = { fill: 'white', text: originalColor }; - } + for (const orderedDriver of orderedDrivers) { + const { driver, isPlayer, classPosition, interpolationIndex } = + orderedDriver; + if (interpolationIndex >= count) continue; + const color = driverColors[driver.CarIdx]; + if (!color) continue; - // on pit road? - const onPitRoad = !!carIdxIsOnPitRoad?.[driver.CarIdx]; - if (onPitRoad) { - color = { fill: '#999999', text: 'white' }; - } + const x = progressToFlatX( + progressValues[interpolationIndex], + HORIZONTAL_PADDING, + usableWidth + ); + const radius = + (isPlayer ? playerCircleSize : driverCircleSize) * circleScale; + const fontSize = radius * (trackmapFontSize / 100); + const originalColor = color.fill; + let fillColor = color.fill; + let textColor = color.text; + const livePosition = driverLivePositions[driver.CarIdx] ?? classPosition; - ctx.fillStyle = color.fill; - ctx.beginPath(); - ctx.arc(x, centerY, radius, 0, 2 * Math.PI); - ctx.fill(); - - // draw a border? - if (driversOffTrack[driver.CarIdx]) { - ctx.strokeStyle = getColor('yellow', 400); - ctx.lineWidth = 4; - ctx.stroke(); - } else if (!isPlayer && invertLeaderColor && livePosition === 1) { - ctx.strokeStyle = originalColor; - ctx.lineWidth = 2; - ctx.stroke(); - } + // highlight leader? + if (!isPlayer && invertLeaderColor && livePosition === 1) { + fillColor = 'white'; + textColor = originalColor; + } + + // on pit road? + const onPitRoad = !!carIdxIsOnPitRoad?.[driver.CarIdx]; + if (onPitRoad) { + fillColor = '#999999'; + textColor = 'white'; + } - if (showCarNumbers) { - ctx.fillStyle = color.text; - ctx.font = `${fontSize}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - let displayText; - if (onPitRoad) { - displayText = 'P'; - } else if (displayMode === 'livePosition') { - displayText = - livePosition !== undefined && livePosition > 0 - ? livePosition.toString() - : ''; - } else if (displayMode === 'sessionPosition') { - displayText = - classPosition !== undefined && classPosition > 0 - ? classPosition.toString() - : ''; - } else { - displayText = driver.CarNumber; - } - if (displayText) { - const m = ctx.measureText(displayText); - const visualOffset = - (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2; - ctx.fillText(displayText, x, centerY + visualOffset); - } + ctx.fillStyle = fillColor; + ctx.beginPath(); + ctx.arc(x, centerY, radius, 0, 2 * Math.PI); + ctx.fill(); + + // draw a border? + if (driversOffTrack[driver.CarIdx]) { + ctx.strokeStyle = getColor('yellow', 400); + ctx.lineWidth = 4; + ctx.stroke(); + } else if (!isPlayer && invertLeaderColor && livePosition === 1) { + ctx.strokeStyle = originalColor; + ctx.lineWidth = 2; + ctx.stroke(); + } + + if (showCarNumbers) { + ctx.fillStyle = textColor; + ctx.font = `${fontSize}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + let displayText; + if (onPitRoad) { + displayText = 'P'; + } else if (displayMode === 'livePosition') { + displayText = + livePosition !== undefined && livePosition > 0 + ? livePosition.toString() + : ''; + } else if (displayMode === 'sessionPosition') { + displayText = + classPosition !== undefined && classPosition > 0 + ? classPosition.toString() + : ''; + } else { + displayText = driver.CarNumber; + } + if (displayText) { + const cache = orderedDriver.textMetricsCache; + const visualOffset = getCachedTextVisualOffset( + ctx, + displayText, + cache + ); + ctx.fillText(displayText, x, centerY + visualOffset); } - }); - }, [ - canvasSize, - drivers, - driverColors, - invertLeaderColor, - driversOffTrack, - showCarNumbers, - displayMode, - driverCircleSize, - playerCircleSize, - trackmapFontSize, - trackLineWidth, - trackOutlineWidth, - invertTrackColors, - driverLivePositions, - carIdxIsOnPitRoad, - ]); + } + } + }); return (
diff --git a/src/frontend/components/TrackMap/TrackCanvas.tsx b/src/frontend/components/TrackMap/TrackCanvas.tsx index 44d210ccf..d38ef1285 100644 --- a/src/frontend/components/TrackMap/TrackCanvas.tsx +++ b/src/frontend/components/TrackMap/TrackCanvas.tsx @@ -13,7 +13,13 @@ import { drawDrivers, drawSectorColors, drawSectorDividers, + compareDriverDrawOrder, + type PositionedTrackDriver, } from './trackDrawingUtils'; +import { + progressToTrackPoint, + useProgressAnimation, +} from './useProgressAnimation'; const EMPTY_PIT_STATE: readonly boolean[] = []; import type { SectorColor } from '@irdashies/context'; @@ -199,72 +205,28 @@ export const TrackCanvas = ({ trackPathPoints: trackDrawing?.active?.trackPathPoints, }); - // Position calculation based on the percentage of the track completed - // with linear interpolation between track points for sub-pixel smoothness - const calculatePositions = useMemo(() => { - if ( - !trackDrawing?.active?.trackPathPoints || - !trackDrawing?.startFinish?.point?.length || - !trackDrawing?.active?.totalLength - ) { - return {}; - } - - const trackPathPoints = trackDrawing.active.trackPathPoints; - const direction = trackDrawing.startFinish.direction; - const intersectionLength = trackDrawing.startFinish.point.length; - const totalLength = trackDrawing.active.totalLength; - - const result: Record< - number, - TrackDriver & { - position: { x: number; y: number }; - sessionPosition?: number; - } - > = {}; - - for (const { - driver, - progress, - isPlayer, - classPosition: sessionPosition, - } of drivers) { - // Calculate position based on progress - const adjustedLength = (totalLength * progress) % totalLength; - const length = - direction === 'anticlockwise' - ? (intersectionLength + adjustedLength) % totalLength - : (intersectionLength - adjustedLength + totalLength) % totalLength; - - // --- Linear Interpolation between points --- - const floatIndex = (length / totalLength) * (trackPathPoints.length - 1); - const index1 = Math.floor(floatIndex); - const index2 = Math.min(index1 + 1, trackPathPoints.length - 1); - const t = floatIndex - index1; - - const p1 = trackPathPoints[index1]; - const p2 = trackPathPoints[index2]; - - result[driver.CarIdx] = { - position: { - x: p1.x + (p2.x - p1.x) * t, - y: p1.y + (p2.y - p1.y) * t, - }, - driver, - isPlayer, - progress, - sessionPosition, - }; - } - - return result; - }, [ - drivers, - trackDrawing?.active?.trackPathPoints, - trackDrawing?.startFinish?.point?.length, - trackDrawing?.startFinish?.direction, - trackDrawing?.active?.totalLength, - ]); + // Snapshot-level collection setup. RAF frames mutate only position/progress. + const positionedDrivers = useMemo< + (PositionedTrackDriver & { interpolationIndex: number })[] + >(() => { + return drivers + .map( + ( + { driver, progress, isPlayer, classPosition }, + interpolationIndex + ) => ({ + driver, + progress, + isPlayer, + classPosition, + sessionPosition: classPosition, + position: { x: 0, y: 0 }, + textMetricsCache: { font: '', text: '', visualOffset: 0 }, + interpolationIndex, + }) + ) + .sort((a, b) => compareDriverDrawOrder(a, b, carIdxIsOnPitRoad)); + }, [drivers, carIdxIsOnPitRoad]); // Canvas setup and resize handling useEffect(() => { @@ -438,12 +400,28 @@ export const TrackCanvas = ({ sfIntersectionLength, ]); - // Dynamic layer — runs on every position tick, blits static cache then draws drivers - useLayoutEffect(() => { + // Dynamic layer — interpolates and paints imperatively between 25 Hz snapshots. + useProgressAnimation(drivers, (progressValues, count) => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!canvas || !ctx || !cacheCanvasRef.current) return; if (canvasSize.width === 0 || canvasSize.height === 0) return; + if (!trackPathPoints || !totalLength || sfIntersectionLength === undefined) + return; + + for (const entry of positionedDrivers) { + if (entry.interpolationIndex >= count) continue; + const progress = progressValues[entry.interpolationIndex]; + entry.progress = progress; + progressToTrackPoint( + progress, + trackPathPoints, + totalLength, + sfIntersectionLength, + sfDirection, + entry.position + ); + } // Blit static cache (identity transform to avoid double DPR scaling) ctx.save(); @@ -465,7 +443,7 @@ export const TrackCanvas = ({ const hasIconOverlay = !!playerIconDataUrl; drawDrivers( ctx, - calculatePositions, + positionedDrivers, driverColors, invertLeaderColor, driversOffTrack, @@ -489,9 +467,13 @@ export const TrackCanvas = ({ if (pitEl) pitEl.style.display = 'none'; return; } - const playerEntry = Object.values(calculatePositions).find( - (e) => e.isPlayer - ); + let playerEntry: PositionedTrackDriver | undefined; + for (const entry of positionedDrivers) { + if (entry.isPlayer) { + playerEntry = entry; + break; + } + } if (!playerEntry) { if (iconEl) iconEl.style.display = 'none'; if (pitEl) pitEl.style.display = 'none'; @@ -519,24 +501,7 @@ export const TrackCanvas = ({ pitEl.style.display = 'none'; } } - }, [ - calculatePositions, - canvasSize, - showCarNumbers, - displayMode, - driversOffTrack, - driverLivePositions, - carIdxIsOnPitRoad, - driverCircleSize, - playerCircleSize, - trackmapFontSize, - turnLabels, - driverColors, - invertLeaderColor, - isMinimalCar, - isMinimalTrack, - playerIconDataUrl, - ]); + }); const renderIconOverlay = () => playerIconDataUrl ? ( diff --git a/src/frontend/components/TrackMap/trackDrawingUtils.spec.ts b/src/frontend/components/TrackMap/trackDrawingUtils.spec.ts index 31f5daa19..44670de69 100644 --- a/src/frontend/components/TrackMap/trackDrawingUtils.spec.ts +++ b/src/frontend/components/TrackMap/trackDrawingUtils.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { drawDrivers } from './trackDrawingUtils'; +import { drawDrivers, getCachedTextVisualOffset } from './trackDrawingUtils'; describe('trackDrawingUtils', () => { let ctx: CanvasRenderingContext2D; @@ -31,6 +31,17 @@ describe('trackDrawingUtils', () => { } as any; }); + it('reuses text metrics until the font or label changes', () => { + const cache = { font: '', text: '', visualOffset: 0 }; + + expect(getCachedTextVisualOffset(ctx, '12', cache)).toBe(3); + expect(getCachedTextVisualOffset(ctx, '12', cache)).toBe(3); + expect(ctx.measureText).toHaveBeenCalledTimes(1); + + getCachedTextVisualOffset(ctx, '13', cache); + expect(ctx.measureText).toHaveBeenCalledTimes(2); + }); + describe('drawDrivers', () => { const driverColors = { 0: { fill: '#FF0000', text: 'white' }, diff --git a/src/frontend/components/TrackMap/trackDrawingUtils.ts b/src/frontend/components/TrackMap/trackDrawingUtils.ts index 242b1e030..34634c0dd 100644 --- a/src/frontend/components/TrackMap/trackDrawingUtils.ts +++ b/src/frontend/components/TrackMap/trackDrawingUtils.ts @@ -3,6 +3,28 @@ import { TrackDrawing, TrackDriver, TurnLabels } from './TrackCanvas'; import type { Sector } from '@irdashies/types'; import type { SectorColor } from '@irdashies/context'; +export interface TextMetricsCache { + font: string; + text: string; + visualOffset: number; +} + +export const getCachedTextVisualOffset = ( + ctx: CanvasRenderingContext2D, + text: string, + cache: TextMetricsCache +) => { + const font = ctx.font; + if (cache.font !== font || cache.text !== text) { + const metrics = ctx.measureText(text); + cache.font = font; + cache.text = text; + cache.visualOffset = + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2; + } + return cache.visualOffset; +}; + export const setupCanvasContext = ( ctx: CanvasRenderingContext2D, scale: number, @@ -146,15 +168,27 @@ export const drawTurnNames = ( }); }; +/** Pit-road cars first, then lower positions, with the player drawn last. */ +export const compareDriverDrawOrder = ( + a: PositionedTrackDriver, + b: PositionedTrackDriver, + carIdxIsOnPitRoad?: readonly boolean[] +) => { + const safePosition = (position: number | undefined) => + position !== undefined && isFinite(position) ? position : 0; + const aOnPit = !!carIdxIsOnPitRoad?.[a.driver.CarIdx]; + const bOnPit = !!carIdxIsOnPitRoad?.[b.driver.CarIdx]; + if (aOnPit !== bOnPit) return aOnPit ? -1 : 1; + if (a.isPlayer !== b.isPlayer) { + return Number(a.isPlayer) - Number(b.isPlayer); + } + return safePosition(b.sessionPosition) - safePosition(a.sessionPosition); +}; + export const drawDrivers = ( ctx: CanvasRenderingContext2D, - calculatePositions: Record< - number, - TrackDriver & { - position: { x: number; y: number }; - sessionPosition?: number; - } - >, + positionedDriverData: + PositionedTrackDriver[] | Record, driverColors: Record, invertLeaderColor: boolean, driversOffTrack: boolean[], @@ -167,86 +201,98 @@ export const drawDrivers = ( carIdxIsOnPitRoad?: readonly boolean[], hidePlayer?: boolean ) => { - const safePosition = (pos: number | undefined): number => - pos !== undefined && isFinite(pos) ? pos : 0; - Object.values(calculatePositions) - .sort((a, b) => { - const aOnPit = !!carIdxIsOnPitRoad?.[a.driver.CarIdx]; - const bOnPit = !!carIdxIsOnPitRoad?.[b.driver.CarIdx]; - if (aOnPit !== bOnPit) return aOnPit ? -1 : 1; // pit cars drawn first (under track drivers) - if (a.isPlayer !== b.isPlayer) { - return Number(a.isPlayer) - Number(b.isPlayer); // draws player last to be on top - } - return safePosition(b.sessionPosition) - safePosition(a.sessionPosition); // draws leader on top - }) - .forEach(({ driver, position, isPlayer, sessionPosition }) => { - let color = driverColors[driver.CarIdx]; - if (!color) return; - - if (isPlayer && hidePlayer) return; - - const circleRadius = isPlayer ? playerCircleSize : driverCircleSize; - const fontSize = circleRadius * (trackmapFontSize / 100); - const originalColor = color.fill; - const livePosition = - driverLivePositions[driver.CarIdx] ?? sessionPosition; - - // highlight leader? - if (!isPlayer && invertLeaderColor && livePosition === 1) { - color = { fill: 'white', text: originalColor }; - } - - // on pit road? - const onPitRoad = !!carIdxIsOnPitRoad?.[driver.CarIdx]; - if (onPitRoad) { - color = { fill: '#999999', text: 'white' }; - } + // Arrays are pre-sorted by the RAF caller to avoid per-frame allocations. + const positionedDrivers = Array.isArray(positionedDriverData) + ? positionedDriverData + : Object.values(positionedDriverData).sort((a, b) => + compareDriverDrawOrder(a, b, carIdxIsOnPitRoad) + ); + positionedDrivers.forEach((entry) => { + const { driver, position, isPlayer, sessionPosition } = entry; + const color = driverColors[driver.CarIdx]; + if (!color) return; + + if (isPlayer && hidePlayer) return; + + const circleRadius = isPlayer ? playerCircleSize : driverCircleSize; + const fontSize = circleRadius * (trackmapFontSize / 100); + const originalColor = color.fill; + let fillColor = color.fill; + let textColor = color.text; + const livePosition = driverLivePositions[driver.CarIdx] ?? sessionPosition; + + // highlight leader? + if (!isPlayer && invertLeaderColor && livePosition === 1) { + fillColor = 'white'; + textColor = originalColor; + } - ctx.fillStyle = color.fill; - ctx.beginPath(); - ctx.arc(position.x, position.y, circleRadius, 0, 2 * Math.PI); - ctx.fill(); + // on pit road? + const onPitRoad = !!carIdxIsOnPitRoad?.[driver.CarIdx]; + if (onPitRoad) { + fillColor = '#999999'; + textColor = 'white'; + } - // draw a border? - if (driversOffTrack[driver.CarIdx]) { - ctx.strokeStyle = getColor('yellow', 400); - ctx.lineWidth = 10; - ctx.stroke(); - } else if ( - !isPlayer && - !onPitRoad && - invertLeaderColor && - livePosition === 1 - ) { - ctx.strokeStyle = originalColor; - ctx.lineWidth = 4; - ctx.stroke(); - } + ctx.fillStyle = fillColor; + ctx.beginPath(); + ctx.arc(position.x, position.y, circleRadius, 0, 2 * Math.PI); + ctx.fill(); + + // draw a border? + if (driversOffTrack[driver.CarIdx]) { + ctx.strokeStyle = getColor('yellow', 400); + ctx.lineWidth = 10; + ctx.stroke(); + } else if ( + !isPlayer && + !onPitRoad && + invertLeaderColor && + livePosition === 1 + ) { + ctx.strokeStyle = originalColor; + ctx.lineWidth = 4; + ctx.stroke(); + } - if (showCarNumbers) { - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = color.text; - ctx.font = `${fontSize}px sans-serif`; - const displayText = onPitRoad - ? 'P' - : displayMode === 'livePosition' - ? livePosition && livePosition > 0 - ? livePosition.toString() + if (showCarNumbers) { + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = textColor; + ctx.font = `${fontSize}px sans-serif`; + const displayText = onPitRoad + ? 'P' + : displayMode === 'livePosition' + ? livePosition && livePosition > 0 + ? livePosition.toString() + : '' + : displayMode === 'sessionPosition' + ? sessionPosition && sessionPosition > 0 + ? sessionPosition.toString() : '' - : displayMode === 'sessionPosition' - ? sessionPosition && sessionPosition > 0 - ? sessionPosition.toString() - : '' - : driver.CarNumber; - if (displayText) { - const m = ctx.measureText(displayText); - const visualOffset = - (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2; - ctx.fillText(displayText, position.x, position.y + visualOffset); + : driver.CarNumber; + if (displayText) { + const cache = entry.textMetricsCache; + let visualOffset: number; + if (cache) { + visualOffset = getCachedTextVisualOffset(ctx, displayText, cache); + } else { + const metrics = ctx.measureText(displayText); + visualOffset = + (metrics.actualBoundingBoxAscent - + metrics.actualBoundingBoxDescent) / + 2; } + ctx.fillText(displayText, position.x, position.y + visualOffset); } - }); + } + }); +}; + +export type PositionedTrackDriver = TrackDriver & { + position: { x: number; y: number }; + sessionPosition?: number; + textMetricsCache?: TextMetricsCache; }; // --------------------------------------------------------------------------- diff --git a/src/frontend/components/TrackMap/useProgressAnimation.spec.tsx b/src/frontend/components/TrackMap/useProgressAnimation.spec.tsx new file mode 100644 index 000000000..5ecc79f27 --- /dev/null +++ b/src/frontend/components/TrackMap/useProgressAnimation.spec.tsx @@ -0,0 +1,198 @@ +import { act, render } from '@testing-library/react'; +import { useRef } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ProgressInterpolator, + progressToFlatX, + progressToTrackPoint, + useProgressAnimation, +} from './useProgressAnimation'; + +describe('ProgressInterpolator', () => { + it('interpolates normal movement', () => { + const interpolator = new ProgressInterpolator(40); + interpolator.setTargets([{ progress: 0.1 }], 0); + interpolator.setTargets([{ progress: 0.3 }], 0); + + expect(interpolator.advance(20)).toBe(true); + expect(interpolator.getValues()[0]).toBeCloseTo(0.2); + }); + + it('uses the shortest wrapped path across start/finish', () => { + const interpolator = new ProgressInterpolator(40); + interpolator.setTargets([{ progress: 0.99 }], 0); + interpolator.setTargets([{ progress: 0.01 }], 0); + + interpolator.advance(20); + expect(interpolator.getValues()[0]).toBeCloseTo(0); + interpolator.advance(40); + expect(interpolator.getValues()[0]).toBeCloseTo(0.01); + }); + + it('settles after one position interval', () => { + const interpolator = new ProgressInterpolator(40); + interpolator.setTargets([{ progress: 0.2 }], 0); + interpolator.setTargets([{ progress: 0.4 }], 0); + + expect(interpolator.advance(39)).toBe(true); + expect(interpolator.advance(40)).toBe(false); + expect(interpolator.getValues()[0]).toBeCloseTo(0.4); + }); + + it('adapts interpolation to the observed snapshot cadence', () => { + const interpolator = new ProgressInterpolator(); + interpolator.setTargets([{ progress: 0.1 }], 0); + interpolator.setTargets([{ progress: 0.3 }], 50); + + expect(interpolator.advance(75)).toBe(true); + expect(interpolator.getValues()[0]).toBeCloseTo(0.2); + expect(interpolator.advance(100)).toBe(false); + }); + + it('reuses its output collection throughout the frame path', () => { + const interpolator = new ProgressInterpolator(40); + interpolator.setTargets([{ progress: 0.1 }, { progress: 0.2 }], 0); + const output = interpolator.getValues(); + + for (let now = 0; now <= 40; now++) { + interpolator.advance(now); + expect(interpolator.getValues()).toBe(output); + } + }); + + it('preserves existing drivers by CarIdx when the roster changes', () => { + const interpolator = new ProgressInterpolator(40); + interpolator.setTargets( + [ + { progress: 0.1, driver: { CarIdx: 7 } }, + { progress: 0.5, driver: { CarIdx: 3 } }, + ], + 0 + ); + interpolator.setTargets( + [ + { progress: 0.7, driver: { CarIdx: 3 } }, + { progress: 0.9, driver: { CarIdx: 9 } }, + ], + 0 + ); + + expect(interpolator.getValues()[0]).toBeCloseTo(0.5); + expect(interpolator.getValues()[1]).toBeCloseTo(0.9); + expect(interpolator.getCount()).toBe(2); + }); +}); + +describe('map projection', () => { + it('projects interpolated progress onto the curved map in place', () => { + const output = { x: 0, y: 0 }; + progressToTrackPoint( + 0.5, + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ], + 100, + 0, + 'anticlockwise', + output + ); + + expect(output).toEqual({ x: 50, y: 0 }); + }); + + it('projects interpolated progress onto the flat map', () => { + expect(progressToFlatX(0.5, 40, 200)).toBe(140); + }); +}); + +describe('useProgressAnimation', () => { + let callbacks: FrameRequestCallback[]; + let nextFrameId: number; + + beforeEach(() => { + callbacks = []; + nextFrameId = 0; + vi.spyOn(performance, 'now').mockReturnValue(0); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callbacks.push(callback); + return ++nextFrameId; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + }); + + afterEach(() => { + delete window.rendererPerfBridge; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('stops RAF when settled without per-frame React renders', () => { + let renderCount = 0; + let drawCount = 0; + + const Harness = ({ progress }: { progress: number }) => { + renderCount++; + const stableDrivers = useRef([{ progress }]); + if (stableDrivers.current[0].progress !== progress) { + stableDrivers.current = [{ progress }]; + } + useProgressAnimation(stableDrivers.current, () => drawCount++); + return null; + }; + + const view = render(); + view.rerender(); + expect(callbacks).toHaveLength(1); + expect(drawCount).toBe(2); + + act(() => callbacks.shift()?.(20)); + expect(callbacks).toHaveLength(1); + act(() => callbacks.shift()?.(40)); + + expect(callbacks).toHaveLength(0); + expect(renderCount).toBe(2); + expect(drawCount).toBeGreaterThan(2); + }); + + it('records RAF work in renderer performance metrics', () => { + const recordMeasure = vi.fn(); + window.rendererPerfBridge = { recordMeasure }; + + const Harness = ({ progress }: { progress: number }) => { + const stableDrivers = useRef([{ progress }]); + if (stableDrivers.current[0].progress !== progress) { + stableDrivers.current = [{ progress }]; + } + useProgressAnimation(stableDrivers.current, () => undefined); + return null; + }; + + const view = render(); + view.rerender(); + act(() => callbacks.shift()?.(20)); + + expect(recordMeasure).toHaveBeenCalledWith( + 'trackMapAnimationFrame', + expect.any(Number) + ); + delete window.rendererPerfBridge; + }); + + it('cancels an active frame on unmount', () => { + const Harness = ({ progress }: { progress: number }) => { + const stableDrivers = useRef([{ progress }]); + if (stableDrivers.current[0].progress !== progress) { + stableDrivers.current = [{ progress }]; + } + useProgressAnimation(stableDrivers.current, () => undefined); + return null; + }; + + const view = render(); + view.rerender(); + view.unmount(); + + expect(cancelAnimationFrame).toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/components/TrackMap/useProgressAnimation.ts b/src/frontend/components/TrackMap/useProgressAnimation.ts new file mode 100644 index 000000000..235cf01f4 --- /dev/null +++ b/src/frontend/components/TrackMap/useProgressAnimation.ts @@ -0,0 +1,219 @@ +import { useLayoutEffect, useRef } from 'react'; +import { perfMetrics } from '@irdashies/utils/perfMetrics'; + +export const TRACK_POSITION_INTERVAL_MS = 1000 / 25; +const MIN_POSITION_INTERVAL_MS = 1000 / 60; +const MAX_POSITION_INTERVAL_MS = 100; + +type ProgressSource = readonly { + progress: number; + driver?: { CarIdx: number }; +}[]; +type DrawProgress = (progress: Float64Array, count: number) => void; + +export const progressToFlatX = ( + progress: number, + startX: number, + usableWidth: number +) => startX + progress * usableWidth; + +export const progressToTrackPoint = ( + progress: number, + trackPathPoints: readonly { x: number; y: number }[], + totalLength: number, + intersectionLength: number, + direction: 'clockwise' | 'anticlockwise' | null | undefined, + output: { x: number; y: number } +) => { + const adjustedLength = (totalLength * progress) % totalLength; + const length = + direction === 'anticlockwise' + ? (intersectionLength + adjustedLength) % totalLength + : (intersectionLength - adjustedLength + totalLength) % totalLength; + const floatIndex = (length / totalLength) * (trackPathPoints.length - 1); + const index1 = Math.floor(floatIndex); + const index2 = Math.min(index1 + 1, trackPathPoints.length - 1); + const amount = floatIndex - index1; + const point1 = trackPathPoints[index1]; + const point2 = trackPathPoints[index2]; + output.x = point1.x + (point2.x - point1.x) * amount; + output.y = point1.y + (point2.y - point1.y) * amount; +}; + +const wrapProgress = (progress: number) => { + const wrapped = progress % 1; + return wrapped < 0 ? wrapped + 1 : wrapped; +}; + +/** + * Allocation-free interpolation state for the track-map RAF hot path. + * Buffers only grow when the driver roster grows; advance() mutates them. + */ +export class ProgressInterpolator { + private starts = new Float64Array(0); + private targets = new Float64Array(0); + private values = new Float64Array(0); + private previousValues = new Float64Array(0); + private driverIds = new Int32Array(0); + private previousDriverIds = new Int32Array(0); + private count = 0; + private startedAt = 0; + private lastTargetsAt = -1; + private initialized = false; + + constructor(private durationMs = TRACK_POSITION_INTERVAL_MS) {} + + setTargets(source: ProgressSource, now: number): boolean { + if (this.initialized) this.advance(now); + if (this.lastTargetsAt >= 0 && now > this.lastTargetsAt) { + this.durationMs = Math.min( + MAX_POSITION_INTERVAL_MS, + Math.max(MIN_POSITION_INTERVAL_MS, now - this.lastTargetsAt) + ); + } + this.lastTargetsAt = now; + this.ensureCapacity(source.length); + const previousCount = this.count; + for (let i = 0; i < previousCount; i++) { + this.previousValues[i] = this.values[i]; + this.previousDriverIds[i] = this.driverIds[i]; + } + this.count = source.length; + + for (let i = 0; i < this.count; i++) { + const target = wrapProgress(source[i].progress); + const driverId = source[i].driver?.CarIdx ?? i; + let previousIndex = -1; + if (this.initialized) { + for (let j = 0; j < previousCount; j++) { + if (this.previousDriverIds[j] === driverId) { + previousIndex = j; + break; + } + } + } + this.values[i] = + previousIndex === -1 ? target : this.previousValues[previousIndex]; + this.starts[i] = this.values[i]; + this.targets[i] = target; + this.driverIds[i] = driverId; + } + + this.startedAt = now; + this.initialized = true; + return this.hasMovement(); + } + + advance(now: number): boolean { + if (!this.initialized) return false; + const elapsed = Math.max(0, now - this.startedAt); + const amount = Math.min(1, elapsed / this.durationMs); + + for (let i = 0; i < this.count; i++) { + let delta = this.targets[i] - this.starts[i]; + if (delta > 0.5) delta -= 1; + else if (delta < -0.5) delta += 1; + this.values[i] = wrapProgress(this.starts[i] + delta * amount); + } + + return amount < 1 && this.hasMovement(); + } + + getValues(): Float64Array { + return this.values; + } + + getCount(): number { + return this.count; + } + + private hasMovement(): boolean { + for (let i = 0; i < this.count; i++) { + let delta = this.targets[i] - this.starts[i]; + if (delta > 0.5) delta -= 1; + else if (delta < -0.5) delta += 1; + if (Math.abs(delta) > Number.EPSILON) return true; + } + return false; + } + + private ensureCapacity(count: number) { + if (this.values.length >= count) return; + const starts = new Float64Array(count); + const targets = new Float64Array(count); + const values = new Float64Array(count); + const driverIds = new Int32Array(count); + starts.set(this.starts); + targets.set(this.targets); + values.set(this.values); + driverIds.set(this.driverIds); + this.starts = starts; + this.targets = targets; + this.values = values; + this.previousValues = new Float64Array(count); + this.driverIds = driverIds; + this.previousDriverIds = new Int32Array(count); + } +} + +export const useProgressAnimation = ( + drivers: ProgressSource, + draw: DrawProgress +) => { + const interpolatorRef = useRef(null); + const drawRef = useRef(draw); + const frameRef = useRef(0); + const previousDriversRef = useRef(null); + + if (!interpolatorRef.current) { + interpolatorRef.current = new ProgressInterpolator(); + } + + // Commit the latest draw callback before target updates or RAF work. Skip + // the repaint when the target effect below will paint this same commit. + useLayoutEffect(() => { + drawRef.current = draw; + if (previousDriversRef.current !== drivers) return; + const interpolator = interpolatorRef.current; + if (!interpolator) return; + const drawCommittedAppearance = () => + draw(interpolator.getValues(), interpolator.getCount()); + perfMetrics.measure('trackMapAnimationFrame', drawCommittedAppearance); + }); + + useLayoutEffect(() => { + const interpolator = interpolatorRef.current; + if (!interpolator) return; + previousDriversRef.current = drivers; + + let frameTime = 0; + const measuredFrame = () => { + const active = interpolator.advance(frameTime); + drawRef.current(interpolator.getValues(), interpolator.getCount()); + return active; + }; + const frame = (now: number) => { + frameTime = now; + const active = perfMetrics.measure( + 'trackMapAnimationFrame', + measuredFrame + ); + frameRef.current = active ? requestAnimationFrame(frame) : 0; + }; + + const active = interpolator.setTargets(drivers, performance.now()); + const drawSnapshot = () => + drawRef.current(interpolator.getValues(), interpolator.getCount()); + perfMetrics.measure('trackMapAnimationFrame', drawSnapshot); + if (active && frameRef.current === 0) { + frameRef.current = requestAnimationFrame(frame); + } + + return () => { + if (frameRef.current !== 0) { + cancelAnimationFrame(frameRef.current); + frameRef.current = 0; + } + }; + }, [drivers]); +}; diff --git a/src/frontend/utils/perfMetrics.ts b/src/frontend/utils/perfMetrics.ts new file mode 100644 index 000000000..bc1d356eb --- /dev/null +++ b/src/frontend/utils/perfMetrics.ts @@ -0,0 +1,15 @@ +import type { RendererPerfMeasureName } from '@irdashies/types'; + +export const perfMetrics = { + measure(name: RendererPerfMeasureName, measured: () => T): T { + const bridge = window.rendererPerfBridge; + if (!bridge) return measured(); + + const startedAt = performance.now(); + try { + return measured(); + } finally { + bridge.recordMeasure(name, performance.now() - startedAt); + } + }, +}; diff --git a/src/interface.d.ts b/src/interface.d.ts index f183ac48b..aad2beed0 100644 --- a/src/interface.d.ts +++ b/src/interface.d.ts @@ -8,6 +8,7 @@ import type { GamepadHostBridge, ChromiumFlagsBridge, TelemetryInspectorBridge, + RendererPerfBridge, } from '@irdashies/types'; import type { ChannelBridge } from '@irdashies/types'; @@ -24,5 +25,6 @@ declare global { /** Present only in the hidden WebHID host renderer (src/hidHost.ts). */ gamepadHost?: GamepadHostBridge; chromiumFlagsBridge: ChromiumFlagsBridge; + rendererPerfBridge?: RendererPerfBridge; } } diff --git a/src/preload.ts b/src/preload.ts index 7e71abe3c..31186b440 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -5,7 +5,7 @@ import { exposeInMainWorld } from './app/rendererExpose'; import { startRendererPerfMetrics } from './app/rendererPerfMetrics'; import { exposeChannelBridge } from './app/bridge/channelRendererBridge'; +startRendererPerfMetrics(); exposeBridge(); exposeInMainWorld(); exposeChannelBridge(); -startRendererPerfMetrics(); diff --git a/src/types/performance.ts b/src/types/performance.ts index 66c550dad..4c0ad65e5 100644 --- a/src/types/performance.ts +++ b/src/types/performance.ts @@ -21,8 +21,15 @@ export interface RendererPerfSample { frameTimeMs: NumericSampleStats; telemetryCallbackMs?: NumericSampleStats; channelCallbackMs?: NumericSampleStats; + trackMapAnimationFrameMs?: NumericSampleStats; telemetryWakeups?: number; channelWakeups?: number; framesOver25Ms: number; framesOver50Ms: number; } + +export type RendererPerfMeasureName = 'trackMapAnimationFrame'; + +export interface RendererPerfBridge { + recordMeasure: (name: RendererPerfMeasureName, durationMs: number) => void; +} diff --git a/tools/perf/analyze.spec.ts b/tools/perf/analyze.spec.ts index 376c1d60a..ca0e3b7c1 100644 --- a/tools/perf/analyze.spec.ts +++ b/tools/perf/analyze.spec.ts @@ -6,6 +6,7 @@ import type { } from '../../src/types/performance'; import { compareSummaries, + comparisonMarkdown, parseCliArgs, parsePerfLog, summarizeCapture, @@ -123,11 +124,7 @@ describe('performance analysis', () => { it('summarizes memory slope and telemetry metrics', () => { const summary = summarizeCapture( - capture([ - mainSample(0, 100), - mainSample(60, 102), - mainSample(120, 104), - ]), + capture([mainSample(0, 100), mainSample(60, 102), mainSample(120, 104)]), 0 ); @@ -157,6 +154,7 @@ describe('performance analysis', () => { intervalMs: 5000, frameTimeMs: stats(16), telemetryCallbackMs: stats(0.2, { count: 100, p99: 0.7 }), + trackMapAnimationFrameMs: stats(0.4, { count: 250, p99: 1.1 }), framesOver25Ms: 0, framesOver50Ms: 0, }, @@ -165,6 +163,24 @@ describe('performance analysis', () => { expect(summary.renderer.telemetryCallbackRateHz).toBe(20); expect(summary.renderer.telemetryCallbackP99MeanMs).toBe(0.7); + expect(summary.renderer.trackMapAnimationFrameRateHz).toBe(50); + expect(summary.renderer.trackMapAnimationFrameP99MeanMs).toBe(1.1); + expect(summary.renderer.trackMapAnimationFrameP99WorstMs).toBe(1.1); + + const candidate = { + ...summary, + renderer: { + ...summary.renderer, + trackMapAnimationFrameRateHz: 60, + trackMapAnimationFrameP99MeanMs: 1.6, + }, + }; + const comparison = compareSummaries(summary, candidate); + expect(comparison.delta.trackMapAnimationFrameRateHz).toBe(10); + expect(comparison.delta.trackMapAnimationFrameP99Ms).toBeCloseTo(0.5); + expect(comparisonMarkdown(comparison)).toContain( + '| Track-map animation-frame p99 mean | 0.500 ms |' + ); }); it('flags a material iRacing FPS regression with conclusive evidence', () => { @@ -225,7 +241,9 @@ describe('performance analysis', () => { expect(summary.evidence.privateMemoryAvailable).toBe(false); expect(summary.evidence.conclusive).toBe(false); - expect(comparison.checks.every((check) => check.passed === null)).toBe(true); + expect(comparison.checks.every((check) => check.passed === null)).toBe( + true + ); }); it('requires publication rather than processor execution for visible coverage', () => { diff --git a/tools/perf/analyze.ts b/tools/perf/analyze.ts index 906190e27..d2ebd1f82 100644 --- a/tools/perf/analyze.ts +++ b/tools/perf/analyze.ts @@ -172,6 +172,9 @@ export interface PerfSummary { totalWakeupRateHz: number; telemetryCallbackP99MeanMs: number; telemetryCallbackP99WorstMs: number; + trackMapAnimationFrameRateHz: number; + trackMapAnimationFrameP99MeanMs: number; + trackMapAnimationFrameP99WorstMs: number; framesOver25MsPercent: number; framesOver50MsPercent: number; }; @@ -226,6 +229,8 @@ export interface PerfComparison { peakMemoryMB: number; processTelemetryP99Ms: number; eventLoopP99Ms: number; + trackMapAnimationFrameRateHz: number; + trackMapAnimationFrameP99Ms: number; }; checks: { name: string; @@ -325,9 +330,10 @@ interface IntervalBounds { end: number; } -const sampleBounds = ( - sample: { timestamp: string; intervalMs?: number } -): IntervalBounds => { +const sampleBounds = (sample: { + timestamp: string; + intervalMs?: number; +}): IntervalBounds => { const end = Date.parse(sample.timestamp); return { start: end - Math.max(0, sample.intervalMs ?? 0), @@ -380,9 +386,7 @@ const coveredDurationSeconds = ( return coveredMs / 1000; }; -type ChannelMetricField = keyof NonNullable< - MainPerfSample['channelMetrics'] ->; +type ChannelMetricField = keyof NonNullable; const isMetricMap = (value: unknown): value is Record => typeof value === 'object' && @@ -407,13 +411,10 @@ const channelCount = ( field: ChannelMetricField, channel: string ): number => - samples.reduce( - (sum, sample) => { - const metrics = sample.channelMetrics?.[field]; - return sum + (isMetricMap(metrics) ? (metrics[channel] ?? 0) : 0); - }, - 0 - ); + samples.reduce((sum, sample) => { + const metrics = sample.channelMetrics?.[field]; + return sum + (isMetricMap(metrics) ? (metrics[channel] ?? 0) : 0); + }, 0); const summarizeChannels = ( samples: readonly MainPerfSample[], @@ -491,8 +492,8 @@ export function summarizeCapture( ...capture.main.map((sample) => Date.parse(sample.timestamp)) ); const analysisEnd = configuredAnalysisEnd ?? lastSampleEnd + 1; - const main = capture.main.filter( - (sample) => overlaps(sampleBounds(sample), analysisStart, analysisEnd) + const main = capture.main.filter((sample) => + overlaps(sampleBounds(sample), analysisStart, analysisEnd) ); if (main.length === 0) { throw new Error('No PerfMetrics samples fall inside the analysis window.'); @@ -526,6 +527,13 @@ export function summarizeCapture( renderer .filter((sample) => sample.telemetryCallbackMs !== undefined) .reduce((sum, sample) => sum + sample.intervalMs, 0) / 1000; + const trackMapAnimationFrames = renderer + .map((sample) => sample.trackMapAnimationFrameMs) + .filter((stats): stats is NumericSampleStats => stats !== undefined); + const trackMapAnimationSeconds = + renderer + .filter((sample) => sample.trackMapAnimationFrameMs !== undefined) + .reduce((sum, sample) => sum + sample.intervalMs, 0) / 1000; const rendererSeconds = renderer.reduce((sum, sample) => sum + sample.intervalMs, 0) / 1000; const telemetryWakeups = renderer.reduce( @@ -630,22 +638,21 @@ export function summarizeCapture( phase.start < analysisEnd ); const hasVisibilitySchedule = (capture.visibility ?? []).length > 0; - const phaseWindows = - hasVisibilitySchedule - ? configuredPhases.map((phase) => ({ - index: phase.marker.index, - visibility: phase.marker.visibility, - start: Math.max(analysisStart, phase.start), - end: Math.min(analysisEnd, phase.end), - })) - : [ - { - index: 0, - visibility: 'visible' as const, - start: analysisStart, - end: analysisEnd, - }, - ]; + const phaseWindows = hasVisibilitySchedule + ? configuredPhases.map((phase) => ({ + index: phase.marker.index, + visibility: phase.marker.visibility, + start: Math.max(analysisStart, phase.start), + end: Math.min(analysisEnd, phase.end), + })) + : [ + { + index: 0, + visibility: 'visible' as const, + start: analysisStart, + end: analysisEnd, + }, + ]; const samplesForPhase = (phase: (typeof phaseWindows)[number]) => effectiveMain.filter((sample) => { const bounds = sampleBounds(sample); @@ -776,9 +783,9 @@ export function summarizeCapture( ? (lastPrivateTimestamp - firstPrivateTimestamp) / 1000 : 0; const privateMemoryMaxGapSeconds = maximum( - privateTimestamps.slice(1).map( - (timestamp, index) => (timestamp - privateTimestamps[index]) / 1000 - ) + privateTimestamps + .slice(1) + .map((timestamp, index) => (timestamp - privateTimestamps[index]) / 1000) ); const requiredPrivateSpanSeconds = Math.min(durationSeconds, MIN_ANALYSIS_SECONDS) * MIN_PRIVATE_SPAN_RATIO; @@ -815,7 +822,7 @@ export function summarizeCapture( ? ['private-memory samples are unavailable or incomplete'] : !privateMemorySamplingAdequate ? ['private-memory sampling span or cadence is insufficient'] - : []), + : []), ...(!inputCoverageAvailable ? ['widget input coverage metadata is unavailable'] : !inputCoverageComplete @@ -1096,6 +1103,22 @@ export function summarizeCapture( telemetryCallbackP99WorstMs: maximum( rendererTelemetryCallbacks.map((stats) => stats.p99) ), + trackMapAnimationFrameRateHz: + trackMapAnimationSeconds === 0 + ? 0 + : trackMapAnimationFrames.reduce( + (sum, stats) => sum + stats.count, + 0 + ) / trackMapAnimationSeconds, + trackMapAnimationFrameP99MeanMs: weightedAverage( + trackMapAnimationFrames.map((stats) => ({ + value: stats.p99, + weight: stats.count, + })) + ), + trackMapAnimationFrameP99WorstMs: maximum( + trackMapAnimationFrames.map((stats) => stats.p99) + ), framesOver25MsPercent: rendererFrameCount === 0 ? 0 @@ -1175,6 +1198,12 @@ export function compareSummaries( baseline.telemetry.processTelemetryP99MeanMs, eventLoopP99Ms: candidate.eventLoop.p99MeanMs - baseline.eventLoop.p99MeanMs, + trackMapAnimationFrameRateHz: + candidate.renderer.trackMapAnimationFrameRateHz - + baseline.renderer.trackMapAnimationFrameRateHz, + trackMapAnimationFrameP99Ms: + candidate.renderer.trackMapAnimationFrameP99MeanMs - + baseline.renderer.trackMapAnimationFrameP99MeanMs, }, checks: [ { @@ -1306,6 +1335,8 @@ export function summaryMarkdown(summary: PerfSummary): string { | Renderer frame-time p99 mean | ${format(summary.renderer.frameTimeP99MeanMs)} ms | | Renderer telemetry callbacks / second | ${format(summary.renderer.telemetryCallbackRateHz)} | | Renderer telemetry callback p99, mean / worst interval | ${format(summary.renderer.telemetryCallbackP99MeanMs, 3)} / ${format(summary.renderer.telemetryCallbackP99WorstMs, 3)} ms | +| Track-map animation frames / second | ${format(summary.renderer.trackMapAnimationFrameRateHz)} | +| Track-map animation-frame p99, mean / worst interval | ${format(summary.renderer.trackMapAnimationFrameP99MeanMs, 3)} / ${format(summary.renderer.trackMapAnimationFrameP99WorstMs, 3)} ms | | Renderer frames over 25 / 50 ms | ${format(summary.renderer.framesOver25MsPercent, 3)}% / ${format(summary.renderer.framesOver50MsPercent, 3)}% | | Worst renderer frame | ${format(summary.renderer.worstFrameMs)} ms | @@ -1374,6 +1405,8 @@ export function comparisonMarkdown(comparison: PerfComparison): string { | irDashies peak memory | ${format(delta.peakMemoryMB)} MB | | processTelemetry p99 mean | ${format(delta.processTelemetryP99Ms)} ms | | Main event-loop p99 mean | ${format(delta.eventLoopP99Ms)} ms | +| Track-map animation frames / second | ${format(delta.trackMapAnimationFrameRateHz)} Hz | +| Track-map animation-frame p99 mean | ${format(delta.trackMapAnimationFrameP99Ms, 3)} ms | | Check | Status | Actual | Target | | --- | --- | ---: | ---: |