Skip to content

Commit ed3e7a3

Browse files
cquil11claude
andcommitted
feat(replay): pin line labels to a stable anchor during replay
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) <noreply@anthropic.com>
1 parent 44ffe6e commit ed3e7a3

5 files changed

Lines changed: 168 additions & 79 deletions

File tree

packages/app/src/components/inference/replay/ReplayPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,7 @@ export default function ReplayPanel({
496496
chartDefinition={chartDefinition}
497497
transitionDuration={0}
498498
niceAxes={false}
499+
pinLineLabels
499500
/>
500501
<div
501502
className="absolute -translate-y-full pointer-events-none text-2xl font-bold tabular-nums opacity-85 leading-none pb-1"

packages/app/src/components/inference/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,15 @@ export interface ScatterGraphProps {
481481
* playback).
482482
*/
483483
niceAxes?: boolean;
484+
/**
485+
* Pin each line label to a stable anchor along its roofline so it tracks the
486+
* line smoothly instead of re-running the per-frame greedy placement (which
487+
* makes labels teleport between candidate positions as the lines animate).
488+
* Defaults to false. The replay panel passes true so labels keep a positional
489+
* "affinity" across frames. Trades the static chart's per-frame de-overlap for
490+
* positional stability — appropriate while the chart is animating.
491+
*/
492+
pinLineLabels?: boolean;
484493
}
485494
/**
486495
* @file types.ts

packages/app/src/components/inference/ui/ScatterGraph.tsx

Lines changed: 95 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
66

77
import { GRADIENT_NUDGE_EVENT } from '@/lib/nudges/registry';
88
import { useInference } from '@/components/inference/InferenceContext';
9+
import { pointNearestX } from '@/components/inference/ui/line-label-anchor';
910
import ChartLegend from '@/components/ui/chart-legend';
1011
import { useUnofficialRun } from '@/components/unofficial-run-provider';
1112
import { computeToggle } from '@/hooks/useTogglableSet';
@@ -122,6 +123,7 @@ const ScatterGraph = React.memo(
122123
overlayData,
123124
transitionDuration = 750,
124125
niceAxes = true,
126+
pinLineLabels = false,
125127
}: ScatterGraphProps) => {
126128
const {
127129
activeHwTypes,
@@ -174,6 +176,11 @@ const ScatterGraph = React.memo(
174176
} = useUnofficialRun();
175177
const chartRef = useRef<D3ChartHandle>(null);
176178

179+
// Pinned line-label anchors (data-space x) keyed by line-label key. Persists
180+
// across renders so each label keeps a stable spot along its line during
181+
// replay animation. Only read/written when `pinLineLabels` is true.
182+
const lineLabelAnchorRef = useRef<Map<string, number>>(new Map());
183+
177184
// Effective active hw types for rendering — shared override when present, else global
178185
const effectiveOfficialHwTypes = localOfficialOverride ?? activeHwTypes;
179186

@@ -946,55 +953,86 @@ const ScatterGraph = React.memo(
946953
if (!prev || e.points.length > prev.points.length) bestByGroup.set(groupKey, e);
947954
}
948955

949-
// Sort entries by highest y-value first (top of chart) for priority
950-
const sorted = [...bestByGroup.values()].toSorted((a, b) => {
951-
const ay = yScale(a.points[0].y);
952-
const by = yScale(b.points[0].y);
953-
return ay - by; // smaller pixel y = higher on chart
954-
});
955-
956-
for (const entry of sorted) {
957-
const pts = entry.points;
956+
// Place one label per series. When pinned (replay), reuse a stored
957+
// data-space anchor so the label tracks the same spot along its line
958+
// as it animates; otherwise re-run greedy placement each render and
959+
// hide on collision (the static chart's de-overlap behavior).
960+
const anchors = lineLabelAnchorRef.current;
961+
const placeLabel = (
962+
key: string,
963+
hw: string,
964+
label: string,
965+
color: string,
966+
pts: InferenceData[],
967+
) => {
958968
const candidates = [
959-
pts[Math.min(1, pts.length - 1)], // top-left (near start)
969+
pts[Math.min(1, pts.length - 1)], // near start
960970
pts[Math.floor(pts.length / 2)], // midpoint
961971
pts[Math.max(0, Math.floor((pts.length * 2) / 3))], // right-third
962972
pts.at(-1)!, // endpoint
963973
];
964-
965-
const label = lineLabelText(entry.hw, entry.precision, multiPrecision);
966-
let foundPlacement = false;
974+
if (pinLineLabels) {
975+
let anchorX = anchors.get(key);
976+
if (anchorX === undefined) {
977+
// First sighting: pick the first non-colliding candidate
978+
// (endpoint as fallback) and remember its data-x for later
979+
// frames so the label no longer hops between candidates.
980+
let chosen = candidates.at(-1)!;
981+
for (const pt of candidates) {
982+
if (!collides(xScale(pt.x), yScale(pt.y))) {
983+
chosen = pt;
984+
break;
985+
}
986+
}
987+
anchorX = chosen.x;
988+
anchors.set(key, anchorX);
989+
}
990+
const pt = pointNearestX(pts, anchorX);
991+
const px = xScale(pt.x);
992+
const py = yScale(pt.y);
993+
placed.push({ x: px, y: py });
994+
// Stay visible across frames — positional stability is the goal
995+
// during animation, so we don't hide on transient collisions.
996+
lineLabels.push({ key, hw, label, color, x: px, y: py, visible: true });
997+
return;
998+
}
967999
for (const pt of candidates) {
9681000
const px = xScale(pt.x);
9691001
const py = yScale(pt.y);
9701002
if (!collides(px, py)) {
971-
lineLabels.push({
972-
key: entry.key,
973-
hw: entry.hw,
974-
label,
975-
color: getCssColor(resolveColor(entry.hw)),
976-
x: px,
977-
y: py,
978-
visible: true,
979-
});
1003+
lineLabels.push({ key, hw, label, color, x: px, y: py, visible: true });
9801004
placed.push({ x: px, y: py });
981-
foundPlacement = true;
982-
break;
1005+
return;
9831006
}
9841007
}
985-
// If all candidates collide, hide this label
986-
if (!foundPlacement) {
987-
const pt = pts[0];
988-
lineLabels.push({
989-
key: entry.key,
990-
hw: entry.hw,
991-
label,
992-
color: getCssColor(resolveColor(entry.hw)),
993-
x: xScale(pt.x),
994-
y: yScale(pt.y),
995-
visible: false,
996-
});
997-
}
1008+
// All candidates collide — hide this label.
1009+
const pt = pts[0];
1010+
lineLabels.push({
1011+
key,
1012+
hw,
1013+
label,
1014+
color,
1015+
x: xScale(pt.x),
1016+
y: yScale(pt.y),
1017+
visible: false,
1018+
});
1019+
};
1020+
1021+
// Sort entries by highest y-value first (top of chart) for priority
1022+
const sorted = [...bestByGroup.values()].toSorted((a, b) => {
1023+
const ay = yScale(a.points[0].y);
1024+
const by = yScale(b.points[0].y);
1025+
return ay - by; // smaller pixel y = higher on chart
1026+
});
1027+
1028+
for (const entry of sorted) {
1029+
placeLabel(
1030+
entry.key,
1031+
entry.hw,
1032+
lineLabelText(entry.hw, entry.precision, multiPrecision),
1033+
getCssColor(resolveColor(entry.hw)),
1034+
entry.points,
1035+
);
9981036
}
9991037

10001038
// Also add hidden entries for any curve that wasn't placed (so the
@@ -1039,49 +1077,22 @@ const ScatterGraph = React.memo(
10391077
.toSorted(([, a], [, b]) => yScale(a.points[0].y) - yScale(b.points[0].y));
10401078

10411079
for (const [ovKey, group] of sortedOverlay) {
1042-
const labelKey = `overlay-${ovKey}`;
1043-
const pts = group.points;
1044-
const candidates = [
1045-
pts[Math.min(1, pts.length - 1)],
1046-
pts[Math.floor(pts.length / 2)],
1047-
pts[Math.max(0, Math.floor((pts.length * 2) / 3))],
1048-
pts.at(-1)!,
1049-
];
1050-
const label = overlayLabelText(
1051-
group.runIndex,
1080+
placeLabel(
1081+
`overlay-${ovKey}`,
10521082
group.hwKey,
1053-
group.points[0]?.precision ?? '',
1083+
overlayLabelText(group.runIndex, group.hwKey, group.points[0]?.precision ?? ''),
1084+
overlayRunColor(group.runIndex),
1085+
group.points,
10541086
);
1055-
let placedOverlay = false;
1056-
for (const pt of candidates) {
1057-
const px = xScale(pt.x);
1058-
const py = yScale(pt.y);
1059-
if (!collides(px, py)) {
1060-
lineLabels.push({
1061-
key: labelKey,
1062-
hw: group.hwKey,
1063-
label,
1064-
color: overlayRunColor(group.runIndex),
1065-
x: px,
1066-
y: py,
1067-
visible: true,
1068-
});
1069-
placed.push({ x: px, y: py });
1070-
placedOverlay = true;
1071-
break;
1072-
}
1073-
}
1074-
if (!placedOverlay) {
1075-
const pt = pts[0];
1076-
lineLabels.push({
1077-
key: labelKey,
1078-
hw: group.hwKey,
1079-
label,
1080-
color: overlayRunColor(group.runIndex),
1081-
x: xScale(pt.x),
1082-
y: yScale(pt.y),
1083-
visible: false,
1084-
});
1087+
}
1088+
1089+
// Drop anchors for series no longer present so the map stays bounded
1090+
// and a re-appearing series gets a fresh, in-range anchor.
1091+
if (pinLineLabels) {
1092+
const live = new Set(lineLabels.map((l) => l.key));
1093+
// Deleting the current key during Map iteration is well-defined.
1094+
for (const k of anchors.keys()) {
1095+
if (!live.has(k)) anchors.delete(k);
10851096
}
10861097
}
10871098
} else {
@@ -1127,8 +1138,12 @@ const ScatterGraph = React.memo(
11271138
visible: true,
11281139
});
11291140
}
1141+
// Pinned (replay): keep labels exactly at their endpoints, which
1142+
// already move smoothly with the line. The vertical de-overlap
1143+
// nudge below reshuffles positions as endpoints shift frame-to-
1144+
// frame, so skip it to preserve positional affinity.
11301145
const visible = lineLabels.filter((l) => l.visible);
1131-
if (visible.length > 1) {
1146+
if (visible.length > 1 && !pinLineLabels) {
11321147
const yRange = yScale.range();
11331148
const top = Math.min(yRange[0], yRange[1]) + LABEL_H;
11341149
const bottom = Math.max(yRange[0], yRange[1]) - LABEL_H;
@@ -1793,6 +1808,7 @@ const ScatterGraph = React.memo(
17931808
allPointLabelsByKey,
17941809
showGradientLabels,
17951810
showLineLabels,
1811+
pinLineLabels,
17961812
showSpeedOverlay,
17971813
showMinecraftOverlay,
17981814
gradientColorByPoint,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { InferenceData } from '@/components/inference/types';
4+
5+
import { pointNearestX } from './line-label-anchor';
6+
7+
// Minimal InferenceData stand-ins — pointNearestX only reads `x`.
8+
const pt = (x: number, y: number): InferenceData => ({ x, y }) as InferenceData;
9+
10+
describe('pointNearestX', () => {
11+
it('returns the point whose x is closest to the target', () => {
12+
const pts = [pt(0, 10), pt(5, 20), pt(10, 30)];
13+
expect(pointNearestX(pts, 4).x).toBe(5);
14+
expect(pointNearestX(pts, 1).x).toBe(0);
15+
expect(pointNearestX(pts, 9).x).toBe(10);
16+
});
17+
18+
it('keeps a stable anchor as the line shifts between frames', () => {
19+
// An anchor stored at x=5 should resolve to whichever current point sits
20+
// nearest x=5 — this is what keeps a replay label glued to its line.
21+
const anchorX = 5;
22+
const frameA = [pt(0, 10), pt(5, 22), pt(10, 30)];
23+
const frameB = [pt(0, 12), pt(5, 18), pt(10, 26)];
24+
expect(pointNearestX(frameA, anchorX)).toMatchObject({ x: 5, y: 22 });
25+
expect(pointNearestX(frameB, anchorX)).toMatchObject({ x: 5, y: 18 });
26+
});
27+
28+
it('clamps to the nearest endpoint when the anchor is out of range', () => {
29+
const pts = [pt(2, 10), pt(4, 20), pt(6, 30)];
30+
expect(pointNearestX(pts, -100).x).toBe(2);
31+
expect(pointNearestX(pts, 100).x).toBe(6);
32+
});
33+
34+
it('handles a single-point line', () => {
35+
const pts = [pt(3, 7)];
36+
expect(pointNearestX(pts, 999)).toMatchObject({ x: 3, y: 7 });
37+
});
38+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { InferenceData } from '@/components/inference/types';
2+
3+
/**
4+
* Find the point on a polyline whose x is closest to a target data-space x.
5+
*
6+
* Used by the pinned (replay) line-label path: an anchor is stored in data
7+
* space so a label tracks the same spot along its line as the line animates,
8+
* instead of jumping between discrete candidate positions on every frame. As
9+
* the polyline's points shift between frames, resolving the anchor to the
10+
* nearest current point keeps the label glued to its line.
11+
*
12+
* `pts` is assumed non-empty (callers guard with `pts.length >= 2`).
13+
*/
14+
export const pointNearestX = (pts: InferenceData[], targetX: number): InferenceData => {
15+
let best = pts[0];
16+
let bestDist = Math.abs(pts[0].x - targetX);
17+
for (let i = 1; i < pts.length; i++) {
18+
const dist = Math.abs(pts[i].x - targetX);
19+
if (dist < bestDist) {
20+
bestDist = dist;
21+
best = pts[i];
22+
}
23+
}
24+
return best;
25+
};

0 commit comments

Comments
 (0)