1- import { useState , useEffect , useRef } from 'react' ;
1+ import { useState , useEffect , useRef , useCallback } from 'react' ;
22import { cn } from '../styles/classNames' ;
33import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' ;
44import { faInfo } from '@fortawesome/free-solid-svg-icons' ;
@@ -10,6 +10,10 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
1010 const [ showSidebarToggle , setShowSidebarToggle ] = useState ( false ) ;
1111 const [ showSidebarTooltip , setShowSidebarTooltip ] = useState ( false ) ;
1212 const tooltipTimeoutRef = useRef ( null ) ;
13+ const [ pendingHash , setPendingHash ] = useState ( ( ) => {
14+ return window . location . hash ? window . location . hash . slice ( 1 ) : null ;
15+ } ) ;
16+ const isScrollingRef = useRef ( false ) ;
1317
1418 // Cleanup tooltip timeout on unmount
1519 useEffect ( ( ) => {
@@ -20,6 +24,21 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
2024 } ;
2125 } , [ ] ) ;
2226
27+ // When loading with a hash, immediately scroll past hero/intro to reduce wait time
28+ useEffect ( ( ) => {
29+ if ( pendingHash ) {
30+ // Find the dashboard section start (after intro) and scroll there instantly
31+ const dashboardStart = document . getElementById ( 'introduction-section' ) ;
32+ if ( dashboardStart ) {
33+ // Instant scroll to just past the intro, then smooth scroll to target will happen later
34+ window . scrollTo ( {
35+ top : dashboardStart . offsetTop + dashboardStart . offsetHeight ,
36+ behavior : 'instant'
37+ } ) ;
38+ }
39+ }
40+ } , [ ] ) ; // Only run once on mount
41+
2342 // Extract headings from chart components and direct h3 headings
2443 useEffect ( ( ) => {
2544 if ( ! containerRef ?. current ) return ;
@@ -30,7 +49,6 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
3049 // Use a Map to track unique headings by text content
3150 const uniqueHeadings = new Map ( ) ;
3251 const headingsList = [ ] ;
33- let sectionCounter = 0 ;
3452
3553 // Find ALL h3 elements in document order
3654 const allHeadings = container . querySelectorAll ( 'h3' ) ;
@@ -54,11 +72,10 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
5472
5573 // Only add if we haven't seen this text before
5674 if ( ! uniqueHeadings . has ( text ) ) {
57- // Create a stable ID if it doesn't exist, based on cleaned text
75+ // Create a stable ID if it doesn't exist, based on cleaned text (no counter for stable URLs)
5876 if ( ! heading . id ) {
5977 const cleanText = text . toLowerCase ( ) . replace ( / [ ^ a - z 0 - 9 ] / g, '-' ) . replace ( / - + / g, '-' ) . replace ( / ^ - | - $ / g, '' ) ;
60- heading . id = `section-${ cleanText } -${ sectionCounter } ` ;
61- sectionCounter ++ ;
78+ heading . id = `section-${ cleanText } ` ;
6279 }
6380
6481 const chartContainer = heading . closest ( '[data-chart-id]' ) ;
@@ -98,6 +115,44 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
98115 return ( ) => observer . disconnect ( ) ;
99116 } , [ containerRef ] ) ;
100117
118+ // Scroll to a section by ID
119+ const scrollToSection = useCallback ( ( sectionId , updateHistory = true ) => {
120+ const element = document . getElementById ( sectionId ) ;
121+ if ( element ) {
122+ // Try to find the associated chart container for better scrolling
123+ let targetElement = element ;
124+ const chartContainer = element . closest ( '[data-chart-id]' ) ;
125+ if ( chartContainer ) {
126+ targetElement = chartContainer ;
127+ }
128+
129+ // Get the element's position relative to the viewport
130+ const elementRect = targetElement . getBoundingClientRect ( ) ;
131+
132+ // Calculate scroll position (current scroll + element position - offset for sticky header)
133+ const headerOffset = 60 ; // Account for sticky header (48px) + small buffer
134+ const targetScrollPosition = window . scrollY + elementRect . top - headerOffset ;
135+
136+ // Mark that we're programmatically scrolling to prevent URL spam
137+ isScrollingRef . current = true ;
138+
139+ // Update URL hash (use pushState for clicks so back button works)
140+ if ( updateHistory ) {
141+ window . history . pushState ( null , '' , `#${ sectionId } ` ) ;
142+ }
143+
144+ window . scrollTo ( {
145+ top : targetScrollPosition ,
146+ behavior : 'smooth'
147+ } ) ;
148+
149+ // Reset scrolling flag after animation completes
150+ setTimeout ( ( ) => {
151+ isScrollingRef . current = false ;
152+ } , 500 ) ;
153+ }
154+ } , [ ] ) ;
155+
101156 // Simple scroll-based active section tracking
102157 useEffect ( ( ) => {
103158 if ( ! containerRef ?. current || sections . length === 0 ) return ;
@@ -143,6 +198,10 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
143198 // Clear active section found
144199 if ( activeId !== activeSection ) {
145200 setActiveSection ( activeId ) ;
201+ // Update URL hash without triggering scroll (only when not programmatically scrolling)
202+ if ( ! isScrollingRef . current ) {
203+ window . history . replaceState ( null , '' , `#${ activeId } ` ) ;
204+ }
146205 }
147206 } else if ( ! activeSection && fallbackId ) {
148207 // Only use fallback if we don't have any active section yet (initial load)
@@ -166,29 +225,32 @@ const TableOfContents = ({ containerRef, width, collapsed, onToggle, onItemMouse
166225 } ;
167226 } , [ containerRef , sections , activeSection ] ) ;
168227
169- const scrollToSection = ( sectionId ) => {
170- const element = document . getElementById ( sectionId ) ;
171- if ( element ) {
172- // Try to find the associated chart container for better scrolling
173- let targetElement = element ;
174- const chartContainer = element . closest ( '[data-chart-id]' ) ;
175- if ( chartContainer ) {
176- targetElement = chartContainer ;
177- }
178-
179- // Get the element's position relative to the viewport
180- const elementRect = targetElement . getBoundingClientRect ( ) ;
228+ // Handle initial page load with hash - wait for section to be discovered
229+ useEffect ( ( ) => {
230+ if ( ! pendingHash || sections . length === 0 ) return ;
231+
232+ const targetSection = sections . find ( s => s . id === pendingHash ) ;
233+ if ( targetSection ) {
234+ // Small delay to ensure layout is stable after charts load
235+ setTimeout ( ( ) => {
236+ scrollToSection ( pendingHash , false ) ;
237+ setPendingHash ( null ) ;
238+ } , 100 ) ;
239+ }
240+ } , [ sections , pendingHash , scrollToSection ] ) ;
181241
182- // Calculate scroll position (current scroll + element position - offset for sticky header)
183- const headerOffset = 60 ; // Account for sticky header (48px) + small buffer
184- const targetScrollPosition = window . scrollY + elementRect . top - headerOffset ;
242+ // Handle browser back/forward navigation
243+ useEffect ( ( ) => {
244+ const handlePopState = ( ) => {
245+ const hash = window . location . hash . slice ( 1 ) ;
246+ if ( hash ) {
247+ scrollToSection ( hash , false ) ;
248+ }
249+ } ;
185250
186- window . scrollTo ( {
187- top : targetScrollPosition ,
188- behavior : 'smooth'
189- } ) ;
190- }
191- } ;
251+ window . addEventListener ( 'popstate' , handlePopState ) ;
252+ return ( ) => window . removeEventListener ( 'popstate' , handlePopState ) ;
253+ } , [ scrollToSection ] ) ;
192254
193255 // Filter sections based on search query
194256 const filteredSections = sections . filter ( section =>
0 commit comments