diff --git a/.planning/phase-2-live-map/01-mapview-singleton.md b/.planning/phase-2-live-map/01-mapview-singleton.md index 94a4be5..5fad219 100644 --- a/.planning/phase-2-live-map/01-mapview-singleton.md +++ b/.planning/phase-2-live-map/01-mapview-singleton.md @@ -203,12 +203,14 @@ Three new files + small updates to existing surfaces: **Deviation flagged:** The spec sketched the singleton's accessor as `map` (a top-level export). Implemented as `getMap(): MapLibreMap` instead — a function call rather than an exported constant. Two reasons: + 1. The singleton is created lazily on first `` mount, not at module import. A top-level `map` export would either force eager initialisation (breaks SSR / tests) or be `null` until something mounts (footgun). 2. `getMap()` throws a clear error if called before `` mounts — a missing pre-condition that's easy to surface and easy to read in stack traces. A `null`-able export forces every consumer to add their own null check. -Side-effect-only `Map*` components (2.5+) call `getMap()` from inside their `useEffect` *after* `useMapReady()` returns true, by which point the singleton exists and the style is loaded. +Side-effect-only `Map*` components (2.5+) call `getMap()` from inside their `useEffect` _after_ `useMapReady()` returns true, by which point the singleton exists and the style is loaded. **Smoke check (local `pnpm dev`):** + - `/monitor` renders a full-viewport OSM map. - Pan / zoom / scroll-zoom all work. - `/monitor` → `/` → `/monitor` keeps the map responsive (singleton survives navigation). diff --git a/package.json b/package.json index 5c3fbb0..3ea825f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "lucide-react": "^1.14.0", "maplibre-gl": "^5.24.0", + "maplibre-google-maps": "^1.1.0", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd2222c..15f3468 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: maplibre-gl: specifier: ^5.24.0 version: 5.24.0 + maplibre-google-maps: + specifier: ^1.1.0 + version: 1.1.0 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1930,6 +1933,9 @@ packages: resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} + maplibre-google-maps@1.1.0: + resolution: {integrity: sha512-bWeaLGvWriC8tMdgQfOdojnXGWyZ8Qva7mbs3qIqLHWAAuOEhlt2X0O4eGKuSKTMHGUIPEKlck3nquoWGhDQ7w==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -4170,6 +4176,8 @@ snapshots: quickselect: 3.0.0 tinyqueue: 3.0.0 + maplibre-google-maps@1.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 diff --git a/src/map/core/basemap-switcher.tsx b/src/map/core/basemap-switcher.tsx new file mode 100644 index 0000000..d69d459 --- /dev/null +++ b/src/map/core/basemap-switcher.tsx @@ -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(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 { + 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 ( +
+ {available.map((d) => { + const isActive = current === d.id; + const isPending = switching === d.id; + return ( + + ); + })} +
+ ); +} diff --git a/src/map/core/map-pref-store.ts b/src/map/core/map-pref-store.ts new file mode 100644 index 0000000..ac32da1 --- /dev/null +++ b/src/map/core/map-pref-store.ts @@ -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()( + persist( + (set) => ({ + basemapId: DEFAULT_BASEMAP_ID, + setBasemap: (id) => set({ basemapId: id }), + }), + { + name: 'trm-map-prefs', + version: 1, + }, + ), +); diff --git a/src/map/core/styles.ts b/src/map/core/styles.ts index 674e59c..a96a2de 100644 --- a/src/map/core/styles.ts +++ b/src/map/core/styles.ts @@ -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 (``) 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 { + if (_googleRegistered) return; + const [{ default: maplibregl }, { googleProtocol }] = await Promise.all([ + import('maplibre-gl'), + import('maplibre-google-maps'), + ]); + maplibregl.addProtocol('google', googleProtocol); + _googleRegistered = true; +} diff --git a/src/routes/_authed/monitor.tsx b/src/routes/_authed/monitor.tsx index 4c28b65..2a627d6 100644 --- a/src/routes/_authed/monitor.tsx +++ b/src/routes/_authed/monitor.tsx @@ -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.
- + + {/* + BasemapSwitcher renders inside 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 's wrapper. + */} + +
); } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index ee15fce..ba2144c 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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; +}