Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 15 additions & 83 deletions src/islands/maps/MapExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | Record<string, any>> {
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<string, any>;
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 };
Expand All @@ -63,7 +33,7 @@ const TR: Record<Lang, {
clear: 'Clear',
hintMeasure: 'Click points on the map to measure distance.',
hintPin: 'Click anywhere on the map to drop a pin and read its coordinates.',
attribution: 'Maps © OpenFreeMap / OpenStreetMap contributors.',
attribution: 'Maps © OpenStreetMap contributors, © CARTO',
distance: (dist, n) => `Distance: ${dist} (${n} points)`,
},
id: {
Expand All @@ -74,7 +44,7 @@ const TR: Record<Lang, {
clear: 'Bersihkan',
hintMeasure: 'Klik titik di peta untuk mengukur jarak.',
hintPin: 'Klik di mana saja pada peta untuk menandai pin dan membaca koordinatnya.',
attribution: 'Peta © OpenFreeMap / OpenStreetMap contributors.',
attribution: 'Peta © OpenStreetMap contributors, © CARTO',
distance: (dist, n) => `Jarak: ${dist} (${n} titik)`,
},
};
Expand All @@ -90,10 +60,8 @@ export default function MapExplorer({ lang = 'en' }: { lang?: Lang }) {
const measureRef = useRef<LatLng[]>([]);
const measuringRef = useRef(false);
const roRef = useRef<ResizeObserver | null>(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<StyleChoice>('auto');
const [pin, setPin] = useState<LatLng | null>(null);
Expand Down Expand Up @@ -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.
Expand All @@ -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 { /* */ } };
Expand Down Expand Up @@ -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);
});
};
Expand Down
15 changes: 12 additions & 3 deletions src/tools/geo/map-styles.lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).carto).toBeDefined();
const tiles = ((style.sources as Record<string, { tiles: string[] }>).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<string, { tiles: string[] }>).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']);
});
});
Expand Down
41 changes: 37 additions & 4 deletions src/tools/geo/map-styles.lib.ts
Original file line number Diff line number Diff line change
@@ -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<StyleChoice, 'auto'>;

Expand All @@ -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<CartoVariant, string> = {
voyager: 'rastertiles/voyager',
light: 'light_all',
dark: 'dark_all',
};

const STYLE_VARIANT: Record<ConcreteStyle, CartoVariant> = {
liberty: 'voyager',
bright: 'light',
positron: 'light',
dark: 'dark',
};

const ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions" target="_blank" rel="noopener">CARTO</a>';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function cartoMapStyle(variant: CartoVariant): Record<string, any> {
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<string, any> } {
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. */
Expand Down
Loading