55
66const ACTIVE_CLASS = "story-step--active" ;
77const STAGE_ACTIVE_CLASS = "evidence-step--active" ;
8+ const STORY_TRIGGER_RATIO = 0.7 ;
89
910/**
1011 * Connects story steps and preset buttons to a chart preset callback.
@@ -18,6 +19,8 @@ export function createStoryController({ applyPreset }) {
1819 const stage = document . querySelector ( ".story-stage" ) ;
1920 const stageSteps = [ ...document . querySelectorAll ( "[data-stage-step]" ) ] ;
2021 const stageJumpButtons = [ ...document . querySelectorAll ( "[data-stage-jump]" ) ] ;
22+ let activeScrollPresetId = "" ;
23+ let scrollFrame = 0 ;
2124
2225 /**
2326 * Applies a preset and updates the active visual state.
@@ -54,24 +57,18 @@ export function createStoryController({ applyPreset }) {
5457 }
5558
5659 if ( ! ( "IntersectionObserver" in window ) ) {
57- return { destroy ( ) { } } ;
60+ syncActiveStoryStep ( ) ;
61+ window . addEventListener ( "scroll" , queueStorySync , { passive : true } ) ;
62+ window . addEventListener ( "resize" , queueStorySync ) ;
63+ return {
64+ destroy ( ) {
65+ if ( scrollFrame ) window . cancelAnimationFrame ( scrollFrame ) ;
66+ window . removeEventListener ( "scroll" , queueStorySync ) ;
67+ window . removeEventListener ( "resize" , queueStorySync ) ;
68+ } ,
69+ } ;
5870 }
5971
60- const observer = new IntersectionObserver (
61- ( entries ) => {
62- const visible = entries
63- . filter ( ( entry ) => entry . isIntersecting )
64- . sort ( ( a , b ) => b . intersectionRatio - a . intersectionRatio ) [ 0 ] ;
65- const presetId = visible ?. target ?. dataset ?. storyPreset ;
66- if ( presetId ) activatePreset ( presetId ) ;
67- } ,
68- {
69- root : null ,
70- rootMargin : "-28% 0px -44% 0px" ,
71- threshold : [ 0.25 , 0.5 , 0.75 ] ,
72- } ,
73- ) ;
74-
7572 const stageObserver =
7673 stage instanceof HTMLElement
7774 ? new IntersectionObserver (
@@ -96,8 +93,10 @@ export function createStoryController({ applyPreset }) {
9693 )
9794 : null ;
9895
99- for ( const step of steps ) observer . observe ( step ) ;
10096 if ( steps [ 0 ] ?. dataset . storyPreset ) activatePreset ( steps [ 0 ] . dataset . storyPreset ) ;
97+ syncActiveStoryStep ( ) ;
98+ window . addEventListener ( "scroll" , queueStorySync , { passive : true } ) ;
99+ window . addEventListener ( "resize" , queueStorySync ) ;
101100 for ( const step of stageSteps ) stageObserver ?. observe ( step ) ;
102101 if ( stageSteps [ 0 ] ?. dataset . stageStep ) {
103102 stageSteps [ 0 ] . classList . add ( STAGE_ACTIVE_CLASS ) ;
@@ -108,10 +107,53 @@ export function createStoryController({ applyPreset }) {
108107
109108 return {
110109 destroy ( ) {
111- observer . disconnect ( ) ;
110+ if ( scrollFrame ) window . cancelAnimationFrame ( scrollFrame ) ;
111+ window . removeEventListener ( "scroll" , queueStorySync ) ;
112+ window . removeEventListener ( "resize" , queueStorySync ) ;
112113 stageObserver ?. disconnect ( ) ;
113114 } ,
114115 } ;
116+
117+ function queueStorySync ( ) {
118+ if ( scrollFrame ) return ;
119+ scrollFrame = window . requestAnimationFrame ( ( ) => {
120+ scrollFrame = 0 ;
121+ syncActiveStoryStep ( ) ;
122+ } ) ;
123+ }
124+
125+ function syncActiveStoryStep ( ) {
126+ const nextStep = getActiveStoryStep ( steps ) ;
127+ const presetId = nextStep ?. dataset ?. storyPreset ;
128+ if ( ! presetId || presetId === activeScrollPresetId ) return ;
129+ activeScrollPresetId = presetId ;
130+ activatePreset ( presetId ) ;
131+ }
132+ }
133+
134+ /**
135+ * Picks the story step whose vertical span currently contains the activation
136+ * line. This fires when the top of a new section crosses the chosen band,
137+ * which behaves well even when sections are taller than the viewport.
138+ *
139+ * @param {HTMLElement[] } steps Story steps ordered in document flow.
140+ * @returns {HTMLElement | undefined } Active step.
141+ */
142+ function getActiveStoryStep ( steps ) {
143+ const triggerY = window . innerHeight * STORY_TRIGGER_RATIO ;
144+ let fallback = steps [ 0 ] ;
145+
146+ for ( const step of steps ) {
147+ const rect = step . getBoundingClientRect ( ) ;
148+ if ( rect . bottom <= 0 ) {
149+ fallback = step ;
150+ continue ;
151+ }
152+ if ( rect . top <= triggerY && rect . bottom > triggerY ) return step ;
153+ if ( rect . top > triggerY ) return fallback ?? step ;
154+ }
155+
156+ return fallback ;
115157}
116158
117159/**
@@ -123,6 +165,6 @@ export function createStoryController({ applyPreset }) {
123165 */
124166function getStageHeaderOffset ( stage ) {
125167 const header = stage . querySelector ( ".section-kicker--panel" ) ;
126- if ( ! ( header instanceof HTMLElement ) ) return 160 ;
168+ if ( ! ( header instanceof HTMLElement ) ) return 18 ;
127169 return header . offsetHeight + 28 ;
128170}
0 commit comments