Skip to content

Commit 5e5fa80

Browse files
authored
Merge pull request #169 from slaveofcode/develop
Promote to production: fix blank tiles in Map Explorer (PR #168)
2 parents 924662c + 9307d8c commit 5e5fa80

1 file changed

Lines changed: 67 additions & 22 deletions

File tree

src/islands/maps/MapExplorer.tsx

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,44 @@ import { resolveStyle, MAP_STYLES, haversineMeters, formatDistance, type StyleCh
1010
import { ddToDms, ddToUtm, encodeGeohash, formatDd, type LatLng } from '@/tools/geo/coord.lib';
1111
import 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+
1351
const STYLE_KEY = 'gwt.map.style';
1452
type 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

Comments
 (0)