11// components/Sidebar/LayersPanel.jsx
2- import React , { useState , useMemo } from 'react' ;
3- import { Eye , EyeOff , X , Layers , ToggleLeft , ToggleRight , ChevronDown , ChevronRight , Loader2 , CheckCircle , AlertCircle , Circle , FileText , Download } from 'lucide-react' ;
2+ import React , { useState , useMemo , useCallback , useEffect } from 'react' ;
3+ import { Eye , EyeOff , X , Layers , ToggleLeft , ToggleRight , ChevronDown , ChevronRight , Loader2 , CheckCircle , AlertCircle , Circle , FileText , Download , Minus } from 'lucide-react' ;
44import { LAYER_GROUPS , DISABLED_INFRASTRUCTURE_LAYERS , NON_RECOMMENDED_INFRASTRUCTURE_LAYERS } from '../../constants/layers' ;
55import { INFRASTRUCTURE_ICONS , svgToDataUrl } from '../../utils/iconUtils' ;
6- import { getCandidateSrcs } from '../../utils/spriteResolver' ;
6+ import { getCandidateSrcs , preloadChain , firstReadyInChain } from '../../utils/spriteResolver' ;
77
88import { useMapViewState } from '../../hooks/useMapViewState' ;
99
@@ -19,7 +19,8 @@ const LayersPanel = ({
1919 permitAreas,
2020 hasSubFocus = false ,
2121 onBeginSubFocus = null ,
22- onClearSubFocus = null
22+ onClearSubFocus = null ,
23+ onToggleSubwayLines = null
2324} ) => {
2425 const view = useMapViewState ( map ) ;
2526 // State for tracking which groups are expanded
@@ -76,6 +77,8 @@ const LayersPanel = ({
7677 if ( ! group ) return [ ] ;
7778 return group . layers . filter ( ( layerId ) => {
7879 const cfg = layers [ layerId ] ;
80+ // Hide subwayLines from UI - it's toggled automatically with subwayEntrances
81+ if ( layerId === 'subwayLines' ) return false ;
7982 return cfg && ! cfg . disabled ;
8083 } ) ;
8184 } ;
@@ -98,27 +101,78 @@ const LayersPanel = ({
98101 } ) ;
99102 } ;
100103
104+ // Memoize sprite candidate sources to avoid redundant computation
105+ // Key: layerId + viewType + spriteBase (stable identifiers)
106+ const spriteCacheRef = React . useRef ( new Map ( ) ) ;
107+ const preloadRef = React . useRef ( new Set ( ) ) ;
108+
109+ // Preload sprites for visible layers with enhanced rendering
110+ useEffect ( ( ) => {
111+ if ( ! view ?. viewType ) return ;
112+ const viewType = view . viewType ;
113+ const angle = viewType === 'isometric' ? 135 : 0 ;
114+
115+ Object . entries ( layers || { } ) . forEach ( ( [ layerId , config ] ) => {
116+ if ( ! config ?. enhancedRendering ?. enabled || ! config ?. enhancedRendering ?. spriteBase ) return ;
117+
118+ const spriteBase = config . enhancedRendering . spriteBase ;
119+ const cacheKey = `${ layerId } :${ viewType } :${ spriteBase } ` ;
120+
121+ // Only preload once per cache key
122+ if ( preloadRef . current . has ( cacheKey ) ) return ;
123+
124+ const candidates = getCandidateSrcs ( config , angle , viewType ) ;
125+ if ( candidates && candidates . length > 0 ) {
126+ // Preload the first candidate (primary sprite) to prevent pop-in
127+ preloadChain ( candidates . slice ( 0 , 2 ) ) . catch ( ( ) => { } ) ; // Fire and forget
128+ preloadRef . current . add ( cacheKey ) ;
129+ }
130+ } ) ;
131+ } , [ layers , view ?. viewType ] ) ;
132+
101133 // Render the appropriate icon for a layer
102- const renderLayerIcon = ( layerId , config ) => {
134+ const renderLayerIcon = useCallback ( ( layerId , config ) => {
103135 const icon = INFRASTRUCTURE_ICONS [ layerId ] ;
104136
105137 // If enhanced rendering is enabled for this layer, use the shared spriteResolver
106138 // to build a robust fallback chain (flat and nested structures, legacy as last resort)
107139 if ( config ?. enhancedRendering ?. enabled ) {
108140 const viewType = view ?. viewType || 'isometric' ;
109141 const angle = viewType === 'isometric' ? 135 : 0 ;
110- const candidates = getCandidateSrcs ( config , angle , viewType ) ;
111- const src = candidates [ 0 ] || null ;
142+
143+ // Create a stable cache key based on layer config that only changes when sprite data changes
144+ const spriteBase = config ?. enhancedRendering ?. spriteBase ;
145+ const cacheKey = `${ layerId } :${ viewType } :${ spriteBase || '' } ` ;
146+
147+ // Check cache first - only recompute if config or view changed
148+ let candidates = spriteCacheRef . current . get ( cacheKey ) ;
149+ if ( ! candidates ) {
150+ candidates = getCandidateSrcs ( config , angle , viewType ) ;
151+ spriteCacheRef . current . set ( cacheKey , candidates ) ;
152+ // Limit cache size to prevent memory leaks
153+ if ( spriteCacheRef . current . size > 100 ) {
154+ const firstKey = spriteCacheRef . current . keys ( ) . next ( ) . value ;
155+ spriteCacheRef . current . delete ( firstKey ) ;
156+ }
157+ // Preload immediately when computing new candidates
158+ if ( candidates && candidates . length > 0 ) {
159+ preloadChain ( candidates . slice ( 0 , 2 ) ) . catch ( ( ) => { } ) ;
160+ }
161+ }
162+
163+ // Use first ready image from cache to prevent pop-in, fallback to first candidate
164+ const readySrc = firstReadyInChain ( candidates ) || candidates [ 0 ] || null ;
112165 return (
113166 < div
114167 className = { `w-6 h-6 flex items-center justify-center flex-shrink-0 ${ config . loading ? 'animate-pulse' : '' } ` }
115168 style = { { opacity : config . requested ? 1 : 0.3 } }
116169 >
117- { src ? (
170+ { readySrc ? (
118171 < img
119- src = { src }
172+ src = { readySrc }
120173 alt = { config . name }
121174 className = "w-7 h-7 object-contain"
175+ loading = "eager"
122176 style = { {
123177 filter : config . loading ? 'grayscale(100%)' : 'none' ,
124178 opacity : config . requested ? 1 : 0.6
@@ -206,7 +260,7 @@ const LayersPanel = ({
206260 } }
207261 />
208262 ) ;
209- } ;
263+ } , [ view ?. viewType ] ) ;
210264
211265 // Render a group header with toggle functionality
212266 const renderGroupHeader = ( groupId , group ) => {
@@ -277,6 +331,13 @@ const LayersPanel = ({
277331 const isEmpty = ! ! config . empty ;
278332 const isError = ! ! config . error ;
279333
334+ // Special handling for subway entrances: show both entrances and lines controls
335+ const isSubwayEntrances = layerId === 'subwayEntrances' ;
336+ const subwayLinesConfig = isSubwayEntrances ? layers . subwayLines : null ;
337+ const subwayLinesRequested = subwayLinesConfig ? ! ! subwayLinesConfig . requested : false ;
338+ const subwayLinesLoading = subwayLinesConfig ? ! ! subwayLinesConfig . loading : false ;
339+ const subwayLinesEnabled = subwayLinesConfig ? ( focusedArea && ! subwayLinesConfig . disabled ) : false ;
340+
280341 const renderStatusIcon = ( ) => {
281342 if ( isError ) return < AlertCircle className = "w-4 h-4 text-red-500" title = "Error loading" /> ;
282343 if ( isLoading ) return < Loader2 className = "w-4 h-4 text-blue-600 animate-spin" title = "Loading" /> ;
@@ -298,19 +359,36 @@ const LayersPanel = ({
298359 } `}
299360 >
300361 < div className = "flex items-center space-x-2.5 min-w-0 flex-1" >
362+ { /* Eye button for subway entrances (or main layer) */ }
301363 < button
302364 onClick = { ( ) => isEnabled && ! config . disabled && onToggleLayer ( layerId ) }
303365 className = { `p-1 rounded flex-shrink-0 ${
304366 isEnabled ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700' : 'cursor-not-allowed'
305367 } `}
306368 disabled = { ! isEnabled || isLoading }
369+ title = { isSubwayEntrances ? "Toggle subway entrances" : undefined }
307370 >
308371 { isRequested ? (
309372 < Eye className = { `w-4 h-4 ${ isEnabled ? 'text-blue-600' : 'text-gray-400 dark:text-gray-500' } ` } />
310373 ) : (
311374 < EyeOff className = { `w-4 h-4 ${ isEnabled ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500' } ` } />
312375 ) }
313376 </ button >
377+
378+ { /* Line button for subway lines (only shown for subway entrances) */ }
379+ { isSubwayEntrances && onToggleSubwayLines && (
380+ < button
381+ onClick = { ( ) => subwayLinesEnabled && ! subwayLinesConfig ?. disabled && onToggleSubwayLines ( 'subwayLines' ) }
382+ className = { `p-1 rounded flex-shrink-0 ${
383+ subwayLinesEnabled ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700' : 'cursor-not-allowed'
384+ } `}
385+ disabled = { ! subwayLinesEnabled || subwayLinesLoading }
386+ title = "Toggle subway lines"
387+ >
388+ < Minus className = { `w-4 h-4 ${ subwayLinesEnabled ? ( subwayLinesRequested ? 'text-blue-600' : 'text-gray-600 dark:text-gray-300' ) : 'text-gray-400 dark:text-gray-500' } ` } />
389+ </ button >
390+ ) }
391+
314392 { renderLayerIcon ( layerId , config ) }
315393 < span className = { `text-sm font-medium truncate ${
316394 isRequested && isEnabled ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400'
0 commit comments