feat: task 2.2 tile-source switcher

- pnpm add maplibre-google-maps (runtime; lazy-imported on Google use).
- src/map/core/styles.ts: BasemapDescriptor model with four entries
  (Esri Satellite / OpenTopoMap / OSM / Google Satellite).
  Google entry gated on cfg.googleMapsKey via available(cfg).
  styleCustom() builds a single-raster-source MapLibre style with a
  glyphs URL for future label rendering.
  ensureGoogleProtocol() lazy-loads maplibre-google-maps + addProtocol
  on first use; _googleRegistered guards re-registration.
- src/map/core/map-pref-store.ts: Zustand + persist middleware
  (key: trm-map-prefs, version: 1). Holds basemapId + setBasemap.
- src/map/core/basemap-switcher.tsx: floating control top-right of
  <MapView>. Filters by available(cfg), highlights active, applies via
  getMap().setStyle(). Mount-time bootstrap (no UI state) + user-driven
  onClick (with switching state) split to satisfy React 19's
  react-hooks/set-state-in-effect rule.
- src/routes/_authed/monitor.tsx: renders <BasemapSwitcher /> as a
  child of <MapView>.
- src/vite-env.d.ts: ambient module declaration for
  maplibre-google-maps (no .d.ts ships in the package).

Deviations:
- BasemapDescriptor.style is buildStyle(cfg): StyleSpecification, not
  the spec's StyleSpecification|string union. Google needs the cfg key
  at build time; uniform function shape across all entries is cleaner.
- Bootstrap apply path doesn't use the switching UI state (no visual
  feedback needed during the silent first-paint flip from OSM to user
  preference). User-click path keeps setSwitching for the in-flight
  indicator.

