From 44ffe6e23e5af632f4034d95f876439925338d2b Mon Sep 17 00:00:00 2001 From: Cam Quilici Date: Mon, 8 Jun 2026 12:56:14 -0500 Subject: [PATCH 1/3] fix(scatter): keep line labels in the foreground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inference performance scatter charts create line labels inside the rooflines layer, which renders before the dot groups so roofline paths stay behind the points. That left the labels painted under the scatter points and overlay marks. Add a trailing custom layer that re-raises every `.line-label` to the end of the zoomGroup after all layers render (and on zoom), so they always read as foreground — mirroring GPUGraph, whose line-label layer already renders last. `.raise()` only changes z-order; label placement and the existing de-overlap (hide-on-collision for interactivity, vertical nudge for TTFT/E2EL) are untouched, so labels still never overlap one another. Selects overlay line labels (`overlay-*`) too, so unofficial-run overlays get the same treatment. Adds an E2E assertion that visible labels follow the dot groups in DOM order. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/app/cypress/e2e/line-labels.cy.ts | 24 +++++++++++++++++++ .../components/inference/ui/ScatterGraph.tsx | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/app/cypress/e2e/line-labels.cy.ts b/packages/app/cypress/e2e/line-labels.cy.ts index a3fa4b68..822cad2d 100644 --- a/packages/app/cypress/e2e/line-labels.cy.ts +++ b/packages/app/cypress/e2e/line-labels.cy.ts @@ -39,6 +39,30 @@ describe('Line Labels Toggle', () => { ); }); + it('line labels render in the foreground, after the scatter points', () => { + // Labels were toggled on in the test above and remain on here. + cy.get('[data-testid="scatter-graph"] svg g.line-label').should('have.length.greaterThan', 0); + + cy.get('[data-testid="scatter-graph"] svg').then(($svg) => { + const svg = $svg[0]; + const dots = svg.querySelectorAll('.dot-group'); + const labels = svg.querySelectorAll('g.line-label'); + expect(dots.length, 'scatter dot groups exist').to.be.greaterThan(0); + expect(labels.length, 'line labels exist').to.be.greaterThan(0); + + // Every label must paint after every dot group. Comparing the *last* dot + // group against the *first* label is sufficient: if the earliest label + // follows the latest dot in document order, all labels are in front. + const lastDot = dots.item(dots.length - 1)!; + const firstLabel = labels.item(0)!; + const relation = lastDot.compareDocumentPosition(firstLabel); + expect( + relation & Node.DOCUMENT_POSITION_FOLLOWING, + 'line label follows the scatter points in DOM order (foreground)', + ).to.be.greaterThan(0); + }); + }); + it('toggling Line Labels off removes label elements', () => { cy.get('#scatter-line-labels').click(); cy.get('#scatter-line-labels').should('have.attr', 'data-state', 'unchecked'); diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index 56e0088e..4037ad73 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -1760,9 +1760,33 @@ const ScatterGraph = React.memo( }, }; + // ── Last layer: keep line labels in the foreground ── + // Line labels are created inside the rooflines layer, which must render + // *before* the dot groups so the roofline paths sit behind the points. + // That ordering would otherwise leave the labels painted under the scatter + // points and the overlay marks. Re-raise every `.line-label` to the end of + // the zoomGroup after all other layers have rendered (and on each zoom) so + // they always read as foreground. `.raise()` only reorders z-index — label + // x/y placement (and the hide-on-collision / vertical-nudge de-overlap in + // the rooflines layer) is untouched, so labels still never overlap one + // another. Mirrors GPUGraph, whose line-label layer is already rendered + // last. Selects overlay line labels (key `overlay-*`) too, so unofficial + // run overlays get the same foreground treatment. + const lineLabelForegroundLayer: CustomLayerConfig = { + type: 'custom', + key: 'line-label-foreground', + render: (zoomGroup) => { + zoomGroup.selectAll('.line-label').raise(); + }, + onZoom: (zoomGroup) => { + zoomGroup.selectAll('.line-label').raise(); + }, + }; + const result: LayerConfig[] = [rooflineLayer, scatterLayer]; if (overlayLayer) result.push(overlayLayer); result.push(speedOverlayLayer); + result.push(lineLabelForegroundLayer); return result; }, [ rooflines, From ed3e7a3037edb18b1fefa69b99a577c428b62059 Mon Sep 17 00:00:00 2001 From: Cam Quilici Date: Mon, 8 Jun 2026 13:24:39 -0500 Subject: [PATCH 2/3] feat(replay): pin line labels to a stable anchor during replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In "Replay over time", line labels were re-placed every frame by the greedy placement algorithm, so they teleported between candidate positions (and the TTFT/E2EL vertical nudge reshuffled them) as the rooflines animated — visually noisy. Give each label a positional "affinity": add an opt-in `pinLineLabels` prop (set by ReplayPanel, like `transitionDuration`/`niceAxes`). When pinned, each label remembers a data-space x anchor the first time its series is seen and, on every later frame, resolves that anchor to the nearest current point on the line — so the label tracks the same spot as the line moves instead of hopping. The TTFT/E2EL de-overlap nudge is skipped while pinned so endpoint labels stay glued to their (smoothly moving) endpoints. Anchors are pruned when a series disappears. The static (non-pinned) chart is unchanged: the default branch of the shared placeLabel helper keeps the exact greedy-place + hide-on-collision behavior, preserving the no-overlap guarantee. Works for both official and overlay (`?unofficialrun=`) line labels. Extracts pointNearestX into its own module with unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../inference/replay/ReplayPanel.tsx | 1 + .../app/src/components/inference/types.ts | 9 + .../components/inference/ui/ScatterGraph.tsx | 174 ++++++++++-------- .../inference/ui/line-label-anchor.test.ts | 38 ++++ .../inference/ui/line-label-anchor.ts | 25 +++ 5 files changed, 168 insertions(+), 79 deletions(-) create mode 100644 packages/app/src/components/inference/ui/line-label-anchor.test.ts create mode 100644 packages/app/src/components/inference/ui/line-label-anchor.ts diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 4ff3a499..3662aa02 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -496,6 +496,7 @@ export default function ReplayPanel({ chartDefinition={chartDefinition} transitionDuration={0} niceAxes={false} + pinLineLabels />
{ const { activeHwTypes, @@ -174,6 +176,11 @@ const ScatterGraph = React.memo( } = useUnofficialRun(); const chartRef = useRef(null); + // Pinned line-label anchors (data-space x) keyed by line-label key. Persists + // across renders so each label keeps a stable spot along its line during + // replay animation. Only read/written when `pinLineLabels` is true. + const lineLabelAnchorRef = useRef>(new Map()); + // Effective active hw types for rendering — shared override when present, else global const effectiveOfficialHwTypes = localOfficialOverride ?? activeHwTypes; @@ -946,55 +953,86 @@ const ScatterGraph = React.memo( if (!prev || e.points.length > prev.points.length) bestByGroup.set(groupKey, e); } - // Sort entries by highest y-value first (top of chart) for priority - const sorted = [...bestByGroup.values()].toSorted((a, b) => { - const ay = yScale(a.points[0].y); - const by = yScale(b.points[0].y); - return ay - by; // smaller pixel y = higher on chart - }); - - for (const entry of sorted) { - const pts = entry.points; + // Place one label per series. When pinned (replay), reuse a stored + // data-space anchor so the label tracks the same spot along its line + // as it animates; otherwise re-run greedy placement each render and + // hide on collision (the static chart's de-overlap behavior). + const anchors = lineLabelAnchorRef.current; + const placeLabel = ( + key: string, + hw: string, + label: string, + color: string, + pts: InferenceData[], + ) => { const candidates = [ - pts[Math.min(1, pts.length - 1)], // top-left (near start) + pts[Math.min(1, pts.length - 1)], // near start pts[Math.floor(pts.length / 2)], // midpoint pts[Math.max(0, Math.floor((pts.length * 2) / 3))], // right-third pts.at(-1)!, // endpoint ]; - - const label = lineLabelText(entry.hw, entry.precision, multiPrecision); - let foundPlacement = false; + if (pinLineLabels) { + let anchorX = anchors.get(key); + if (anchorX === undefined) { + // First sighting: pick the first non-colliding candidate + // (endpoint as fallback) and remember its data-x for later + // frames so the label no longer hops between candidates. + let chosen = candidates.at(-1)!; + for (const pt of candidates) { + if (!collides(xScale(pt.x), yScale(pt.y))) { + chosen = pt; + break; + } + } + anchorX = chosen.x; + anchors.set(key, anchorX); + } + const pt = pointNearestX(pts, anchorX); + const px = xScale(pt.x); + const py = yScale(pt.y); + placed.push({ x: px, y: py }); + // Stay visible across frames — positional stability is the goal + // during animation, so we don't hide on transient collisions. + lineLabels.push({ key, hw, label, color, x: px, y: py, visible: true }); + return; + } for (const pt of candidates) { const px = xScale(pt.x); const py = yScale(pt.y); if (!collides(px, py)) { - lineLabels.push({ - key: entry.key, - hw: entry.hw, - label, - color: getCssColor(resolveColor(entry.hw)), - x: px, - y: py, - visible: true, - }); + lineLabels.push({ key, hw, label, color, x: px, y: py, visible: true }); placed.push({ x: px, y: py }); - foundPlacement = true; - break; + return; } } - // If all candidates collide, hide this label - if (!foundPlacement) { - const pt = pts[0]; - lineLabels.push({ - key: entry.key, - hw: entry.hw, - label, - color: getCssColor(resolveColor(entry.hw)), - x: xScale(pt.x), - y: yScale(pt.y), - visible: false, - }); - } + // All candidates collide — hide this label. + const pt = pts[0]; + lineLabels.push({ + key, + hw, + label, + color, + x: xScale(pt.x), + y: yScale(pt.y), + visible: false, + }); + }; + + // Sort entries by highest y-value first (top of chart) for priority + const sorted = [...bestByGroup.values()].toSorted((a, b) => { + const ay = yScale(a.points[0].y); + const by = yScale(b.points[0].y); + return ay - by; // smaller pixel y = higher on chart + }); + + for (const entry of sorted) { + placeLabel( + entry.key, + entry.hw, + lineLabelText(entry.hw, entry.precision, multiPrecision), + getCssColor(resolveColor(entry.hw)), + entry.points, + ); } // Also add hidden entries for any curve that wasn't placed (so the @@ -1039,49 +1077,22 @@ const ScatterGraph = React.memo( .toSorted(([, a], [, b]) => yScale(a.points[0].y) - yScale(b.points[0].y)); for (const [ovKey, group] of sortedOverlay) { - const labelKey = `overlay-${ovKey}`; - const pts = group.points; - const candidates = [ - pts[Math.min(1, pts.length - 1)], - pts[Math.floor(pts.length / 2)], - pts[Math.max(0, Math.floor((pts.length * 2) / 3))], - pts.at(-1)!, - ]; - const label = overlayLabelText( - group.runIndex, + placeLabel( + `overlay-${ovKey}`, group.hwKey, - group.points[0]?.precision ?? '', + overlayLabelText(group.runIndex, group.hwKey, group.points[0]?.precision ?? ''), + overlayRunColor(group.runIndex), + group.points, ); - let placedOverlay = false; - for (const pt of candidates) { - const px = xScale(pt.x); - const py = yScale(pt.y); - if (!collides(px, py)) { - lineLabels.push({ - key: labelKey, - hw: group.hwKey, - label, - color: overlayRunColor(group.runIndex), - x: px, - y: py, - visible: true, - }); - placed.push({ x: px, y: py }); - placedOverlay = true; - break; - } - } - if (!placedOverlay) { - const pt = pts[0]; - lineLabels.push({ - key: labelKey, - hw: group.hwKey, - label, - color: overlayRunColor(group.runIndex), - x: xScale(pt.x), - y: yScale(pt.y), - visible: false, - }); + } + + // Drop anchors for series no longer present so the map stays bounded + // and a re-appearing series gets a fresh, in-range anchor. + if (pinLineLabels) { + const live = new Set(lineLabels.map((l) => l.key)); + // Deleting the current key during Map iteration is well-defined. + for (const k of anchors.keys()) { + if (!live.has(k)) anchors.delete(k); } } } else { @@ -1127,8 +1138,12 @@ const ScatterGraph = React.memo( visible: true, }); } + // Pinned (replay): keep labels exactly at their endpoints, which + // already move smoothly with the line. The vertical de-overlap + // nudge below reshuffles positions as endpoints shift frame-to- + // frame, so skip it to preserve positional affinity. const visible = lineLabels.filter((l) => l.visible); - if (visible.length > 1) { + if (visible.length > 1 && !pinLineLabels) { const yRange = yScale.range(); const top = Math.min(yRange[0], yRange[1]) + LABEL_H; const bottom = Math.max(yRange[0], yRange[1]) - LABEL_H; @@ -1793,6 +1808,7 @@ const ScatterGraph = React.memo( allPointLabelsByKey, showGradientLabels, showLineLabels, + pinLineLabels, showSpeedOverlay, showMinecraftOverlay, gradientColorByPoint, diff --git a/packages/app/src/components/inference/ui/line-label-anchor.test.ts b/packages/app/src/components/inference/ui/line-label-anchor.test.ts new file mode 100644 index 00000000..31686486 --- /dev/null +++ b/packages/app/src/components/inference/ui/line-label-anchor.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import type { InferenceData } from '@/components/inference/types'; + +import { pointNearestX } from './line-label-anchor'; + +// Minimal InferenceData stand-ins — pointNearestX only reads `x`. +const pt = (x: number, y: number): InferenceData => ({ x, y }) as InferenceData; + +describe('pointNearestX', () => { + it('returns the point whose x is closest to the target', () => { + const pts = [pt(0, 10), pt(5, 20), pt(10, 30)]; + expect(pointNearestX(pts, 4).x).toBe(5); + expect(pointNearestX(pts, 1).x).toBe(0); + expect(pointNearestX(pts, 9).x).toBe(10); + }); + + it('keeps a stable anchor as the line shifts between frames', () => { + // An anchor stored at x=5 should resolve to whichever current point sits + // nearest x=5 — this is what keeps a replay label glued to its line. + const anchorX = 5; + const frameA = [pt(0, 10), pt(5, 22), pt(10, 30)]; + const frameB = [pt(0, 12), pt(5, 18), pt(10, 26)]; + expect(pointNearestX(frameA, anchorX)).toMatchObject({ x: 5, y: 22 }); + expect(pointNearestX(frameB, anchorX)).toMatchObject({ x: 5, y: 18 }); + }); + + it('clamps to the nearest endpoint when the anchor is out of range', () => { + const pts = [pt(2, 10), pt(4, 20), pt(6, 30)]; + expect(pointNearestX(pts, -100).x).toBe(2); + expect(pointNearestX(pts, 100).x).toBe(6); + }); + + it('handles a single-point line', () => { + const pts = [pt(3, 7)]; + expect(pointNearestX(pts, 999)).toMatchObject({ x: 3, y: 7 }); + }); +}); diff --git a/packages/app/src/components/inference/ui/line-label-anchor.ts b/packages/app/src/components/inference/ui/line-label-anchor.ts new file mode 100644 index 00000000..89a7cfcb --- /dev/null +++ b/packages/app/src/components/inference/ui/line-label-anchor.ts @@ -0,0 +1,25 @@ +import type { InferenceData } from '@/components/inference/types'; + +/** + * Find the point on a polyline whose x is closest to a target data-space x. + * + * Used by the pinned (replay) line-label path: an anchor is stored in data + * space so a label tracks the same spot along its line as the line animates, + * instead of jumping between discrete candidate positions on every frame. As + * the polyline's points shift between frames, resolving the anchor to the + * nearest current point keeps the label glued to its line. + * + * `pts` is assumed non-empty (callers guard with `pts.length >= 2`). + */ +export const pointNearestX = (pts: InferenceData[], targetX: number): InferenceData => { + let best = pts[0]; + let bestDist = Math.abs(pts[0].x - targetX); + for (let i = 1; i < pts.length; i++) { + const dist = Math.abs(pts[i].x - targetX); + if (dist < bestDist) { + bestDist = dist; + best = pts[i]; + } + } + return best; +}; From ac07c813dc5030e22dc9aefc4dc5222e7bb4cda9 Mon Sep 17 00:00:00 2001 From: Cam Quilici Date: Mon, 8 Jun 2026 13:51:03 -0500 Subject: [PATCH 3/3] feat(replay): fixed axes across the run, with a toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default the replay axes now stay fixed to the whole run's extent (for the active hardware) instead of refitting to each frame, so you can watch the frontier expand toward a constant coordinate space over time. A "Fixed axes" switch in the replay controls flips back to per-frame (dynamic) axes that hug the current frontier. - buildReplayTimeline: add computeFullRunDomain(timeline, hwFilter) — the bounding box across every step for the filtered configs. - ScatterGraph: add optional xExtentOverride/yExtentOverride; when set, the axis domain is based on them (normal padding + log/zero-baseline handling still applied) instead of the current points. Undefined for every non-replay caller, so the static chart is unchanged. - ReplayPanel: compute the full-run extent for the active hardware and pass it when "Fixed axes" is on (default); pass undefined when off. Unit tests cover computeFullRunDomain (spans all steps, respects the hw filter). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../inference/replay/ReplayPanel.tsx | 39 +++++++++++++- .../__tests__/buildReplayTimeline.test.ts | 51 ++++++++++++++++++- .../inference/replay/buildReplayTimeline.ts | 25 +++++++++ .../app/src/components/inference/types.ts | 9 ++++ .../components/inference/ui/ScatterGraph.tsx | 16 +++--- 5 files changed, 131 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 3662aa02..7273ed24 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -10,6 +10,8 @@ import { useInference } from '@/components/inference/InferenceContext'; import ScatterGraph from '@/components/inference/ui/ScatterGraph'; import type { ChartDefinition } from '@/components/inference/types'; import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, @@ -21,7 +23,7 @@ import { useBenchmarkHistory } from '@/hooks/api/use-benchmark-history'; import { track } from '@/lib/analytics'; import { cn } from '@/lib/utils'; -import { buildReplayTimeline } from './buildReplayTimeline'; +import { buildReplayTimeline, computeFullRunDomain } from './buildReplayTimeline'; import type { Mp4ExportError, Mp4ExportStage } from './exportMp4'; import { buildFrameData, dateAtFraction, shouldCommitFraction, spanMs } from './replayFrameData'; import { useReducedMotion } from './useReducedMotion'; @@ -63,7 +65,7 @@ export default function ReplayPanel({ xLabel, }: ReplayPanelProps) { const inference = useInference(); - const { selectedModel, selectedSequence } = inference; + const { selectedModel, selectedSequence, activeHwTypes } = inference; const { isl = 0, osl = 0 } = sequenceToIslOsl(selectedSequence) ?? {}; const history = useBenchmarkHistory(selectedModel, isl, osl); @@ -90,6 +92,15 @@ export default function ReplayPanel({ inference.selectedPrecisions, ]); + // Fixed axes for the whole run: take the extent across every step (not just + // the current frame) for the active hardware, so the axes stay put and the + // frontier visibly expands toward them over time instead of the chart + // refitting each frame. Recomputed when the legend's hw filter changes. + const fixedExtent = useMemo( + () => (timeline ? computeFullRunDomain(timeline, (hw) => activeHwTypes.has(hw)) : null), + [timeline, activeHwTypes], + ); + // Track the SVG's position inside our relative wrapper so the date overlay // can anchor its bottom-right to the chart plot's top-right (the wrapper // also contains the legend, so we can't anchor to the wrapper edge). @@ -161,6 +172,10 @@ export default function ReplayPanel({ const [fraction, setFraction] = useState(0); const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState(1); + // Fixed axes (default) freeze the coordinate space to the whole run so the + // frontier visibly expands over time; turning this off lets the axes refit to + // each frame's points. + const [fixedAxes, setFixedAxes] = useState(true); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); @@ -497,6 +512,8 @@ export default function ReplayPanel({ transitionDuration={0} niceAxes={false} pinLineLabels + xExtentOverride={fixedAxes ? fixedExtent?.x : undefined} + yExtentOverride={fixedAxes ? fixedExtent?.y : undefined} />
+
+ { + setFixedAxes(checked); + track('inference_replay_fixed_axes_toggled', { enabled: checked }); + }} + /> + +