@@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
66
77import { GRADIENT_NUDGE_EVENT } from '@/lib/nudges/registry' ;
88import { useInference } from '@/components/inference/InferenceContext' ;
9+ import { pointNearestX } from '@/components/inference/ui/line-label-anchor' ;
910import ChartLegend from '@/components/ui/chart-legend' ;
1011import { useUnofficialRun } from '@/components/unofficial-run-provider' ;
1112import { 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 ,
0 commit comments