mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
1 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0977861806 |
282
frontend/src/components/charts/DeltaTimeGraph.tsx
Normal file
282
frontend/src/components/charts/DeltaTimeGraph.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
computeCumulativeDeltas,
|
||||||
|
deltaPolylineSegments,
|
||||||
|
formatDeltaSeconds,
|
||||||
|
type DeltaSeries,
|
||||||
|
type DriverDeltaResult,
|
||||||
|
} from '../../lib/delta'
|
||||||
|
import '../../styles/delta-graph.css'
|
||||||
|
|
||||||
|
export type { DeltaSeries }
|
||||||
|
|
||||||
|
export interface DeltaTimeGraphProps {
|
||||||
|
series: DeltaSeries[]
|
||||||
|
referenceLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 640
|
||||||
|
const H = 220
|
||||||
|
const PL = 44
|
||||||
|
const PR = 16
|
||||||
|
const PT = 12
|
||||||
|
const PB = 28
|
||||||
|
|
||||||
|
function lapCount(series: ReadonlyArray<DeltaSeries>): number {
|
||||||
|
return series.reduce((max, s) => Math.max(max, s.lapTimes.length), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function yExtent(drivers: ReadonlyArray<DriverDeltaResult>): { min: number; max: number } {
|
||||||
|
let min = 0
|
||||||
|
let max = 0
|
||||||
|
for (const driver of drivers) {
|
||||||
|
for (const delta of driver.deltas) {
|
||||||
|
if (delta === null) continue
|
||||||
|
min = Math.min(min, delta)
|
||||||
|
max = Math.max(max, delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (min === max) {
|
||||||
|
const pad = 1
|
||||||
|
return { min: min - pad, max: max + pad }
|
||||||
|
}
|
||||||
|
const span = max - min
|
||||||
|
const pad = span * 0.08
|
||||||
|
return { min: min - pad, max: max + pad }
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceYTicks(min: number, max: number): number[] {
|
||||||
|
const span = max - min
|
||||||
|
if (span <= 0) return [0]
|
||||||
|
const rough = span / 4
|
||||||
|
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)))
|
||||||
|
const step = Math.ceil(rough / magnitude) * magnitude
|
||||||
|
const ticks: number[] = []
|
||||||
|
const start = Math.ceil(min / step) * step
|
||||||
|
for (let v = start; v <= max + step * 0.01; v += step) {
|
||||||
|
ticks.push(Number(v.toFixed(6)))
|
||||||
|
}
|
||||||
|
if (!ticks.some((t) => Math.abs(t) < step * 0.01)) {
|
||||||
|
ticks.push(0)
|
||||||
|
ticks.sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeltaTimeGraph({ series, referenceLabel }: DeltaTimeGraphProps) {
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const [hoverLap, setHoverLap] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const drivers = useMemo(
|
||||||
|
() => computeCumulativeDeltas(series, referenceLabel),
|
||||||
|
[series, referenceLabel],
|
||||||
|
)
|
||||||
|
|
||||||
|
const laps = useMemo(() => lapCount(series), [series])
|
||||||
|
const plotW = W - PL - PR
|
||||||
|
const plotH = H - PT - PB
|
||||||
|
const { min: yMin, max: yMax } = useMemo(() => yExtent(drivers), [drivers])
|
||||||
|
const yTicks = useMemo(() => niceYTicks(yMin, yMax), [yMin, yMax])
|
||||||
|
|
||||||
|
const toX = useCallback(
|
||||||
|
(lapIndex: number) => {
|
||||||
|
if (laps <= 1) return PL + plotW / 2
|
||||||
|
return PL + (lapIndex / (laps - 1)) * plotW
|
||||||
|
},
|
||||||
|
[laps, plotW],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toY = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
const span = yMax - yMin || 1
|
||||||
|
return PT + ((delta - yMin) / span) * plotH
|
||||||
|
},
|
||||||
|
[yMin, yMax, plotH],
|
||||||
|
)
|
||||||
|
|
||||||
|
const lapTickNumbers = useMemo(() => {
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let lap = 5; lap <= laps; lap += 5) {
|
||||||
|
ticks.push(lap)
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}, [laps])
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>) => {
|
||||||
|
if (!svgRef.current || laps === 0) return
|
||||||
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
|
if (rect.width <= 0) return
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * W
|
||||||
|
const frac = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||||
|
const lapIndex = laps <= 1 ? 0 : Math.round(frac * (laps - 1))
|
||||||
|
if (!Number.isFinite(lapIndex)) return
|
||||||
|
setHoverLap(Math.max(0, Math.min(laps - 1, lapIndex)))
|
||||||
|
},
|
||||||
|
[laps, plotW],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePointerLeave = useCallback(() => setHoverLap(null), [])
|
||||||
|
|
||||||
|
if (series.length === 0 || laps === 0) {
|
||||||
|
return (
|
||||||
|
<div className="delta-graph" data-testid="delta-time-graph-empty">
|
||||||
|
<p className="delta-graph-empty">No lap data to compare.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drivers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="delta-graph" data-testid="delta-time-graph-empty">
|
||||||
|
<p className="delta-graph-empty">Select at least two drivers to compare.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverX = hoverLap !== null ? toX(hoverLap) : null
|
||||||
|
const tooltipRows = hoverLap !== null
|
||||||
|
? drivers
|
||||||
|
.map((d) => {
|
||||||
|
const delta = d.deltas[hoverLap]
|
||||||
|
if (delta == null) return null
|
||||||
|
return { label: d.label, color: d.color, delta }
|
||||||
|
})
|
||||||
|
.filter((row): row is { label: string; color: string; delta: number } => row !== null)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const tooltipH = 18 + tooltipRows.length * 14
|
||||||
|
const tooltipW = 120
|
||||||
|
const tooltipX = hoverX !== null ? Math.min(Math.max(hoverX + 8, PL), W - PR - tooltipW) : 0
|
||||||
|
const tooltipY = PT
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="delta-graph" data-testid="delta-time-graph">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="delta-graph-svg"
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
role="img"
|
||||||
|
aria-label="Cumulative delta time chart"
|
||||||
|
>
|
||||||
|
{yTicks.map((tick) => (
|
||||||
|
<g key={`y-${tick}`}>
|
||||||
|
<line
|
||||||
|
x1={PL}
|
||||||
|
x2={W - PR}
|
||||||
|
y1={toY(tick)}
|
||||||
|
y2={toY(tick)}
|
||||||
|
className={Math.abs(tick) < 1e-9 ? 'delta-graph-zero-line' : 'delta-graph-grid-line'}
|
||||||
|
data-testid={Math.abs(tick) < 1e-9 ? 'delta-zero-line' : undefined}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={PL - 6}
|
||||||
|
y={toY(tick) + 3}
|
||||||
|
textAnchor="end"
|
||||||
|
className="delta-graph-axis-label"
|
||||||
|
>
|
||||||
|
{formatDeltaSeconds(tick)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{lapTickNumbers.map((lap) => {
|
||||||
|
const x = toX(lap - 1)
|
||||||
|
return (
|
||||||
|
<g key={`lap-${lap}`}>
|
||||||
|
<line
|
||||||
|
x1={x}
|
||||||
|
x2={x}
|
||||||
|
y1={H - PB}
|
||||||
|
y2={H - PB + 4}
|
||||||
|
className="delta-graph-grid-line"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={H - PB + 16}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="delta-graph-axis-label"
|
||||||
|
>
|
||||||
|
{lap}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{drivers.map((driver) => {
|
||||||
|
const segments = deltaPolylineSegments(driver.deltas, (lapIndex, delta) =>
|
||||||
|
`${toX(lapIndex).toFixed(1)},${toY(delta).toFixed(1)}`,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<g key={driver.label} data-testid={`delta-line-${driver.label}`}>
|
||||||
|
{segments.map((points, i) => (
|
||||||
|
<polyline
|
||||||
|
key={`${driver.label}-${i}`}
|
||||||
|
points={points}
|
||||||
|
className="delta-graph-driver-line"
|
||||||
|
stroke={driver.color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{hoverX !== null && (
|
||||||
|
<>
|
||||||
|
<line
|
||||||
|
x1={hoverX}
|
||||||
|
x2={hoverX}
|
||||||
|
y1={PT}
|
||||||
|
y2={H - PB}
|
||||||
|
className="delta-graph-crosshair"
|
||||||
|
data-testid="delta-crosshair"
|
||||||
|
/>
|
||||||
|
{tooltipRows.length > 0 && (
|
||||||
|
<g className="delta-graph-tooltip" data-testid="delta-tooltip">
|
||||||
|
<rect
|
||||||
|
x={tooltipX}
|
||||||
|
y={tooltipY}
|
||||||
|
width={tooltipW}
|
||||||
|
height={tooltipH}
|
||||||
|
rx={4}
|
||||||
|
className="delta-graph-tooltip-bg"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={tooltipX + 8}
|
||||||
|
y={tooltipY + 14}
|
||||||
|
className="delta-graph-tooltip-title"
|
||||||
|
>
|
||||||
|
Lap {hoverLap! + 1}
|
||||||
|
</text>
|
||||||
|
{tooltipRows.map((row, i) => (
|
||||||
|
<text
|
||||||
|
key={row.label}
|
||||||
|
x={tooltipX + 8}
|
||||||
|
y={tooltipY + 28 + i * 14}
|
||||||
|
className="delta-graph-tooltip-row"
|
||||||
|
fill={row.color}
|
||||||
|
>
|
||||||
|
{row.label} {formatDeltaSeconds(row.delta)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x={PL}
|
||||||
|
y={PT}
|
||||||
|
width={plotW}
|
||||||
|
height={plotH}
|
||||||
|
fill="transparent"
|
||||||
|
className="delta-graph-hover-layer"
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onMouseMove={handlePointerMove}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
onMouseLeave={handlePointerLeave}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { useMemo, useRef, useState } from 'react'
|
|
||||||
import '../../styles/telemetry-trace.css'
|
|
||||||
|
|
||||||
export interface TelemetryTraceSample {
|
|
||||||
speed: number
|
|
||||||
throttle: number
|
|
||||||
brake: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TelemetryTraceSeries {
|
|
||||||
label: string
|
|
||||||
color: string
|
|
||||||
samples: TelemetryTraceSample[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TelemetryTraceChannel = 'speed' | 'throttle' | 'brake'
|
|
||||||
|
|
||||||
export interface TelemetryTraceChartProps {
|
|
||||||
series: TelemetryTraceSeries[]
|
|
||||||
channels?: TelemetryTraceChannel[]
|
|
||||||
/** Height of each channel panel in SVG units (viewBox space). */
|
|
||||||
height?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const VIEW_WIDTH = 800
|
|
||||||
const PANEL_GAP = 18
|
|
||||||
const PAD_TOP = 6
|
|
||||||
const PAD_BOTTOM = 6
|
|
||||||
|
|
||||||
const CHANNEL_LABELS: Record<TelemetryTraceChannel, string> = {
|
|
||||||
speed: 'Speed',
|
|
||||||
throttle: 'Throttle',
|
|
||||||
brake: 'Brake',
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
|
|
||||||
|
|
||||||
function channelValue(sample: TelemetryTraceSample, channel: TelemetryTraceChannel): number {
|
|
||||||
const raw = sample[channel]
|
|
||||||
// Throttle/brake are percentages; clamp so out-of-range API values can't
|
|
||||||
// draw outside the panel. Speed is clamped to >= 0.
|
|
||||||
if (channel === 'speed') return Math.max(0, raw)
|
|
||||||
return clamp(raw, 0, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatValue(value: number, channel: TelemetryTraceChannel): string {
|
|
||||||
if (channel === 'speed') return `${Math.round(value)} km/h`
|
|
||||||
return `${Math.round(value)}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TelemetryTraceChart({
|
|
||||||
series,
|
|
||||||
channels = ['speed', 'throttle', 'brake'],
|
|
||||||
height = 110,
|
|
||||||
}: TelemetryTraceChartProps) {
|
|
||||||
const svgRef = useRef<SVGSVGElement | null>(null)
|
|
||||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
|
||||||
|
|
||||||
// Series are index-aligned; mismatched lengths are clamped to the shortest
|
|
||||||
// series so every drawn x has a value for every driver.
|
|
||||||
const sampleCount = useMemo(
|
|
||||||
() => (series.length === 0 ? 0 : Math.min(...series.map((s) => s.samples.length))),
|
|
||||||
[series],
|
|
||||||
)
|
|
||||||
|
|
||||||
const speedMax = useMemo(() => {
|
|
||||||
let max = 0
|
|
||||||
for (const s of series) {
|
|
||||||
for (let i = 0; i < sampleCount; i++) {
|
|
||||||
max = Math.max(max, channelValue(s.samples[i], 'speed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return max > 0 ? max : 1
|
|
||||||
}, [series, sampleCount])
|
|
||||||
|
|
||||||
if (series.length === 0 || sampleCount === 0 || channels.length === 0) {
|
|
||||||
return <div className="telemetry-trace-empty">No telemetry data</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp a stale hover index in case the series prop shrank between renders.
|
|
||||||
const hover = hoverIndex === null ? null : Math.min(hoverIndex, sampleCount - 1)
|
|
||||||
|
|
||||||
const panelHeight = height
|
|
||||||
const totalHeight = channels.length * panelHeight + (channels.length - 1) * PANEL_GAP
|
|
||||||
const xAt = (i: number) => (i / Math.max(1, sampleCount - 1)) * VIEW_WIDTH
|
|
||||||
|
|
||||||
const channelMax = (channel: TelemetryTraceChannel) => (channel === 'speed' ? speedMax : 100)
|
|
||||||
|
|
||||||
const yAt = (value: number, channel: TelemetryTraceChannel, panelTop: number) => {
|
|
||||||
const usable = panelHeight - PAD_TOP - PAD_BOTTOM
|
|
||||||
const frac = channelValue({ speed: value, throttle: value, brake: value }, channel) / channelMax(channel)
|
|
||||||
return panelTop + PAD_TOP + (1 - frac) * usable
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
|
||||||
const rect = svgRef.current?.getBoundingClientRect()
|
|
||||||
if (!rect || rect.width === 0) {
|
|
||||||
setHoverIndex(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const frac = clamp((e.clientX - rect.left) / rect.width, 0, 1)
|
|
||||||
setHoverIndex(clamp(Math.round(frac * (sampleCount - 1)), 0, sampleCount - 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="telemetry-trace" data-testid="telemetry-trace">
|
|
||||||
<div className="telemetry-trace-legend">
|
|
||||||
{series.map((s) => (
|
|
||||||
<span key={s.label} className="telemetry-trace-legend-item">
|
|
||||||
<span className="telemetry-trace-swatch" style={{ background: s.color }} aria-hidden="true" />
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
ref={svgRef}
|
|
||||||
className="telemetry-trace-svg"
|
|
||||||
viewBox={`0 0 ${VIEW_WIDTH} ${totalHeight}`}
|
|
||||||
role="img"
|
|
||||||
aria-label="Telemetry trace chart"
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseLeave={() => setHoverIndex(null)}
|
|
||||||
>
|
|
||||||
{channels.map((channel, panelIdx) => {
|
|
||||||
const panelTop = panelIdx * (panelHeight + PANEL_GAP)
|
|
||||||
return (
|
|
||||||
<g key={channel} className="telemetry-trace-panel" data-channel={channel}>
|
|
||||||
<rect
|
|
||||||
className="telemetry-trace-panel-bg"
|
|
||||||
x={0}
|
|
||||||
y={panelTop}
|
|
||||||
width={VIEW_WIDTH}
|
|
||||||
height={panelHeight}
|
|
||||||
/>
|
|
||||||
<text className="telemetry-trace-panel-title" x={6} y={panelTop + 13}>
|
|
||||||
{CHANNEL_LABELS[channel]}
|
|
||||||
</text>
|
|
||||||
<text className="telemetry-trace-axis-max" x={VIEW_WIDTH - 6} y={panelTop + 13} textAnchor="end">
|
|
||||||
{channel === 'speed' ? `${Math.round(speedMax)} km/h` : '100%'}
|
|
||||||
</text>
|
|
||||||
{series.map((s) => {
|
|
||||||
const points = Array.from({ length: sampleCount }, (_, i) => {
|
|
||||||
const x = xAt(i)
|
|
||||||
const y = yAt(s.samples[i][channel], channel, panelTop)
|
|
||||||
return `${x.toFixed(2)},${y.toFixed(2)}`
|
|
||||||
}).join(' ')
|
|
||||||
return (
|
|
||||||
<polyline
|
|
||||||
key={s.label}
|
|
||||||
className="telemetry-trace-line"
|
|
||||||
data-channel={channel}
|
|
||||||
data-series={s.label}
|
|
||||||
points={points}
|
|
||||||
fill="none"
|
|
||||||
stroke={s.color}
|
|
||||||
strokeWidth={1.6}
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{hover !== null && (
|
|
||||||
<line
|
|
||||||
className="telemetry-trace-crosshair"
|
|
||||||
data-testid="telemetry-trace-crosshair"
|
|
||||||
x1={xAt(hover)}
|
|
||||||
y1={0}
|
|
||||||
x2={xAt(hover)}
|
|
||||||
y2={totalHeight}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{hover !== null && (
|
|
||||||
<div className="telemetry-trace-readout" data-testid="telemetry-trace-readout">
|
|
||||||
<span className="telemetry-trace-readout-index">Sample {hover}</span>
|
|
||||||
{series.map((s) => (
|
|
||||||
<span key={s.label} className="telemetry-trace-readout-driver">
|
|
||||||
<span className="telemetry-trace-swatch" style={{ background: s.color }} aria-hidden="true" />
|
|
||||||
<span className="telemetry-trace-readout-label">{s.label}</span>
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<span key={channel} className="telemetry-trace-readout-value">
|
|
||||||
{formatValue(channelValue(s.samples[hover], channel), channel)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
112
frontend/src/lib/delta.ts
Normal file
112
frontend/src/lib/delta.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// Cumulative lap-time delta math for driver comparison charts.
|
||||||
|
// Pure functions only — no React — so everything is unit-testable.
|
||||||
|
|
||||||
|
export interface DeltaSeries {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
/** Lap duration in seconds; null = no time (pit/out lap). */
|
||||||
|
lapTimes: (number | null)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverDeltaResult {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
/** Cumulative delta vs reference (seconds); null when that lap has no time. */
|
||||||
|
deltas: (number | null)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a delta value for axis labels and tooltips (e.g. "+2.5s", "-1.2s"). */
|
||||||
|
export function formatDeltaSeconds(delta: number): string {
|
||||||
|
const sign = delta >= 0 ? '+' : ''
|
||||||
|
return `${sign}${delta.toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCumulative(lapTimes: ReadonlyArray<number | null>): number[] {
|
||||||
|
const cumulative: number[] = []
|
||||||
|
let running = 0
|
||||||
|
for (const lap of lapTimes) {
|
||||||
|
if (lap !== null) {
|
||||||
|
running += lap
|
||||||
|
}
|
||||||
|
cumulative.push(running)
|
||||||
|
}
|
||||||
|
return cumulative
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReference(
|
||||||
|
series: ReadonlyArray<DeltaSeries>,
|
||||||
|
referenceLabel?: string,
|
||||||
|
): DeltaSeries | null {
|
||||||
|
if (series.length === 0) return null
|
||||||
|
if (referenceLabel) {
|
||||||
|
return series.find((s) => s.label === referenceLabel) ?? series[0]
|
||||||
|
}
|
||||||
|
return series[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute per-lap cumulative time delta for each non-reference driver.
|
||||||
|
* Positive = behind reference; negative = ahead.
|
||||||
|
* Null laps carry cumulative forward but emit null in deltas (skip when plotting).
|
||||||
|
*/
|
||||||
|
export function computeCumulativeDeltas(
|
||||||
|
series: ReadonlyArray<DeltaSeries>,
|
||||||
|
referenceLabel?: string,
|
||||||
|
): DriverDeltaResult[] {
|
||||||
|
const reference = resolveReference(series, referenceLabel)
|
||||||
|
if (!reference) return []
|
||||||
|
|
||||||
|
const refCumulative = buildCumulative(reference.lapTimes)
|
||||||
|
|
||||||
|
return series
|
||||||
|
.filter((s) => s.label !== reference.label)
|
||||||
|
.map((driver) => {
|
||||||
|
const driverCumulative = buildCumulative(driver.lapTimes)
|
||||||
|
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
|
||||||
|
const deltas: (number | null)[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < lapCount; i++) {
|
||||||
|
if (driver.lapTimes[i] === null) {
|
||||||
|
deltas.push(null)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const refValue = refCumulative[i] ?? refCumulative[refCumulative.length - 1] ?? 0
|
||||||
|
const driverValue =
|
||||||
|
driverCumulative[i] ?? driverCumulative[driverCumulative.length - 1] ?? 0
|
||||||
|
deltas.push(driverValue - refValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: driver.label,
|
||||||
|
color: driver.color,
|
||||||
|
deltas,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split delta samples into contiguous SVG polyline point strings (gaps at null laps). */
|
||||||
|
export function deltaPolylineSegments(
|
||||||
|
deltas: ReadonlyArray<number | null>,
|
||||||
|
toPoint: (lapIndex: number, delta: number) => string,
|
||||||
|
): string[] {
|
||||||
|
const segments: string[] = []
|
||||||
|
let current: string[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < deltas.length; i++) {
|
||||||
|
const value = deltas[i]
|
||||||
|
if (value === null) {
|
||||||
|
if (current.length > 0) {
|
||||||
|
segments.push(current.join(' '))
|
||||||
|
current = []
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current.push(toPoint(i, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
segments.push(current.join(' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
76
frontend/src/styles/delta-graph.css
Normal file
76
frontend/src/styles/delta-graph.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
.delta-graph {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-empty {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-zero-line {
|
||||||
|
stroke: var(--text-3);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 4 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-grid-line {
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-axis-label {
|
||||||
|
fill: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-driver-line {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-crosshair {
|
||||||
|
stroke: var(--text-2);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-hover-layer {
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-tooltip {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-tooltip-bg {
|
||||||
|
fill: var(--bg-elevated, var(--bg));
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-tooltip-title {
|
||||||
|
fill: var(--text-1);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-tooltip-row {
|
||||||
|
fill: var(--text-2);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
.telemetry-trace {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-empty {
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-3);
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 14px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-legend-item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-swatch {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 2px;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-panel-bg {
|
|
||||||
fill: var(--surface);
|
|
||||||
stroke: var(--border);
|
|
||||||
stroke-width: 1;
|
|
||||||
rx: 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-panel-title {
|
|
||||||
fill: var(--text-2);
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-axis-max {
|
|
||||||
fill: var(--text-3);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-crosshair {
|
|
||||||
stroke: var(--text-2);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-readout {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-2);
|
|
||||||
background: var(--surface-h);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-readout-index {
|
|
||||||
color: var(--text-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-readout-driver {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-readout-label {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.telemetry-trace-readout-value {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
121
frontend/src/test/delta-time-graph.test.tsx
Normal file
121
frontend/src/test/delta-time-graph.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { DeltaTimeGraph } from '../components/charts/DeltaTimeGraph'
|
||||||
|
import {
|
||||||
|
computeCumulativeDeltas,
|
||||||
|
deltaPolylineSegments,
|
||||||
|
formatDeltaSeconds,
|
||||||
|
type DeltaSeries,
|
||||||
|
} from '../lib/delta'
|
||||||
|
|
||||||
|
const reference: DeltaSeries = {
|
||||||
|
label: 'VER',
|
||||||
|
color: '#3671C6',
|
||||||
|
lapTimes: [90, 91, 92],
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenger: DeltaSeries = {
|
||||||
|
label: 'HAM',
|
||||||
|
color: '#E8002D',
|
||||||
|
lapTimes: [89, 92, 90],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('formatDeltaSeconds', () => {
|
||||||
|
it('formats signed deltas with one decimal and s suffix', () => {
|
||||||
|
expect(formatDeltaSeconds(2.5)).toBe('+2.5s')
|
||||||
|
expect(formatDeltaSeconds(-1.2)).toBe('-1.2s')
|
||||||
|
expect(formatDeltaSeconds(0)).toBe('+0.0s')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeCumulativeDeltas', () => {
|
||||||
|
it('computes cumulative delta vs the first series by default', () => {
|
||||||
|
const result = computeCumulativeDeltas([reference, challenger])
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].label).toBe('HAM')
|
||||||
|
// Lap 1: 89-90=-1, Lap 2: (89+92)-(90+91)=0, Lap 3: (89+92+90)-(90+91+92)=-2
|
||||||
|
expect(result[0].deltas[0]).toBeCloseTo(-1)
|
||||||
|
expect(result[0].deltas[1]).toBeCloseTo(0)
|
||||||
|
expect(result[0].deltas[2]).toBeCloseTo(-2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects an explicit reference label', () => {
|
||||||
|
const result = computeCumulativeDeltas([reference, challenger], 'HAM')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].label).toBe('VER')
|
||||||
|
expect(result[0].deltas[0]).toBeCloseTo(1)
|
||||||
|
expect(result[0].deltas[2]).toBeCloseTo(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits null for missing lap times while carrying cumulative forward', () => {
|
||||||
|
const withNull: DeltaSeries = {
|
||||||
|
label: 'NOR',
|
||||||
|
color: '#FF8000',
|
||||||
|
lapTimes: [88, null, 93],
|
||||||
|
}
|
||||||
|
const result = computeCumulativeDeltas([reference, withNull])
|
||||||
|
expect(result[0].deltas[0]).toBeCloseTo(-2)
|
||||||
|
expect(result[0].deltas[1]).toBeNull()
|
||||||
|
// After null: NOR cum=181, VER cum=273 → delta -92
|
||||||
|
expect(result[0].deltas[2]).toBeCloseTo(-92)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty array when only one series is provided', () => {
|
||||||
|
expect(computeCumulativeDeltas([reference])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deltaPolylineSegments', () => {
|
||||||
|
it('splits polylines at null laps', () => {
|
||||||
|
const segments = deltaPolylineSegments(
|
||||||
|
[1, null, 2],
|
||||||
|
(lap, delta) => `${lap},${delta}`,
|
||||||
|
)
|
||||||
|
expect(segments).toEqual(['0,1', '2,2'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DeltaTimeGraph', () => {
|
||||||
|
it('renders an empty state with no series', () => {
|
||||||
|
render(<DeltaTimeGraph series={[]} />)
|
||||||
|
expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/No lap data/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders an empty state with only the reference driver', () => {
|
||||||
|
render(<DeltaTimeGraph series={[reference]} />)
|
||||||
|
expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/at least two drivers/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a zero line and one polyline per non-reference driver', () => {
|
||||||
|
const { container } = render(<DeltaTimeGraph series={[reference, challenger]} />)
|
||||||
|
expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('[data-testid="delta-zero-line"]')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('delta-line-HAM')).toBeInTheDocument()
|
||||||
|
expect(container.querySelectorAll('.delta-graph-driver-line')).toHaveLength(1)
|
||||||
|
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a crosshair tooltip on hover', () => {
|
||||||
|
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 640,
|
||||||
|
height: 220,
|
||||||
|
right: 640,
|
||||||
|
bottom: 220,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
})
|
||||||
|
const { container } = render(<DeltaTimeGraph series={[reference, challenger]} />)
|
||||||
|
const hoverLayer = container.querySelector('.delta-graph-hover-layer')
|
||||||
|
expect(hoverLayer).toBeTruthy()
|
||||||
|
fireEvent.mouseMove(hoverLayer!, { clientX: 44, clientY: 100 })
|
||||||
|
expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('delta-tooltip')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
|
||||||
import {
|
|
||||||
TelemetryTraceChart,
|
|
||||||
type TelemetryTraceSeries,
|
|
||||||
} from '../components/charts/TelemetryTraceChart'
|
|
||||||
|
|
||||||
function makeSeries(label: string, color: string, speeds: number[]): TelemetryTraceSeries {
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
color,
|
|
||||||
samples: speeds.map((speed, i) => ({
|
|
||||||
speed,
|
|
||||||
throttle: (i * 25) % 101,
|
|
||||||
brake: i % 2 === 0 ? 100 : 0,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const VER = makeSeries('VER', '#2563eb', [280, 310, 190, 90, 240])
|
|
||||||
const LEC = makeSeries('LEC', '#dc2626', [275, 305, 200, 95, 235])
|
|
||||||
|
|
||||||
describe('TelemetryTraceChart', () => {
|
|
||||||
it('renders one polyline per series per channel', () => {
|
|
||||||
const { container } = render(<TelemetryTraceChart series={[VER, LEC]} />)
|
|
||||||
const lines = container.querySelectorAll('polyline.telemetry-trace-line')
|
|
||||||
expect(lines).toHaveLength(2 * 3)
|
|
||||||
for (const channel of ['speed', 'throttle', 'brake']) {
|
|
||||||
expect(
|
|
||||||
container.querySelectorAll(`polyline.telemetry-trace-line[data-channel="${channel}"]`),
|
|
||||||
).toHaveLength(2)
|
|
||||||
}
|
|
||||||
expect(
|
|
||||||
container.querySelectorAll('polyline.telemetry-trace-line[data-series="VER"]'),
|
|
||||||
).toHaveLength(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('respects the channels prop', () => {
|
|
||||||
const { container } = render(<TelemetryTraceChart series={[VER]} channels={['speed']} />)
|
|
||||||
expect(container.querySelectorAll('polyline.telemetry-trace-line')).toHaveLength(1)
|
|
||||||
expect(container.querySelectorAll('.telemetry-trace-panel')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders an empty state for no series', () => {
|
|
||||||
render(<TelemetryTraceChart series={[]} />)
|
|
||||||
expect(screen.getByText('No telemetry data')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders an empty state when a series has no samples', () => {
|
|
||||||
render(<TelemetryTraceChart series={[makeSeries('VER', '#2563eb', [])]} />)
|
|
||||||
expect(screen.getByText('No telemetry data')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('survives a single sample and mismatched series lengths', () => {
|
|
||||||
const short = makeSeries('VER', '#2563eb', [300])
|
|
||||||
const long = makeSeries('LEC', '#dc2626', [280, 290, 300])
|
|
||||||
const { container } = render(<TelemetryTraceChart series={[short, long]} />)
|
|
||||||
// Clamped to the shortest series: every polyline has exactly one point.
|
|
||||||
const lines = container.querySelectorAll('polyline.telemetry-trace-line')
|
|
||||||
expect(lines).toHaveLength(6)
|
|
||||||
for (const line of lines) {
|
|
||||||
expect(line.getAttribute('points')!.trim().split(' ')).toHaveLength(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clamps throttle values to 0-100 within the panel', () => {
|
|
||||||
const wild: TelemetryTraceSeries = {
|
|
||||||
label: 'VER',
|
|
||||||
color: '#2563eb',
|
|
||||||
samples: [
|
|
||||||
{ speed: 100, throttle: 150, brake: 0 },
|
|
||||||
{ speed: 100, throttle: -20, brake: 0 },
|
|
||||||
{ speed: 100, throttle: 50, brake: 0 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const { container } = render(
|
|
||||||
<TelemetryTraceChart series={[wild]} channels={['throttle']} height={110} />,
|
|
||||||
)
|
|
||||||
const line = container.querySelector('polyline.telemetry-trace-line[data-channel="throttle"]')!
|
|
||||||
const ys = line
|
|
||||||
.getAttribute('points')!
|
|
||||||
.split(' ')
|
|
||||||
.map((p) => Number(p.split(',')[1]))
|
|
||||||
// Panel occupies y 0..110 with 6px padding; clamped values pin to the edges.
|
|
||||||
expect(ys[0]).toBeCloseTo(6, 1) // 150% -> 100% -> panel top
|
|
||||||
expect(ys[1]).toBeCloseTo(104, 1) // -20% -> 0% -> panel bottom
|
|
||||||
expect(ys[2]).toBeCloseTo(55, 1) // 50% -> middle
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the speed axis max in km/h', () => {
|
|
||||||
render(<TelemetryTraceChart series={[VER]} channels={['speed']} />)
|
|
||||||
expect(screen.getByText('310 km/h')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lists every series in the legend with a color swatch', () => {
|
|
||||||
const { container } = render(<TelemetryTraceChart series={[VER, LEC]} />)
|
|
||||||
const legend = container.querySelector('.telemetry-trace-legend')!
|
|
||||||
expect(legend.textContent).toContain('VER')
|
|
||||||
expect(legend.textContent).toContain('LEC')
|
|
||||||
expect(legend.querySelectorAll('.telemetry-trace-swatch')).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a crosshair and per-driver readout on hover, hides on leave', () => {
|
|
||||||
const { container } = render(<TelemetryTraceChart series={[VER, LEC]} />)
|
|
||||||
const svg = container.querySelector('svg.telemetry-trace-svg')!
|
|
||||||
expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.mouseMove(svg, { clientX: 0, clientY: 10 })
|
|
||||||
expect(screen.getByTestId('telemetry-trace-crosshair')).toBeInTheDocument()
|
|
||||||
const readout = screen.getByTestId('telemetry-trace-readout')
|
|
||||||
expect(readout.textContent).toContain('VER')
|
|
||||||
expect(readout.textContent).toContain('LEC')
|
|
||||||
expect(readout.textContent).toContain('km/h')
|
|
||||||
|
|
||||||
fireEvent.mouseLeave(svg)
|
|
||||||
expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user