@@ -10,6 +10,44 @@ import { resolveStyle, MAP_STYLES, haversineMeters, formatDistance, type StyleCh
1010import { ddToDms , ddToUtm , encodeGeohash , formatDd , type LatLng } from '@/tools/geo/coord.lib' ;
1111import type { Lang } from '@/i18n/config' ;
1212
13+ // Pre-fetch a MapLibre style URL and inline any TileJSON-backed vector sources
14+ // so MapLibre never needs to make a separate TileJSON fetch. This works around
15+ // Cloudflare edge-caching issues where the style URL returns stale/broken content
16+ // at certain CDN nodes. cache:'no-cache' forces revalidation with the origin server.
17+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18+ async function fetchResolvedStyle ( url : string ) : Promise < string | Record < string , any > > {
19+ try {
20+ const res = await fetch ( url , { cache : 'no-cache' } ) ;
21+ if ( ! res . ok ) return url ;
22+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23+ const style = await res . json ( ) as { version ?: number ; sources ?: Record < string , any > } ;
24+ // Validate: must look like a MapLibre style (has version + sources)
25+ if ( ! style . version || ! style . sources ) return url ;
26+ await Promise . all (
27+ Object . values ( style . sources ) . map ( async ( src ) => {
28+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29+ const s = src as Record < string , any > ;
30+ if ( s . type === 'vector' && typeof s . url === 'string' && ! s . tiles ) {
31+ try {
32+ const tj = await fetch ( s . url , { cache : 'no-cache' } ) . then ( r => r . json ( ) ) as {
33+ tiles ?: string [ ] ; minzoom ?: number ; maxzoom ?: number ;
34+ } ;
35+ if ( tj . tiles ?. length ) {
36+ s . tiles = tj . tiles ;
37+ if ( tj . minzoom != null ) s . minzoom = tj . minzoom ;
38+ if ( tj . maxzoom != null ) s . maxzoom = tj . maxzoom ;
39+ delete s . url ;
40+ }
41+ } catch { /* keep url-based source as fallback */ }
42+ }
43+ } )
44+ ) ;
45+ return style ;
46+ } catch {
47+ return url ;
48+ }
49+ }
50+
1351const STYLE_KEY = 'gwt.map.style' ;
1452type SearchHit = { name : string ; lat : number ; lng : number } ;
1553
@@ -120,37 +158,35 @@ export default function MapExplorer({ lang = 'en' }: { lang?: Lang }) {
120158 mlRef . current = ml ;
121159 const initialUrl = resolveStyle ( style , theme ) . url ;
122160 appliedStyleUrl . current = initialUrl ;
161+ // Fetch style + resolve inline TileJSON before creating the map so MapLibre
162+ // never needs its own TileJSON fetch (avoids stale CDN edge responses).
163+ const styleInput = await fetchResolvedStyle ( initialUrl ) ;
164+ if ( cancelled || ! containerRef . current ) return ;
123165 const map = new ml . Map ( {
124166 container : containerRef . current ,
125- style : initialUrl ,
167+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
168+ style : styleInput as any ,
126169 center : [ 106.8272 , - 6.1751 ] ,
127170 zoom : 3 ,
128171 minZoom : 0 ,
129172 maxZoom : 20 ,
130173 } ) ;
131174 map . addControl ( new ml . NavigationControl ( ) , 'top-right' ) ;
132- map . addControl ( new ml . GeolocateControl ( { positionOptions : { enableHighAccuracy : true } , trackUserLocation : false } ) , 'top-right' ) ;
175+ // Cap GeolocateControl zoom at 14 — the vector tile source's maxzoom is 14;
176+ // zooming beyond that causes overzoom tile-loading issues in MapLibre v6.
177+ map . addControl ( new ml . GeolocateControl ( {
178+ positionOptions : { enableHighAccuracy : true } ,
179+ trackUserLocation : false ,
180+ fitBoundsOptions : { maxZoom : 14 } ,
181+ } ) , 'top-right' ) ;
133182 map . on ( 'click' , e => handleClick ( e . lngLat . lat , e . lngLat . lng ) ) ;
134183 map . on ( 'style.load' , ( ) => { ensureMeasureLayer ( ) ; refreshMeasureLine ( ) ; } ) ;
135184 map . on ( 'load' , ( ) => map . resize ( ) ) ;
136- // After any pan/zoom animation (flyTo, GeolocateControl, etc.) revalidate the
137- // canvas size — without this, MapLibre reports stale dimensions and tiles don't
138- // fill the viewport, leaving the map blank.
139- // Guard + rAF: calling map.resize() synchronously inside moveend causes MapLibre
140- // to fire moveend again from within constrainInternal → stack overflow. Deferring
141- // to the next animation frame breaks the synchronous recursion.
142- let resizePending = false ;
143- map . on ( 'moveend' , ( ) => {
144- if ( resizePending ) return ;
145- resizePending = true ;
146- requestAnimationFrame ( ( ) => {
147- resizePending = false ;
148- // resize() is a no-op when canvas dimensions haven't changed, so follow
149- // it with triggerRepaint() to ensure tile fetching runs for the new viewport.
150- map . resize ( ) ;
151- map . triggerRepaint ( ) ;
152- } ) ;
153- } ) ;
185+ // Force a repaint after each move so MapLibre re-evaluates missing tiles.
186+ // We do NOT call resize() here — the canvas size doesn't change on pan/zoom,
187+ // and calling resize() synchronously in moveend triggers constrainInternal
188+ // recursion in MapLibre v6. The ResizeObserver below handles actual size changes.
189+ map . on ( 'moveend' , ( ) => { map . triggerRepaint ( ) ; } ) ;
154190 // The container is mounted via a dynamically-imported island, so it can be
155191 // laid out after the map is created — resize once it (or its size) settles,
156192 // otherwise the map renders blank at 0×0.
@@ -170,7 +206,14 @@ export default function MapExplorer({ lang = 'en' }: { lang?: Lang }) {
170206 const url = resolveStyle ( style , theme ) . url ;
171207 if ( ! mapRef . current || url === appliedStyleUrl . current ) return ;
172208 appliedStyleUrl . current = url ;
173- mapRef . current . setStyle ( url ) ;
209+ let isCurrent = true ;
210+ fetchResolvedStyle ( url ) . then ( styleInput => {
211+ if ( isCurrent && mapRef . current ) {
212+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
213+ mapRef . current . setStyle ( styleInput as any ) ;
214+ }
215+ } ) ;
216+ return ( ) => { isCurrent = false ; } ;
174217 } , [ style , theme ] ) ;
175218
176219 const pickStyle = ( s : StyleChoice ) => { setStyle ( s ) ; try { localStorage . setItem ( STYLE_KEY , s ) ; } catch { /* */ } } ;
@@ -207,7 +250,9 @@ export default function MapExplorer({ lang = 'en' }: { lang?: Lang }) {
207250
208251 const myLocation = ( ) => {
209252 navigator . geolocation ?. getCurrentPosition ( pos => {
210- mapRef . current ?. flyTo ( { center : [ pos . coords . longitude , pos . coords . latitude ] , zoom : 15 } ) ;
253+ // Cap at zoom 14 — the vector tile source's maxzoom; overzooming beyond that
254+ // causes blank tiles in MapLibre v6.
255+ mapRef . current ?. flyTo ( { center : [ pos . coords . longitude , pos . coords . latitude ] , zoom : 14 } ) ;
211256 handleClick ( pos . coords . latitude , pos . coords . longitude ) ;
212257 } ) ;
213258 } ;
0 commit comments