@@ -20,7 +20,12 @@ import type {
2020 GraphCompilation ,
2121} from '@opendata-ai/openchart-engine' ;
2222import { buildEdgeTooltip , compileGraph } from '@opendata-ai/openchart-engine' ;
23- import { type CameraFlightOptions , clampK , createCameraFlight } from './graph/camera' ;
23+ import {
24+ type CameraFlightOptions ,
25+ clampK ,
26+ createCameraFlight ,
27+ createCameraFollow ,
28+ } from './graph/camera' ;
2429import { GraphCanvasRenderer } from './graph/canvas-renderer' ;
2530import { ENTRANCE_STAGGER_MAX_NODES } from './graph/entrance' ;
2631import {
@@ -180,6 +185,9 @@ const CURSOR_FORCE_MAX_NODES = 2000;
180185/** Cursor pointer-feed throttle (~30Hz) so we don't post on every mousemove. */
181186const CURSOR_POINTER_THROTTLE_MS = 33 ;
182187
188+ /** Post-flight camera follow stops once the sim alpha settles below this. */
189+ const FOLLOW_SETTLE_ALPHA = 0.05 ;
190+
183191// ---------------------------------------------------------------------------
184192// Main API
185193// ---------------------------------------------------------------------------
@@ -240,6 +248,10 @@ export function createGraph(
240248 let lastPointerFeedTime = 0 ;
241249 // Camera flight state.
242250 let activeFlight : GraphAnimation | null = null ;
251+ // Post-flight follow for provider-form flights (tracks a still-settling node).
252+ let activeFollow : GraphAnimation | null = null ;
253+ // Latest simulation alpha, fed by onTick; the follow stops below the threshold.
254+ let lastAlpha = 1 ;
243255 let cameraChangePending = false ;
244256
245257 // Focus / highlight state (Phase 5). One highlight slot with two writers:
@@ -635,13 +647,18 @@ export function createGraph(
635647 cursorRepulsion : cursorForceEnabled ( ) ? compilation . interaction . cursorRepulsion : null ,
636648 } ) ;
637649
650+ // A fresh sim starts hot; don't let a settled previous sim's alpha linger
651+ // (it would end a post-flight camera follow before the first tick lands).
652+ lastAlpha = opts ?. initialAlpha ?? 1 ;
653+
638654 let initialSettleDone = false ;
639655 // Update sims keep the current camera and don't run the entrance reveal, so
640656 // pre-mark the fit as done — the first update tick just streams positions.
641657 let initialFitDone = opts ?. skipEntrance ?? false ;
642658
643- simulation . onTick ( ( positions , _alpha ) => {
659+ simulation . onTick ( ( positions , alpha ) => {
644660 if ( destroyed ) return ;
661+ lastAlpha = alpha ;
645662
646663 // Build position lookup
647664 const posMap = new Map < string , { x : number ; y : number } > ( ) ;
@@ -728,6 +745,7 @@ export function createGraph(
728745
729746 if ( suppressed || ! enter || prefersReducedMotion ( ) ) {
730747 interactionManager . setTransform ( fit ) ;
748+ cameraChangePending = true ;
731749 entranceActive = false ;
732750 entranceProgress = 1 ;
733751 return ;
@@ -737,6 +755,7 @@ export function createGraph(
737755 const { width : cw , height : ch } = getCanvasDimensions ( ) ;
738756 const pulledBack = fit . zoomAt ( fit . k * 0.92 , cw / 2 , ch / 2 ) ;
739757 interactionManager . setTransform ( pulledBack ) ;
758+ cameraChangePending = true ;
740759
741760 entranceActive = true ;
742761 entranceProgress = 0 ;
@@ -1072,11 +1091,8 @@ export function createGraph(
10721091 ) : void {
10731092 if ( destroyed || ! interactionManager ) return ;
10741093
1075- // Cancel any in-flight camera animation.
1076- if ( activeFlight ) {
1077- scheduler . remove ( activeFlight ) ;
1078- activeFlight = null ;
1079- }
1094+ // Cancel any in-flight camera animation (and a lingering post-flight follow).
1095+ cancelFlight ( ) ;
10801096
10811097 const cameraCfg = compilation . animation ?. camera ?? null ;
10821098 const resolveTarget = ( ) => ( typeof to === 'function' ? to ( ) : to ) ;
@@ -1111,6 +1127,9 @@ export function createGraph(
11111127 activeFlight = null ;
11121128 isGesturing = false ;
11131129 needsRender = true ;
1130+ // Provider-form flights converge at t=1 while the tracked node may
1131+ // still be settling — keep following it until the sim quiets down.
1132+ if ( typeof to === 'function' ) startFollow ( to ) ;
11141133 scheduleRender ( ) ;
11151134 onDone ?.( ) ;
11161135 } ,
@@ -1120,13 +1139,32 @@ export function createGraph(
11201139 scheduler . add ( flight ) ;
11211140 }
11221141
1123- /** Cancel any active camera flight (called by user-initiated pan/zoom). */
1142+ /** Snap the camera to a provider each frame until the sim settles. */
1143+ function startFollow ( target : ( ) => ZoomTransform ) : void {
1144+ const follow = createCameraFollow ( {
1145+ target,
1146+ apply : ( t ) => {
1147+ interactionManager ! . setTransform ( t ) ;
1148+ cameraChangePending = true ;
1149+ needsRender = true ;
1150+ } ,
1151+ isActive : ( ) => ! destroyed && lastAlpha >= FOLLOW_SETTLE_ALPHA ,
1152+ } ) ;
1153+ activeFollow = follow ;
1154+ scheduler . add ( follow ) ;
1155+ }
1156+
1157+ /** Cancel any active camera flight/follow (called by user-initiated pan/zoom). */
11241158 function cancelFlight ( ) : void {
11251159 if ( activeFlight ) {
11261160 scheduler . remove ( activeFlight ) ;
11271161 activeFlight = null ;
11281162 isGesturing = false ;
11291163 }
1164+ if ( activeFollow ) {
1165+ scheduler . remove ( activeFollow ) ;
1166+ activeFollow = null ;
1167+ }
11301168 }
11311169
11321170 // ---------------------------------------------------------------------------
@@ -1147,6 +1185,9 @@ export function createGraph(
11471185 // node-drag correctly doesn't reach here either.
11481186 cancelFlight ( ) ;
11491187 markGesture ( ) ;
1188+ // User pan/zoom is a camera change too — zoom UIs and camera
1189+ // persistence rely on the coalesced onCameraChange, not polling.
1190+ cameraChangePending = true ;
11501191 needsRender = true ;
11511192 scheduleRender ( ) ;
11521193 } ,
@@ -1461,6 +1502,7 @@ export function createGraph(
14611502 cancelFlight ( ) ;
14621503 entranceFitInFlight = false ;
14631504 interactionManager . setTransform ( computeInitialFit ( ) ) ;
1505+ cameraChangePending = true ;
14641506 }
14651507
14661508 needsRender = true ;
@@ -1594,10 +1636,10 @@ export function createGraph(
15941636 Math . max ( prevNodeCount , nextNodeCount ) ,
15951637 ) ;
15961638 const prevEdgeCount =
1597- compilation . edges . length - enteringEdgeCount ( diff ) + diff . exitingEdges . length ;
1639+ compilation . edges . length - diff . enteringEdgeCount + diff . exitingEdges . length ;
15981640 const nextEdgeCount = compilation . edges . length ;
15991641 const edgeRatio = ratio (
1600- enteringEdgeCount ( diff ) + diff . exitingEdges . length ,
1642+ diff . enteringEdgeCount + diff . exitingEdges . length ,
16011643 Math . max ( prevEdgeCount , nextEdgeCount ) ,
16021644 ) ;
16031645 const changeRatio = Math . max ( nodeRatio , edgeRatio ) ;
@@ -1639,17 +1681,6 @@ export function createGraph(
16391681 scheduleRender ( ) ;
16401682 }
16411683
1642- /** Count of edges in the new compilation touching an entering node. */
1643- function enteringEdgeCount ( diff : ReturnType < typeof diffGraphUpdate > ) : number {
1644- if ( diff . enteringIds . length === 0 ) return 0 ;
1645- const entering = new Set ( diff . enteringIds ) ;
1646- let count = 0 ;
1647- for ( const e of compilation . edges ) {
1648- if ( entering . has ( e . source ) || entering . has ( e . target ) ) count ++ ;
1649- }
1650- return count ;
1651- }
1652-
16531684 /** Safe ratio (0 when the denominator is 0). */
16541685 function ratio ( numerator : number , denominator : number ) : number {
16551686 return denominator > 0 ? numerator / denominator : 0 ;
@@ -1776,6 +1807,7 @@ export function createGraph(
17761807 // writes to removed state.
17771808 scheduler . cancelAll ( ) ;
17781809 activeFlight = null ;
1810+ activeFollow = null ;
17791811 // Reset entrance state so a remount (React StrictMode) starts clean.
17801812 entranceReveal = null ;
17811813 entranceActive = false ;
0 commit comments