Bundle: MapLibre is now its own chunk (1MB / 273KB gz), shared across
maplibre consumers. Main bundle 371KB / 114KB gz.
This commit is contained in:
2026-05-02 21:15:21 +02:00
parent 29c1052775
commit bc54f1e811
8 changed files with 266 additions and 19 deletions
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react';
import { useRuntimeConfig } from '@/config/context';
import { cn } from '@/lib/utils';
import { getMap } from './map-view';
import { useMapPrefStore } from './map-pref-store';
import { BASEMAP_STYLES, ensureGoogleProtocol, type BasemapDescriptor } from './styles';
/**
* Floating basemap-picker control, top-right of the map.
*
* Reads the persisted choice from `useMapPrefStore` and applies it to the
* map via `map.setStyle(...)`. Style swap fires the `mapReady` gate which
* coordinates the unmount/remount of every other `Map*` component on the
* map (sprites, sources, layers all get re-applied).
*/
export function BasemapSwitcher() {
const cfg = useRuntimeConfig();
const current = useMapPrefStore((s) => s.basemapId);
const setBasemap = useMapPrefStore((s) => s.setBasemap);
const [switching, setSwitching] = useState<string | null>(null);
const available = BASEMAP_STYLES.filter((d) => d.available(cfg));
// Bootstrap: apply the persisted basemap once at mount. The singleton
// boots with OSM as a bootstrap; this flips to the user's saved choice.
// No `switching` UI state needed here — there's nothing to indicate to
// the user during the very first style swap.
useEffect(() => {
const descriptor = available.find((d) => d.id === current);
if (!descriptor) return;
void (async () => {
if (descriptor.id === 'google-satellite') {
await ensureGoogleProtocol();
}
getMap().setStyle(descriptor.buildStyle(cfg));
})();
// Run-once on mount. The user-driven onClick path handles subsequent
// changes; depending on `available` / `cfg` here would re-run the
// bootstrap unnecessarily.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function onClick(descriptor: BasemapDescriptor): Promise<void> {
if (current === descriptor.id) return;
setBasemap(descriptor.id);
setSwitching(descriptor.id);
try {
if (descriptor.id === 'google-satellite') {
await ensureGoogleProtocol();
}
getMap().setStyle(descriptor.buildStyle(cfg));
} finally {
setSwitching(null);
}
}
return (
<div className="absolute top-3 right-3 bg-background border border-border rounded-md shadow-sm overflow-hidden z-10">
{available.map((d) => {
const isActive = current === d.id;
const isPending = switching === d.id;
return (
<button
key={d.id}
type="button"
onClick={() => void onClick(d)}
disabled={isPending}
className={cn(
'block w-full text-left px-3 py-1.5 text-sm transition-colors',
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50 text-foreground',
isPending && 'opacity-50',
)}
>
{d.label}
</button>
);
})}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { DEFAULT_BASEMAP_ID, type BasemapId } from './styles';
type MapPrefState = {
basemapId: BasemapId;
};
type MapPrefActions = {
setBasemap: (id: BasemapId) => void;
};
type Store = MapPrefState & MapPrefActions;
/**
* Persists user preferences for the map: which basemap is selected,
* eventually whether trails are visible, follow mode, etc. Survives
* reloads via localStorage.
*/
export const useMapPrefStore = create<Store>()(
persist(
(set) => ({
basemapId: DEFAULT_BASEMAP_ID,
setBasemap: (id) => set({ basemapId: id }),
}),
{
name: 'trm-map-prefs',
version: 1,
},
),
);
+121 -17
View File
@@ -1,24 +1,128 @@
import type { StyleSpecification } from 'maplibre-gl';
import type { RuntimeConfig } from '@/config/schema';
export type BasemapId = 'esri-satellite' | 'opentopo' | 'osm' | 'google-satellite';
export type BasemapDescriptor = {
id: BasemapId;
label: string;
available: (cfg: RuntimeConfig) => boolean;
buildStyle: (cfg: RuntimeConfig) => StyleSpecification;
};
/**
* Bootstrap basemap style used until task 2.2 (basemap switcher) lands.
* Build a minimal MapLibre style with a single raster source + raster layer.
*
* Plain OSM raster tiles. Free, attribution-required, polite User-Agent
* recommended for any production load. For dogfood-scale traffic this is
* fine.
* The `glyphs` URL lets future symbol layers (sprites + labels) render text
* even on raster basemaps.
*/
export const defaultStyle: StyleSpecification = {
version: 8,
sources: {
raster: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
minzoom: 0,
maxzoom: 19,
function styleCustom(opts: {
tiles: string[];
attribution: string;
minZoom?: number;
maxZoom?: number;
}): StyleSpecification {
return {
version: 8,
sources: {
raster: {
type: 'raster',
tiles: opts.tiles,
tileSize: 256,
attribution: opts.attribution,
minzoom: opts.minZoom ?? 0,
maxzoom: opts.maxZoom ?? 19,
},
},
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf',
layers: [{ id: 'raster', type: 'raster', source: 'raster' }],
};
}
export const BASEMAP_STYLES: BasemapDescriptor[] = [
{
id: 'esri-satellite',
label: 'Satellite',
available: () => true,
buildStyle: () =>
styleCustom({
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
attribution: 'Tiles © Esri',
maxZoom: 19,
}),
},
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf',
layers: [{ id: 'raster', type: 'raster', source: 'raster' }],
};
{
id: 'opentopo',
label: 'Topo',
available: () => true,
buildStyle: () =>
styleCustom({
tiles: [
'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
'https://b.tile.opentopomap.org/{z}/{x}/{y}.png',
'https://c.tile.opentopomap.org/{z}/{x}/{y}.png',
],
attribution: '© OpenTopoMap (CC-BY-SA)',
maxZoom: 17,
}),
},
{
id: 'osm',
label: 'Street',
available: () => true,
buildStyle: () =>
styleCustom({
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
}),
},
{
id: 'google-satellite',
label: 'Google',
available: (cfg) => Boolean(cfg.googleMapsKey),
buildStyle: (cfg) =>
styleCustom({
tiles: [`google://satellite/{z}/{x}/{y}?key=${cfg.googleMapsKey ?? ''}`],
attribution: '© Google',
maxZoom: 22,
}),
},
];
export const DEFAULT_BASEMAP_ID: BasemapId = 'esri-satellite';
/**
* Bootstrap style used by the singleton at first render — before runtime
* config has finished loading and before the user has picked a basemap.
*
* OSM is the universal fallback (free, no key, no cookie). The basemap
* switcher (`<BasemapSwitcher>`) flips to the user's chosen basemap once
* the runtime config + persisted preference are available.
*/
export const defaultStyle: StyleSpecification = styleCustom({
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
});
/**
* Lazily register the `google://` protocol handler on first use.
*
* The adapter is a runtime dep but its registration is gated on the
* presence of a Google Maps key in the runtime config. If the key is
* absent, this function never runs and the adapter's chunk doesn't load.
*/
let _googleRegistered = false;
export async function ensureGoogleProtocol(): Promise<void> {
if (_googleRegistered) return;
const [{ default: maplibregl }, { googleProtocol }] = await Promise.all([
import('maplibre-gl'),
import('maplibre-google-maps'),
]);
maplibregl.addProtocol('google', googleProtocol);
_googleRegistered = true;
}
+12 -1
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router';
import { BasemapSwitcher } from '@/map/core/basemap-switcher';
import { MapView } from '@/map/core/map-view';
export const Route = createFileRoute('/_authed/monitor')({
@@ -11,7 +12,17 @@ function MonitorPage() {
// For now the parent _authed layout has no top-bar, so the map can
// fill the full viewport — adjust when chrome lands.
<div className="relative h-[calc(100vh-0rem)] w-full">
<MapView />
<MapView>
{/*
BasemapSwitcher renders inside <MapView> so it remounts after
every style swap, ensuring the persisted preference is re-applied
if the user does something funny (e.g. force-reloads style via
devtools). It's also a sibling of the map canvas, not on top of
it visually — the absolute-positioned card is positioned relative
to <MapView>'s wrapper.
*/}
<BasemapSwitcher />
</MapView>
</div>
);
}
+10
View File
@@ -14,3 +14,13 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/**
* `maplibre-google-maps` ships an `index.js` without type declarations.
* The protocol handler we consume has a stable signature documented in
* the package's README; declare the surface we use.
*/
declare module 'maplibre-google-maps' {
import type { AddProtocolAction } from 'maplibre-gl';
export const googleProtocol: AddProtocolAction;
}