From 6737def6b67df68569c804a30984c62531a1b5ca Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 8 Aug 2026 17:19:41 +0700 Subject: [PATCH] fix(maps): switch Map Explorer to CartoDB raster tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces OpenFreeMap vector tiles (which silently failed to render in MapLibre after flyTo on the Singapore Cloudflare edge) with CARTO free raster tiles. No API key required, Fastly CDN, four sub-domains for parallelism. Drops transformRequest proxy, fetchResolvedStyle async fetch, and all OFM diagnostic event listeners — map init is now synchronous and reliable. Co-Authored-By: Claude Sonnet 4.6 --- src/islands/maps/MapExplorer.tsx | 98 +++++----------------------- src/tools/geo/map-styles.lib.test.ts | 15 ++++- src/tools/geo/map-styles.lib.ts | 41 ++++++++++-- 3 files changed, 64 insertions(+), 90 deletions(-) diff --git a/src/islands/maps/MapExplorer.tsx b/src/islands/maps/MapExplorer.tsx index ff4a4d4..d440227 100644 --- a/src/islands/maps/MapExplorer.tsx +++ b/src/islands/maps/MapExplorer.tsx @@ -10,36 +10,6 @@ import { resolveStyle, MAP_STYLES, haversineMeters, formatDistance, type StyleCh import { ddToDms, ddToUtm, encodeGeohash, formatDd, type LatLng } from '@/tools/geo/coord.lib'; import type { Lang } from '@/i18n/config'; -// Fetch the MapLibre style through our proxy and return the parsed JSON. -// We do NOT manually rewrite URLs inside the style — that is handled entirely -// by the map's `transformRequest` callback, which intercepts every network -// request MapLibre makes (tiles, TileJSON, sprites, glyphs) and routes any -// https://tiles.openfreemap.org/ URL through our /ofm/ Worker proxy. -// -// Earlier attempts to manually inline TileJSON and rewrite tile URLs broke -// MapLibre's vector tile pipeline: inlining `tiles` without the TileJSON's -// `vector_layers` metadata caused MapLibre to load tile bytes but silently -// fail to extract features from them (confirmed: valid 181 KB PBF received, -// zero features rendered). Letting MapLibre own its TileJSON fetch (via -// transformRequest) gives it the full metadata it needs. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function fetchResolvedStyle(url: string): Promise> { - try { - const res = await fetch(url, { cache: 'no-cache' }); - console.log('[map] style fetch', url, res.status, res.ok); - if (!res.ok) return url; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const style = await res.json() as Record; - if (!style.version || !style.sources) { - console.warn('[map] style response invalid:', JSON.stringify(style).slice(0, 200)); - return url; - } - return style; - } catch (e) { - console.error('[map] fetchResolvedStyle failed:', e); - return url; - } -} const STYLE_KEY = 'gwt.map.style'; type SearchHit = { name: string; lat: number; lng: number }; @@ -63,7 +33,7 @@ const TR: Record `Distance: ${dist} (${n} points)`, }, id: { @@ -74,7 +44,7 @@ const TR: Record `Jarak: ${dist} (${n} titik)`, }, }; @@ -90,10 +60,8 @@ export default function MapExplorer({ lang = 'en' }: { lang?: Lang }) { const measureRef = useRef([]); const measuringRef = useRef(false); const roRef = useRef(null); - // Track the URL currently loaded by the map to avoid spurious setStyle calls. - // Calling setStyle with the same URL still triggers a full style reload in - // MapLibre, which cancels any in-flight tile requests and causes blank tiles. - const appliedStyleUrl = useRef(''); + // Track the style ID currently loaded by the map to avoid spurious setStyle calls. + const appliedStyleId = useRef(''); const [style, setStyle] = useState('auto'); const [pin, setPin] = useState(null); @@ -149,55 +117,29 @@ export default function MapExplorer({ lang = 'en' }: { lang?: Lang }) { const ml = await import('maplibre-gl'); if (cancelled || !containerRef.current || mapRef.current) return; mlRef.current = ml; - const initialUrl = resolveStyle(style, theme).url; - appliedStyleUrl.current = initialUrl; - // Fetch the style JSON through our proxy. URL rewriting is handled entirely - // by transformRequest below — we do NOT manually patch URLs in the style. - const styleInput = await fetchResolvedStyle(initialUrl); - if (cancelled || !containerRef.current) return; - - // Route every OFM URL MapLibre fetches (TileJSON, tiles, sprites, glyphs) - // through our /ofm/ Worker proxy. This is the single source of truth for - // URL rewriting — transformRequest is called for ALL network requests - // including those from MapLibre's tile Web Workers. - const OFM_ORIGIN = 'https://tiles.openfreemap.org/'; - const ofmProxyBase = `${location.origin}/ofm/`; - const transformRequest = (url: string) => { - if (url.startsWith(OFM_ORIGIN)) { - return { url: ofmProxyBase + url.slice(OFM_ORIGIN.length) }; - } - }; + const { id, style: styleObj } = resolveStyle(style, theme); + appliedStyleId.current = id; const map = new ml.Map({ container: containerRef.current, // eslint-disable-next-line @typescript-eslint/no-explicit-any - style: styleInput as any, + style: styleObj as any, center: [106.8272, -6.1751], zoom: 3, minZoom: 0, maxZoom: 20, - transformRequest, }); map.addControl(new ml.NavigationControl(), 'top-right'); - // Cap GeolocateControl zoom at 14 — the vector tile source's maxzoom is 14; - // zooming beyond that causes overzoom tile-loading issues in MapLibre v6. map.addControl(new ml.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: false, - fitBoundsOptions: { maxZoom: 14 }, + fitBoundsOptions: { maxZoom: 18 }, }), 'top-right'); map.on('click', e => handleClick(e.lngLat.lat, e.lngLat.lng)); map.on('style.load', () => { ensureMeasureLayer(); refreshMeasureLine(); }); map.on('load', () => map.resize()); // eslint-disable-next-line @typescript-eslint/no-explicit-any map.on('error', (e: any) => console.error('[map] error:', e?.error?.message ?? e)); - map.on('sourcedataloading', (e) => console.log('[map] source loading:', e.sourceId)); - map.on('sourcedata', (e) => { if (e.isSourceLoaded) console.log('[map] source loaded:', e.sourceId); }); - // Force a repaint after each move so MapLibre re-evaluates missing tiles. - // We do NOT call resize() here — the canvas size doesn't change on pan/zoom, - // and calling resize() synchronously in moveend triggers constrainInternal - // recursion in MapLibre v6. The ResizeObserver below handles actual size changes. - map.on('moveend', () => { map.triggerRepaint(); }); // The container is mounted via a dynamically-imported island, so it can be // laid out after the map is created — resize once it (or its size) settles, // otherwise the map renders blank at 0×0. @@ -210,21 +152,13 @@ export default function MapExplorer({ lang = 'en' }: { lang?: Lang }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Re-style on choice or site-theme change — but only when the URL actually - // changes. MapLibre reloads the full style (cancelling in-flight tile requests) - // even when setStyle is called with the same URL, so skip no-op calls. + // Re-style on choice or site-theme change — skip no-op calls (same ID). useEffect(() => { - const url = resolveStyle(style, theme).url; - if (!mapRef.current || url === appliedStyleUrl.current) return; - appliedStyleUrl.current = url; - let isCurrent = true; - fetchResolvedStyle(url).then(styleInput => { - if (isCurrent && mapRef.current) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mapRef.current.setStyle(styleInput as any); - } - }); - return () => { isCurrent = false; }; + const { id, style: styleObj } = resolveStyle(style, theme); + if (!mapRef.current || id === appliedStyleId.current) return; + appliedStyleId.current = id; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mapRef.current.setStyle(styleObj as any); }, [style, theme]); const pickStyle = (s: StyleChoice) => { setStyle(s); try { localStorage.setItem(STYLE_KEY, s); } catch { /* */ } }; @@ -261,9 +195,7 @@ export default function MapExplorer({ lang = 'en' }: { lang?: Lang }) { const myLocation = () => { navigator.geolocation?.getCurrentPosition(pos => { - // Cap at zoom 14 — the vector tile source's maxzoom; overzooming beyond that - // causes blank tiles in MapLibre v6. - mapRef.current?.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 14 }); + mapRef.current?.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 16 }); handleClick(pos.coords.latitude, pos.coords.longitude); }); }; diff --git a/src/tools/geo/map-styles.lib.test.ts b/src/tools/geo/map-styles.lib.test.ts index 7f73f15..13efbf4 100644 --- a/src/tools/geo/map-styles.lib.test.ts +++ b/src/tools/geo/map-styles.lib.test.ts @@ -10,13 +10,22 @@ describe('resolveStyle', () => { expect(resolveStyle('positron', 'dark').id).toBe('positron'); expect(resolveStyle('dark', 'light').id).toBe('dark'); }); - it('produces a proxied style url', () => { - expect(resolveStyle('bright', 'light').url).toBe('/ofm/styles/bright'); + it('returns a MapLibre raster style object with CARTO tiles', () => { + const { style } = resolveStyle('liberty', 'light'); + expect(style.version).toBe(8); + expect((style.sources as Record).carto).toBeDefined(); + const tiles = ((style.sources as Record).carto).tiles; + expect(tiles[0]).toMatch(/cartocdn\.com\/rastertiles\/voyager/); + }); + it('uses dark_all tiles for the dark style', () => { + const { style } = resolveStyle('dark', 'dark'); + const tiles = ((style.sources as Record).carto).tiles; + expect(tiles[0]).toMatch(/dark_all/); }); }); describe('MAP_STYLES', () => { - it('exposes auto + the four OpenFreeMap styles', () => { + it('exposes auto + the four styles', () => { expect(MAP_STYLES.map(s => s.id)).toEqual(['auto', 'liberty', 'bright', 'positron', 'dark']); }); }); diff --git a/src/tools/geo/map-styles.lib.ts b/src/tools/geo/map-styles.lib.ts index 88a2d7f..9fc8c94 100644 --- a/src/tools/geo/map-styles.lib.ts +++ b/src/tools/geo/map-styles.lib.ts @@ -1,6 +1,6 @@ import type { LatLng } from './coord.lib'; -/** OpenFreeMap styles (free, open, no API key). 'auto' follows the site theme. */ +/** CARTO raster tiles (free, no API key, Fastly CDN). 'auto' follows the site theme. */ export type StyleChoice = 'auto' | 'liberty' | 'bright' | 'positron' | 'dark'; export type ConcreteStyle = Exclude; @@ -12,10 +12,43 @@ export const MAP_STYLES: { id: StyleChoice; label: string }[] = [ { id: 'dark', label: 'Dark' }, ]; -/** Resolve a style choice (+ current site theme) to a concrete OpenFreeMap style. */ -export function resolveStyle(choice: StyleChoice, siteTheme: 'light' | 'dark'): { id: ConcreteStyle; url: string } { +type CartoVariant = 'voyager' | 'light' | 'dark'; + +const CARTO_PATH: Record = { + voyager: 'rastertiles/voyager', + light: 'light_all', + dark: 'dark_all', +}; + +const STYLE_VARIANT: Record = { + liberty: 'voyager', + bright: 'light', + positron: 'light', + dark: 'dark', +}; + +const ATTRIBUTION = '© OpenStreetMap contributors © CARTO'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function cartoMapStyle(variant: CartoVariant): Record { + const path = CARTO_PATH[variant]; + const tiles = (['a','b','c','d'] as const).map( + s => `https://${s}.basemaps.cartocdn.com/${path}/{z}/{x}/{y}@2x.png` + ); + return { + version: 8, + sources: { + carto: { type: 'raster', tiles, tileSize: 512, attribution: ATTRIBUTION }, + }, + layers: [{ id: 'carto', type: 'raster', source: 'carto', minzoom: 0, maxzoom: 22 }], + }; +} + +/** Resolve a style choice (+ current site theme) to a concrete CARTO raster style object. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function resolveStyle(choice: StyleChoice, siteTheme: 'light' | 'dark'): { id: ConcreteStyle; style: Record } { const id: ConcreteStyle = choice === 'auto' ? (siteTheme === 'dark' ? 'dark' : 'liberty') : choice; - return { id, url: `/ofm/styles/${id}` }; + return { id, style: cartoMapStyle(STYLE_VARIANT[id]) }; } /** Great-circle distance between two points, in metres. */