@@ -27,7 +27,7 @@ import {
2727 createCameraFollow ,
2828} from './graph/camera' ;
2929import { GraphCanvasRenderer } from './graph/canvas-renderer' ;
30- import { ENTRANCE_STAGGER_MAX_NODES } from './graph/entrance' ;
30+ import { ENTRANCE_STAGGER_MAX_NODES , entranceOffsets , entranceOrder } from './graph/entrance' ;
3131import {
3232 composeStandingFocus ,
3333 type FocusSnapshot ,
@@ -278,6 +278,10 @@ export function createGraph(
278278 let entranceProgress = 1 ;
279279 let entranceActive = false ;
280280 let entranceStagger = false ;
281+ // Pop choreography inputs (staggered entrances only): centroid-radial stagger
282+ // rank and per-node convergence drift vectors, built once at entrance start.
283+ let entranceOrderMap : Map < string , number > | null = null ;
284+ let entranceOffsetMap : Map < string , { x : number ; y : number } > | null = null ;
281285 let entranceFitInFlight = false ;
282286 let entranceReveal : GraphAnimation | null = null ;
283287 // Mount-level opt-out (Phase 9): when set, the FIRST entrance takes the instant
@@ -489,6 +493,7 @@ export function createGraph(
489493 }
490494
491495 container . appendChild ( wrapper ) ;
496+ syncChromeInset ( ) ;
492497
493498 // Canvas uses the full container height; chrome overlays on top
494499 const canvasHeight = Math . max ( height , 200 ) ;
@@ -553,6 +558,28 @@ export function createGraph(
553558 /** Re-render the legend to reflect the current active-category state. */
554559 function syncLegendActiveState ( ) : void {
555560 if ( legendController ) legendController . update ( legendViewData ( ) ) ;
561+ syncChromeInset ( ) ;
562+ }
563+
564+ /**
565+ * Keep the chrome block out of the legend's column: the title/subtitle wrap
566+ * before they reach the legend box instead of running underneath it. No-op
567+ * when there's no legend (or it has no measurable width, e.g. in happy-dom).
568+ */
569+ function syncChromeInset ( ) : void {
570+ if ( ! chromeEl ) return ;
571+ const legendW = legendEl ?. offsetWidth ?? 0 ;
572+ chromeEl . style . right = legendW > 0 ? `${ legendW + 24 } px` : '' ;
573+ }
574+
575+ /**
576+ * Height of the chrome overlay band (title + subtitle) the camera fit should
577+ * reserve, so nodes never settle underneath the text. 0 when chrome is empty
578+ * or unmeasurable (happy-dom).
579+ */
580+ function chromeInsetTop ( ) : number {
581+ if ( ! chromeEl || chromeEl . style . display === 'none' ) return 0 ;
582+ return chromeEl . offsetHeight ;
556583 }
557584
558585 // ---------------------------------------------------------------------------
@@ -723,6 +750,7 @@ export function createGraph(
723750 const warmed = ( compilation . simulationConfig . warmupTicks ?? 0 ) > 0 ;
724751 const { transform } = ZoomTransform . fitBounds ( positionedNodes , cw , ch , undefined , {
725752 spread : ! warmed ,
753+ insetTop : chromeInsetTop ( ) ,
726754 } ) ;
727755 return transform ;
728756 }
@@ -753,7 +781,7 @@ export function createGraph(
753781
754782 // Start pulled back so the reveal has somewhere to fly in from.
755783 const { width : cw , height : ch } = getCanvasDimensions ( ) ;
756- const pulledBack = fit . zoomAt ( fit . k * 0.92 , cw / 2 , ch / 2 ) ;
784+ const pulledBack = fit . zoomAt ( fit . k * 0.85 , cw / 2 , ch / 2 ) ;
757785 interactionManager . setTransform ( pulledBack ) ;
758786 cameraChangePending = true ;
759787
@@ -762,6 +790,15 @@ export function createGraph(
762790 // Stagger only when the spec asks for it AND the graph is small enough that
763791 // per-node start times still batch. Above the cap: a single global fade.
764792 entranceStagger = enter . stagger && positionedNodes . length <= ENTRANCE_STAGGER_MAX_NODES ;
793+ // Pop choreography inputs, computed once against the warmed (near-final)
794+ // positions: centroid-radial stagger order + per-node convergence drift.
795+ if ( entranceStagger ) {
796+ entranceOrderMap = entranceOrder ( positionedNodes ) ;
797+ entranceOffsetMap = entranceOffsets ( positionedNodes ) ;
798+ } else {
799+ entranceOrderMap = null ;
800+ entranceOffsetMap = null ;
801+ }
765802
766803 // Optional camera flight from the pulled-back framing to the true fit.
767804 if ( enter . cameraFit ) {
@@ -1048,7 +1085,12 @@ export function createGraph(
10481085 dimOpacity : highlightDimOpacity ?? compilation . interaction . dimOpacity ,
10491086 entrance :
10501087 entranceActive && entranceProgress < 1
1051- ? { t : entranceProgress , stagger : entranceStagger }
1088+ ? {
1089+ t : entranceProgress ,
1090+ stagger : entranceStagger ,
1091+ order : entranceOrderMap ?? undefined ,
1092+ offsets : entranceOffsetMap ?? undefined ,
1093+ }
10521094 : undefined ,
10531095 enterAlpha : enterAlphaMap ?? undefined ,
10541096 exiting : exitingGhosts ?? undefined ,
@@ -1372,6 +1414,9 @@ export function createGraph(
13721414 cw ,
13731415 ch ,
13741416 opts ?. padding ,
1417+ {
1418+ insetTop : chromeInsetTop ( ) ,
1419+ } ,
13751420 ) ;
13761421 flyCamera ( fitTransform , opts ) ;
13771422 }
@@ -1494,6 +1539,9 @@ export function createGraph(
14941539 const { width, height } = getContainerDimensions ( ) ;
14951540 const canvasHeight = Math . max ( height , 200 ) ;
14961541 renderer . resize ( width , canvasHeight ) ;
1542+ // A width change can rewrap the title or move the legend; re-derive the
1543+ // chrome/legend separation before any fit below measures the chrome band.
1544+ syncChromeInset ( ) ;
14971545
14981546 // Mid-entrance: the in-flight camera fit targets the OLD viewport, so cancel
14991547 // just that flight and snap to the new-viewport fit. The reveal tween (node
0 commit comments