Files
spa/src/map/core/basemap-switcher.tsx
T
julian bc54f1e811 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.
2026-05-03 09:29:14 +02:00

81 lines
2.9 KiB
TypeScript

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>
);
}