2026-04-26 00:03:37 +00:00
|
|
|
|
import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from 'react'
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
import maplibregl from 'maplibre-gl'
|
|
|
|
|
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
|
|
|
|
|
import { Protocol } from 'pmtiles'
|
|
|
|
|
|
import { layers, namedTheme } from 'protomaps-themes-base'
|
|
|
|
|
|
import { useStore } from '../store'
|
|
|
|
|
|
import { decodePolyline } from '../utils/decode'
|
2026-04-20 21:27:07 +00:00
|
|
|
|
import { fetchReverse } from '../api'
|
2026-04-21 00:52:20 +00:00
|
|
|
|
import { getConfig, hasFeature } from '../config'
|
2026-04-26 06:44:12 +00:00
|
|
|
|
import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star } from 'lucide-react'
|
2026-04-26 05:42:48 +00:00
|
|
|
|
import RadialMenu from './RadialMenu'
|
|
|
|
|
|
import useContextMenu from '../hooks/useContextMenu'
|
|
|
|
|
|
import toast from 'react-hot-toast'
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
|
|
|
|
|
|
const ROUTE_SOURCE = 'route-source'
|
2026-04-26 08:26:56 +00:00
|
|
|
|
const BOUNDARY_SOURCE = 'boundary-source'
|
|
|
|
|
|
const BOUNDARY_LAYER = 'boundary-layer'
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
const ROUTE_LAYER_PREFIX = 'route-layer-'
|
2026-04-21 00:52:20 +00:00
|
|
|
|
const HILLSHADE_SOURCE = 'hillshade-dem'
|
|
|
|
|
|
const HILLSHADE_LAYER = 'hillshade-layer'
|
|
|
|
|
|
const TRAFFIC_SOURCE = 'traffic-tiles'
|
|
|
|
|
|
const TRAFFIC_LAYER = 'traffic-layer'
|
2026-04-22 18:52:53 +00:00
|
|
|
|
const PUBLIC_LANDS_SOURCE = 'public-lands-tiles'
|
|
|
|
|
|
const PUBLIC_LANDS_FILL = 'public-lands-fill'
|
|
|
|
|
|
const PUBLIC_LANDS_LINE = 'public-lands-line'
|
|
|
|
|
|
const PUBLIC_LANDS_LABEL = 'public-lands-label'
|
2026-04-24 00:44:20 +00:00
|
|
|
|
const CONTOUR_SOURCE = 'contour-tiles'
|
|
|
|
|
|
const CONTOUR_MINOR = 'contour-minor'
|
|
|
|
|
|
const CONTOUR_INTERMEDIATE = 'contour-intermediate'
|
|
|
|
|
|
const CONTOUR_INDEX = 'contour-index'
|
|
|
|
|
|
const CONTOUR_LABEL = 'contour-label'
|
2026-04-26 00:03:37 +00:00
|
|
|
|
const CONTOUR_TEST_SOURCE = 'contour-test-tiles'
|
|
|
|
|
|
const CONTOUR_TEST_MINOR = 'contour-test-minor'
|
|
|
|
|
|
const CONTOUR_TEST_INTERMEDIATE = 'contour-test-intermediate'
|
|
|
|
|
|
const CONTOUR_TEST_INDEX = 'contour-test-index'
|
|
|
|
|
|
const CONTOUR_TEST_LABEL = 'contour-test-label'
|
2026-04-26 04:03:52 +00:00
|
|
|
|
const CONTOUR_TEST_10FT_SOURCE = 'contour-test-10ft-tiles'
|
|
|
|
|
|
const CONTOUR_TEST_10FT_MINOR = 'contour-test-10ft-minor'
|
|
|
|
|
|
const CONTOUR_TEST_10FT_INTERMEDIATE = 'contour-test-10ft-intermediate'
|
|
|
|
|
|
const CONTOUR_TEST_10FT_INDEX = 'contour-test-10ft-index'
|
|
|
|
|
|
const CONTOUR_TEST_10FT_LABEL = 'contour-test-10ft-label'
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
/** Build a full MapLibre style object for the given theme */
|
|
|
|
|
|
function buildStyle(themeName) {
|
2026-04-20 23:36:02 +00:00
|
|
|
|
const config = getConfig()
|
|
|
|
|
|
const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles'
|
|
|
|
|
|
const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM'
|
|
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
return {
|
|
|
|
|
|
version: 8,
|
|
|
|
|
|
glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
|
|
|
|
|
|
sprite: `https://protomaps.github.io/basemaps-assets/sprites/v4/${themeName}`,
|
|
|
|
|
|
sources: {
|
|
|
|
|
|
protomaps: {
|
|
|
|
|
|
type: 'vector',
|
2026-04-20 23:36:02 +00:00
|
|
|
|
url: `pmtiles://${tileUrl}`,
|
|
|
|
|
|
attribution,
|
2026-04-20 20:59:18 +00:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
layers: layers('protomaps', namedTheme(themeName), { lang: 'en' }),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** SVG for ATAK-style chevron pointing up (will be rotated via CSS) */
|
|
|
|
|
|
const CHEVRON_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
|
|
<path d="M8 1 L14 13 L8 10 L2 13 Z" fill="var(--accent)" stroke="var(--bg-raised)" stroke-width="1.5" stroke-linejoin="round"/>
|
|
|
|
|
|
</svg>`
|
|
|
|
|
|
|
2026-04-21 00:52:20 +00:00
|
|
|
|
/** Add hillshade raster-dem source + layer to the map */
|
|
|
|
|
|
function addHillshade(map) {
|
|
|
|
|
|
if (!map || map.getSource(HILLSHADE_SOURCE)) return
|
|
|
|
|
|
const config = getConfig()
|
|
|
|
|
|
const hs = config?.tileset_hillshade
|
|
|
|
|
|
if (!hs?.url) return
|
|
|
|
|
|
|
|
|
|
|
|
map.addSource(HILLSHADE_SOURCE, {
|
|
|
|
|
|
type: 'raster-dem',
|
|
|
|
|
|
url: `pmtiles://${hs.url}`,
|
|
|
|
|
|
encoding: hs.encoding || 'terrarium',
|
|
|
|
|
|
tileSize: 256,
|
|
|
|
|
|
maxzoom: hs.max_zoom || 12,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Insert below the first symbol/label layer for proper z-ordering
|
|
|
|
|
|
let beforeId = undefined
|
|
|
|
|
|
for (const layer of map.getStyle().layers) {
|
|
|
|
|
|
if (layer.type === 'symbol') {
|
|
|
|
|
|
beforeId = layer.id
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: HILLSHADE_LAYER,
|
|
|
|
|
|
type: 'hillshade',
|
|
|
|
|
|
source: HILLSHADE_SOURCE,
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'hillshade-exaggeration': 0.5,
|
|
|
|
|
|
'hillshade-illumination-direction': 315,
|
|
|
|
|
|
'hillshade-shadow-color': '#000000',
|
|
|
|
|
|
'hillshade-highlight-color': '#ffffff',
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Remove hillshade layer + source */
|
|
|
|
|
|
function removeHillshade(map) {
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
if (map.getLayer(HILLSHADE_LAYER)) map.removeLayer(HILLSHADE_LAYER)
|
|
|
|
|
|
if (map.getSource(HILLSHADE_SOURCE)) map.removeSource(HILLSHADE_SOURCE)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Add traffic raster tile source + layer */
|
|
|
|
|
|
function addTraffic(map) {
|
|
|
|
|
|
if (!map || map.getSource(TRAFFIC_SOURCE)) return
|
|
|
|
|
|
const config = getConfig()
|
|
|
|
|
|
const tr = config?.traffic
|
|
|
|
|
|
if (!tr?.proxy_url) return
|
|
|
|
|
|
|
|
|
|
|
|
const tileUrl = tr.proxy_url.replace('{z}', '{z}').replace('{x}', '{x}').replace('{y}', '{y}')
|
|
|
|
|
|
|
|
|
|
|
|
map.addSource(TRAFFIC_SOURCE, {
|
|
|
|
|
|
type: 'raster',
|
|
|
|
|
|
tiles: [tileUrl],
|
|
|
|
|
|
tileSize: 256,
|
|
|
|
|
|
maxzoom: 18,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: TRAFFIC_LAYER,
|
|
|
|
|
|
type: 'raster',
|
|
|
|
|
|
source: TRAFFIC_SOURCE,
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'raster-opacity': 0.6,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Remove traffic layer + source */
|
|
|
|
|
|
function removeTraffic(map) {
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
if (map.getLayer(TRAFFIC_LAYER)) map.removeLayer(TRAFFIC_LAYER)
|
|
|
|
|
|
if (map.getSource(TRAFFIC_SOURCE)) map.removeSource(TRAFFIC_SOURCE)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 18:52:53 +00:00
|
|
|
|
/** Add public lands vector tile overlay (PAD-US) */
|
|
|
|
|
|
function addPublicLands(map) {
|
|
|
|
|
|
if (!map || map.getSource(PUBLIC_LANDS_SOURCE)) return
|
|
|
|
|
|
|
|
|
|
|
|
map.addSource(PUBLIC_LANDS_SOURCE, {
|
|
|
|
|
|
type: 'vector',
|
|
|
|
|
|
url: 'pmtiles:///tiles/public-lands.pmtiles',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Insert below symbol layers for proper z-ordering
|
|
|
|
|
|
let beforeId = undefined
|
|
|
|
|
|
for (const layer of map.getStyle().layers) {
|
|
|
|
|
|
if (layer.type === 'symbol') {
|
|
|
|
|
|
beforeId = layer.id
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
|
|
|
|
|
const opacityMod = isDark ? 0.7 : 1.0
|
|
|
|
|
|
|
|
|
|
|
|
// Fill layer — data-driven color by agency + designation
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: PUBLIC_LANDS_FILL,
|
|
|
|
|
|
type: 'fill',
|
|
|
|
|
|
source: PUBLIC_LANDS_SOURCE,
|
|
|
|
|
|
'source-layer': 'public_lands',
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'fill-color': [
|
|
|
|
|
|
'case',
|
|
|
|
|
|
['==', ['get', 'designation'], 'WA'], '#7c6b2f',
|
|
|
|
|
|
['==', ['get', 'designation'], 'WSA'], '#7c6b2f',
|
|
|
|
|
|
['==', ['get', 'agency'], 'NPS'], '#3d6b1f',
|
|
|
|
|
|
['==', ['get', 'agency'], 'USFS'], '#5a7c2f',
|
|
|
|
|
|
['==', ['get', 'agency'], 'BLM'], '#c4a672',
|
|
|
|
|
|
['==', ['get', 'agency'], 'FWS'], '#4a7a5a',
|
|
|
|
|
|
['any',
|
|
|
|
|
|
['==', ['get', 'manager_type'], 'STAT'],
|
|
|
|
|
|
['==', ['get', 'agency'], 'SPR'],
|
|
|
|
|
|
['==', ['get', 'agency'], 'SDC'],
|
|
|
|
|
|
['==', ['get', 'agency'], 'SLB']
|
|
|
|
|
|
], '#5a8c7c',
|
|
|
|
|
|
['any',
|
|
|
|
|
|
['==', ['get', 'manager_type'], 'LOC'],
|
|
|
|
|
|
['==', ['get', 'manager_type'], 'DIST']
|
|
|
|
|
|
], '#8ca694',
|
|
|
|
|
|
'#a0a0a0'
|
|
|
|
|
|
],
|
|
|
|
|
|
'fill-opacity': [
|
|
|
|
|
|
'case',
|
|
|
|
|
|
['==', ['get', 'designation'], 'WA'], 0.30 * opacityMod,
|
|
|
|
|
|
['==', ['get', 'designation'], 'WSA'], 0.30 * opacityMod,
|
|
|
|
|
|
['==', ['get', 'agency'], 'NPS'], 0.30 * opacityMod,
|
|
|
|
|
|
['==', ['get', 'agency'], 'USFS'], 0.25 * opacityMod,
|
|
|
|
|
|
['==', ['get', 'agency'], 'BLM'], 0.20 * opacityMod,
|
|
|
|
|
|
['any',
|
|
|
|
|
|
['==', ['get', 'manager_type'], 'STAT'],
|
|
|
|
|
|
['==', ['get', 'agency'], 'SPR']
|
|
|
|
|
|
], 0.25 * opacityMod,
|
|
|
|
|
|
['any',
|
|
|
|
|
|
['==', ['get', 'manager_type'], 'LOC'],
|
|
|
|
|
|
['==', ['get', 'manager_type'], 'DIST']
|
|
|
|
|
|
], 0.20 * opacityMod,
|
|
|
|
|
|
0.15 * opacityMod
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Outline layer
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: PUBLIC_LANDS_LINE,
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
source: PUBLIC_LANDS_SOURCE,
|
|
|
|
|
|
'source-layer': 'public_lands',
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'line-color': [
|
|
|
|
|
|
'case',
|
|
|
|
|
|
['==', ['get', 'designation'], 'WA'], '#5a4d20',
|
|
|
|
|
|
['==', ['get', 'designation'], 'WSA'], '#5a4d20',
|
|
|
|
|
|
['==', ['get', 'agency'], 'NPS'], '#2a4a15',
|
|
|
|
|
|
['==', ['get', 'agency'], 'USFS'], '#3d5520',
|
|
|
|
|
|
['==', ['get', 'agency'], 'BLM'], '#8a7343',
|
|
|
|
|
|
['==', ['get', 'agency'], 'FWS'], '#2d5a3a',
|
|
|
|
|
|
['any',
|
|
|
|
|
|
['==', ['get', 'manager_type'], 'STAT'],
|
|
|
|
|
|
['==', ['get', 'agency'], 'SPR']
|
|
|
|
|
|
], '#3d6055',
|
|
|
|
|
|
['any',
|
|
|
|
|
|
['==', ['get', 'manager_type'], 'LOC'],
|
|
|
|
|
|
['==', ['get', 'manager_type'], 'DIST']
|
|
|
|
|
|
], '#5c6e66',
|
|
|
|
|
|
'#707070'
|
|
|
|
|
|
],
|
|
|
|
|
|
'line-opacity': [
|
|
|
|
|
|
'case',
|
|
|
|
|
|
['==', ['get', 'agency'], 'NPS'], 0.7,
|
|
|
|
|
|
['==', ['get', 'agency'], 'USFS'], 0.6,
|
|
|
|
|
|
['==', ['get', 'agency'], 'BLM'], 0.5,
|
|
|
|
|
|
0.5
|
|
|
|
|
|
],
|
|
|
|
|
|
'line-width': [
|
|
|
|
|
|
'interpolate', ['linear'], ['zoom'],
|
|
|
|
|
|
4, 0.3,
|
|
|
|
|
|
8, 0.8,
|
|
|
|
|
|
12, 1.2
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Label layer — unit names at zoom 10+
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: PUBLIC_LANDS_LABEL,
|
|
|
|
|
|
type: 'symbol',
|
|
|
|
|
|
source: PUBLIC_LANDS_SOURCE,
|
|
|
|
|
|
'source-layer': 'public_lands',
|
|
|
|
|
|
minzoom: 10,
|
|
|
|
|
|
layout: {
|
|
|
|
|
|
'text-field': ['get', 'name'],
|
|
|
|
|
|
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 10, 14, 13],
|
|
|
|
|
|
'text-font': ['Noto Sans Regular'],
|
|
|
|
|
|
'symbol-placement': 'point',
|
|
|
|
|
|
'text-anchor': 'center',
|
|
|
|
|
|
'text-max-width': 8,
|
|
|
|
|
|
'text-allow-overlap': false,
|
|
|
|
|
|
'text-ignore-placement': false,
|
|
|
|
|
|
},
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'text-color': isDark ? '#c0c8b8' : '#3a4a30',
|
|
|
|
|
|
'text-halo-color': isDark ? '#1a1a1a' : '#ffffff',
|
|
|
|
|
|
'text-halo-width': 1.5,
|
|
|
|
|
|
'text-opacity': 0.85,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Remove public lands layers + source */
|
|
|
|
|
|
function removePublicLands(map) {
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
if (map.getLayer(PUBLIC_LANDS_LABEL)) map.removeLayer(PUBLIC_LANDS_LABEL)
|
|
|
|
|
|
if (map.getLayer(PUBLIC_LANDS_LINE)) map.removeLayer(PUBLIC_LANDS_LINE)
|
|
|
|
|
|
if (map.getLayer(PUBLIC_LANDS_FILL)) map.removeLayer(PUBLIC_LANDS_FILL)
|
|
|
|
|
|
if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 00:44:20 +00:00
|
|
|
|
/** Add topographic contour vector tile overlay */
|
|
|
|
|
|
function addContours(map) {
|
|
|
|
|
|
if (!map || map.getSource(CONTOUR_SOURCE)) return
|
|
|
|
|
|
|
|
|
|
|
|
map.addSource(CONTOUR_SOURCE, {
|
|
|
|
|
|
type: 'vector',
|
|
|
|
|
|
url: 'pmtiles:///tiles/contours-na.pmtiles',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Insert below first symbol layer (above hillshade, below labels)
|
|
|
|
|
|
let beforeId = undefined
|
|
|
|
|
|
for (const layer of map.getStyle().layers) {
|
|
|
|
|
|
if (layer.type === 'symbol') {
|
|
|
|
|
|
beforeId = layer.id
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
|
|
|
|
|
const opMod = isDark ? 0.8 : 1.0
|
|
|
|
|
|
|
|
|
|
|
|
// Minor contours (40ft) — visible z11+
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_MINOR,
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
source: CONTOUR_SOURCE,
|
|
|
|
|
|
'source-layer': 'contours',
|
|
|
|
|
|
minzoom: 11,
|
|
|
|
|
|
filter: ['==', ['get', 'tier'], 'minor'],
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'line-color': '#8b6f47',
|
|
|
|
|
|
'line-opacity': 0.4 * opMod,
|
|
|
|
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.5, 14, 1.0],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Intermediate contours (200ft) — visible z8+
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_INTERMEDIATE,
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
source: CONTOUR_SOURCE,
|
|
|
|
|
|
'source-layer': 'contours',
|
|
|
|
|
|
minzoom: 8,
|
|
|
|
|
|
filter: ['==', ['get', 'tier'], 'intermediate'],
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'line-color': '#8b6f47',
|
|
|
|
|
|
'line-opacity': 0.7 * opMod,
|
|
|
|
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 14, 1.2],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Index contours (1000ft) — visible z4+
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_INDEX,
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
source: CONTOUR_SOURCE,
|
|
|
|
|
|
'source-layer': 'contours',
|
|
|
|
|
|
minzoom: 4,
|
|
|
|
|
|
filter: ['==', ['get', 'tier'], 'index'],
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'line-color': '#6b4f2a',
|
|
|
|
|
|
'line-opacity': 0.9 * opMod,
|
|
|
|
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 1.2, 14, 1.8],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Elevation labels on index contours (z12+)
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_LABEL,
|
|
|
|
|
|
type: 'symbol',
|
|
|
|
|
|
source: CONTOUR_SOURCE,
|
|
|
|
|
|
'source-layer': 'contours',
|
|
|
|
|
|
minzoom: 12,
|
|
|
|
|
|
filter: ['==', ['get', 'tier'], 'index'],
|
|
|
|
|
|
layout: {
|
|
|
|
|
|
'text-field': ['concat', ['to-string', ['get', 'elevation_ft']], "'"],
|
|
|
|
|
|
'text-size': 10,
|
|
|
|
|
|
'text-font': ['Noto Sans Regular'],
|
|
|
|
|
|
'symbol-placement': 'line',
|
|
|
|
|
|
'text-anchor': 'center',
|
|
|
|
|
|
'symbol-spacing': 400,
|
|
|
|
|
|
'text-max-angle': 30,
|
|
|
|
|
|
'text-allow-overlap': false,
|
|
|
|
|
|
},
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'text-color': isDark ? '#c0b898' : '#5a4020',
|
|
|
|
|
|
'text-halo-color': isDark ? '#1a1a1a' : '#ffffff',
|
|
|
|
|
|
'text-halo-width': 1.5,
|
|
|
|
|
|
'text-opacity': 0.85,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Remove contour layers + source */
|
|
|
|
|
|
function removeContours(map) {
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
if (map.getLayer(CONTOUR_LABEL)) map.removeLayer(CONTOUR_LABEL)
|
|
|
|
|
|
if (map.getLayer(CONTOUR_INDEX)) map.removeLayer(CONTOUR_INDEX)
|
|
|
|
|
|
if (map.getLayer(CONTOUR_INTERMEDIATE)) map.removeLayer(CONTOUR_INTERMEDIATE)
|
|
|
|
|
|
if (map.getLayer(CONTOUR_MINOR)) map.removeLayer(CONTOUR_MINOR)
|
|
|
|
|
|
if (map.getSource(CONTOUR_SOURCE)) map.removeSource(CONTOUR_SOURCE)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 00:03:37 +00:00
|
|
|
|
/** Add TEST topographic contour overlay (blue color scheme) */
|
|
|
|
|
|
function addContoursTest(map) {
|
|
|
|
|
|
if (!map || map.getSource(CONTOUR_TEST_SOURCE)) return
|
|
|
|
|
|
|
|
|
|
|
|
map.addSource(CONTOUR_TEST_SOURCE, {
|
|
|
|
|
|
type: "vector",
|
|
|
|
|
|
url: "pmtiles:///tiles/contours-test.pmtiles",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
let beforeId = undefined
|
|
|
|
|
|
for (const layer of map.getStyle().layers) {
|
|
|
|
|
|
if (layer.type === "symbol") {
|
|
|
|
|
|
beforeId = layer.id
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isDark = document.documentElement.getAttribute("data-theme") === "dark"
|
|
|
|
|
|
const opMod = isDark ? 0.8 : 1.0
|
|
|
|
|
|
|
|
|
|
|
|
// Minor contours (40ft) — blue scheme
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_TEST_MINOR,
|
|
|
|
|
|
type: "line",
|
|
|
|
|
|
source: CONTOUR_TEST_SOURCE,
|
|
|
|
|
|
"source-layer": "contours",
|
|
|
|
|
|
minzoom: 11,
|
|
|
|
|
|
filter: ["==", ["get", "tier"], "minor"],
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
"line-color": "#4a7c9b",
|
|
|
|
|
|
"line-opacity": 0.4 * opMod,
|
|
|
|
|
|
"line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.5, 14, 1.0],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Intermediate contours (200ft)
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_TEST_INTERMEDIATE,
|
|
|
|
|
|
type: "line",
|
|
|
|
|
|
source: CONTOUR_TEST_SOURCE,
|
|
|
|
|
|
"source-layer": "contours",
|
|
|
|
|
|
minzoom: 8,
|
|
|
|
|
|
filter: ["==", ["get", "tier"], "intermediate"],
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
"line-color": "#4a7c9b",
|
|
|
|
|
|
"line-opacity": 0.7 * opMod,
|
|
|
|
|
|
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 14, 1.2],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Index contours (1000ft)
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_TEST_INDEX,
|
|
|
|
|
|
type: "line",
|
|
|
|
|
|
source: CONTOUR_TEST_SOURCE,
|
|
|
|
|
|
"source-layer": "contours",
|
|
|
|
|
|
minzoom: 4,
|
|
|
|
|
|
filter: ["==", ["get", "tier"], "index"],
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
"line-color": "#2a5a7c",
|
|
|
|
|
|
"line-opacity": 0.9 * opMod,
|
|
|
|
|
|
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 1.2, 14, 1.8],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Labels
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_TEST_LABEL,
|
|
|
|
|
|
type: "symbol",
|
|
|
|
|
|
source: CONTOUR_TEST_SOURCE,
|
|
|
|
|
|
"source-layer": "contours",
|
|
|
|
|
|
minzoom: 12,
|
|
|
|
|
|
filter: ["==", ["get", "tier"], "index"],
|
|
|
|
|
|
layout: {
|
|
|
|
|
|
"text-field": ["concat", ["to-string", ["get", "elevation_ft"]], ""],
|
|
|
|
|
|
"text-size": 10,
|
|
|
|
|
|
"text-font": ["Noto Sans Regular"],
|
|
|
|
|
|
"symbol-placement": "line",
|
|
|
|
|
|
"text-anchor": "center",
|
|
|
|
|
|
"symbol-spacing": 400,
|
|
|
|
|
|
"text-max-angle": 30,
|
|
|
|
|
|
"text-allow-overlap": false,
|
|
|
|
|
|
},
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
"text-color": isDark ? "#98b8d0" : "#205080",
|
|
|
|
|
|
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff",
|
|
|
|
|
|
"text-halo-width": 1.5,
|
|
|
|
|
|
"text-opacity": 0.85,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Remove TEST contour layers + source */
|
|
|
|
|
|
function removeContoursTest(map) {
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
if (map.getLayer(CONTOUR_TEST_LABEL)) map.removeLayer(CONTOUR_TEST_LABEL)
|
|
|
|
|
|
if (map.getLayer(CONTOUR_TEST_INDEX)) map.removeLayer(CONTOUR_TEST_INDEX)
|
|
|
|
|
|
if (map.getLayer(CONTOUR_TEST_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_INTERMEDIATE)
|
|
|
|
|
|
if (map.getLayer(CONTOUR_TEST_MINOR)) map.removeLayer(CONTOUR_TEST_MINOR)
|
|
|
|
|
|
if (map.getSource(CONTOUR_TEST_SOURCE)) map.removeSource(CONTOUR_TEST_SOURCE)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 04:03:52 +00:00
|
|
|
|
/** Add TEST 10ft topographic contour overlay (green color scheme) */
|
|
|
|
|
|
function addContoursTest10ft(map) {
|
|
|
|
|
|
if (!map || map.getSource(CONTOUR_TEST_10FT_SOURCE)) return
|
|
|
|
|
|
|
|
|
|
|
|
map.addSource(CONTOUR_TEST_10FT_SOURCE, {
|
|
|
|
|
|
type: "vector",
|
|
|
|
|
|
url: "pmtiles:///tiles/contours-test-10ft.pmtiles",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
let beforeId = undefined
|
|
|
|
|
|
for (const layer of map.getStyle().layers) {
|
|
|
|
|
|
if (layer.type === "symbol") {
|
|
|
|
|
|
beforeId = layer.id
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isDark = document.documentElement.getAttribute("data-theme") === "dark"
|
|
|
|
|
|
const opMod = isDark ? 0.8 : 1.0
|
|
|
|
|
|
|
|
|
|
|
|
// Minor contours (10ft) — green scheme
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_TEST_10FT_MINOR,
|
|
|
|
|
|
type: "line",
|
|
|
|
|
|
source: CONTOUR_TEST_10FT_SOURCE,
|
|
|
|
|
|
"source-layer": "contours",
|
|
|
|
|
|
minzoom: 11,
|
|
|
|
|
|
filter: ["==", ["get", "tier"], "minor"],
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
"line-color": "#3a7c4f",
|
|
|
|
|
|
"line-opacity": 0.4 * opMod,
|
|
|
|
|
|
"line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.5, 14, 1.0],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Intermediate contours (50ft) — green scheme
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_TEST_10FT_INTERMEDIATE,
|
|
|
|
|
|
type: "line",
|
|
|
|
|
|
source: CONTOUR_TEST_10FT_SOURCE,
|
|
|
|
|
|
"source-layer": "contours",
|
|
|
|
|
|
minzoom: 8,
|
|
|
|
|
|
filter: ["==", ["get", "tier"], "intermediate"],
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
"line-color": "#3a7c4f",
|
|
|
|
|
|
"line-opacity": 0.7 * opMod,
|
|
|
|
|
|
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 14, 1.2],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Index contours (250ft) — darker green
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_TEST_10FT_INDEX,
|
|
|
|
|
|
type: "line",
|
|
|
|
|
|
source: CONTOUR_TEST_10FT_SOURCE,
|
|
|
|
|
|
"source-layer": "contours",
|
|
|
|
|
|
minzoom: 4,
|
|
|
|
|
|
filter: ["==", ["get", "tier"], "index"],
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
"line-color": "#2a5c3a",
|
|
|
|
|
|
"line-opacity": 0.9 * opMod,
|
|
|
|
|
|
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 1.2, 14, 1.8],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, beforeId)
|
|
|
|
|
|
|
|
|
|
|
|
// Elevation labels on index contours (z12+)
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: CONTOUR_TEST_10FT_LABEL,
|
|
|
|
|
|
type: "symbol",
|
|
|
|
|
|
source: CONTOUR_TEST_10FT_SOURCE,
|
|
|
|
|
|
"source-layer": "contours",
|
|
|
|
|
|
minzoom: 12,
|
|
|
|
|
|
filter: ["==", ["get", "tier"], "index"],
|
|
|
|
|
|
layout: {
|
|
|
|
|
|
"text-field": ["concat", ["to-string", ["get", "elevation_ft"]], "'"],
|
|
|
|
|
|
"text-size": 10,
|
|
|
|
|
|
"text-font": ["Noto Sans Regular"],
|
|
|
|
|
|
"symbol-placement": "line",
|
|
|
|
|
|
"text-anchor": "center",
|
|
|
|
|
|
"symbol-spacing": 400,
|
|
|
|
|
|
"text-max-angle": 30,
|
|
|
|
|
|
"text-allow-overlap": false,
|
|
|
|
|
|
},
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
"text-color": isDark ? "#98c0a8" : "#2a4030",
|
|
|
|
|
|
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff",
|
|
|
|
|
|
"text-halo-width": 1.5,
|
|
|
|
|
|
"text-opacity": 0.85,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Remove test 10ft contour layers + source */
|
|
|
|
|
|
function removeContoursTest10ft(map) {
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
if (map.getLayer(CONTOUR_TEST_10FT_LABEL)) map.removeLayer(CONTOUR_TEST_10FT_LABEL)
|
|
|
|
|
|
if (map.getLayer(CONTOUR_TEST_10FT_INDEX)) map.removeLayer(CONTOUR_TEST_10FT_INDEX)
|
|
|
|
|
|
if (map.getLayer(CONTOUR_TEST_10FT_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_10FT_INTERMEDIATE)
|
|
|
|
|
|
if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR)
|
|
|
|
|
|
if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
const MapView = forwardRef(function MapView(_, ref) {
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
const mapRef = useRef(null)
|
|
|
|
|
|
const mapInstance = useRef(null)
|
|
|
|
|
|
const markersRef = useRef([])
|
|
|
|
|
|
const popupRef = useRef(null)
|
2026-04-20 20:59:18 +00:00
|
|
|
|
const gpsMarkerRef = useRef(null)
|
|
|
|
|
|
const previewMarkerRef = useRef(null)
|
|
|
|
|
|
const watchIdRef = useRef(null)
|
|
|
|
|
|
const currentThemeRef = useRef('dark')
|
2026-04-21 00:52:20 +00:00
|
|
|
|
// Track which overlay layers are currently active (for theme swap re-add)
|
2026-04-26 04:03:52 +00:00
|
|
|
|
const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false })
|
2026-04-20 21:27:07 +00:00
|
|
|
|
// Flag to suppress map-click when a stop pin was clicked
|
|
|
|
|
|
const pinClickedRef = useRef(false)
|
2026-04-26 08:15:09 +00:00
|
|
|
|
const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
|
|
|
|
|
|
const stops = useStore((s) => s.stops)
|
|
|
|
|
|
const route = useStore((s) => s.route)
|
2026-04-20 20:59:18 +00:00
|
|
|
|
const theme = useStore((s) => s.theme)
|
|
|
|
|
|
const selectedPlace = useStore((s) => s.selectedPlace)
|
2026-04-26 07:17:33 +00:00
|
|
|
|
const clickMarker = useStore((s) => s.clickMarker)
|
|
|
|
|
|
const setClickMarker = useStore((s) => s.setClickMarker)
|
|
|
|
|
|
const clearClickMarker = useStore((s) => s.clearClickMarker)
|
2026-04-20 20:59:18 +00:00
|
|
|
|
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
|
|
|
|
|
const geoPermission = useStore((s) => s.geoPermission)
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
const setSheetState = useStore((s) => s.setSheetState)
|
2026-04-26 04:03:52 +00:00
|
|
|
|
const setMapCenter = useStore((s) => s.setMapCenter)
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
|
2026-04-26 00:03:37 +00:00
|
|
|
|
// Zoom level indicator state
|
|
|
|
|
|
const [zoomLevel, setZoomLevel] = useState(10)
|
|
|
|
|
|
|
2026-04-26 05:42:48 +00:00
|
|
|
|
// Radial menu state
|
|
|
|
|
|
const [radialMenu, setRadialMenu] = useState({
|
|
|
|
|
|
open: false,
|
|
|
|
|
|
x: 0,
|
|
|
|
|
|
y: 0,
|
|
|
|
|
|
lat: 0,
|
|
|
|
|
|
lon: 0,
|
|
|
|
|
|
centerLabel: null,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
// Expose map methods to parent
|
2026-04-26 05:42:48 +00:00
|
|
|
|
// Radial menu wedges configuration
|
|
|
|
|
|
const radialWedges = [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'drop-pin',
|
|
|
|
|
|
label: 'Drop pin',
|
|
|
|
|
|
icon: MapPin,
|
|
|
|
|
|
onSelect: () => toast('Drop pin coming soon', { icon: '📍' }),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'directions-to',
|
|
|
|
|
|
label: 'To here',
|
|
|
|
|
|
icon: ArrowDownLeft,
|
|
|
|
|
|
onSelect: () => toast('Directions to here coming soon', { icon: '🧭' }),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'save-place',
|
|
|
|
|
|
label: 'Save',
|
|
|
|
|
|
icon: Star,
|
|
|
|
|
|
requiresAuth: true,
|
|
|
|
|
|
onSelect: () => toast('Save place coming soon', { icon: '⭐' }),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'add-stop',
|
|
|
|
|
|
label: 'Add stop',
|
|
|
|
|
|
icon: Plus,
|
|
|
|
|
|
onSelect: () => toast('Add stop coming soon', { icon: '➕' }),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'directions-from',
|
|
|
|
|
|
label: 'From here',
|
|
|
|
|
|
icon: ArrowUpRight,
|
|
|
|
|
|
onSelect: () => toast('Directions from here coming soon', { icon: '🧭' }),
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
// Context menu trigger handler
|
|
|
|
|
|
const handleContextMenuTrigger = ({ x, y }) => {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map || !mapRef.current) return
|
|
|
|
|
|
|
|
|
|
|
|
// Convert screen coords to lat/lon
|
|
|
|
|
|
const rect = mapRef.current.getBoundingClientRect()
|
|
|
|
|
|
const lngLat = map.unproject([x - rect.left, y - rect.top])
|
|
|
|
|
|
|
|
|
|
|
|
setRadialMenu({
|
|
|
|
|
|
open: true,
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
lat: lngLat.lat,
|
|
|
|
|
|
lon: lngLat.lng,
|
|
|
|
|
|
centerLabel: null,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Async reverse geocode for center label
|
|
|
|
|
|
fetchReverse(lngLat.lat, lngLat.lng).then((place) => {
|
|
|
|
|
|
if (place) {
|
|
|
|
|
|
setRadialMenu((m) => {
|
|
|
|
|
|
if (m.open && Math.abs(m.lat - lngLat.lat) < 0.00001) {
|
|
|
|
|
|
return { ...m, centerLabel: place.name }
|
|
|
|
|
|
}
|
|
|
|
|
|
return m
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Context menu hook
|
|
|
|
|
|
const contextMenuHandlers = useContextMenu(handleContextMenuTrigger)
|
|
|
|
|
|
|
|
|
|
|
|
useImperativeHandle(ref, () => ({
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
flyTo(lat, lon, zoom = 14) {
|
|
|
|
|
|
mapInstance.current?.flyTo({ center: [lon, lat], zoom })
|
|
|
|
|
|
},
|
|
|
|
|
|
getMap() {
|
|
|
|
|
|
return mapInstance.current
|
|
|
|
|
|
},
|
2026-04-21 00:52:20 +00:00
|
|
|
|
addHillshadeLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
addHillshade(map)
|
|
|
|
|
|
activeLayersRef.current.hillshade = true
|
|
|
|
|
|
},
|
|
|
|
|
|
removeHillshadeLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
removeHillshade(map)
|
|
|
|
|
|
activeLayersRef.current.hillshade = false
|
|
|
|
|
|
},
|
|
|
|
|
|
addTrafficLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
addTraffic(map)
|
|
|
|
|
|
activeLayersRef.current.traffic = true
|
|
|
|
|
|
},
|
|
|
|
|
|
removeTrafficLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
removeTraffic(map)
|
|
|
|
|
|
activeLayersRef.current.traffic = false
|
|
|
|
|
|
},
|
2026-04-22 18:52:53 +00:00
|
|
|
|
addPublicLandsLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
addPublicLands(map)
|
|
|
|
|
|
activeLayersRef.current.publicLands = true
|
|
|
|
|
|
},
|
|
|
|
|
|
removePublicLandsLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
removePublicLands(map)
|
|
|
|
|
|
activeLayersRef.current.publicLands = false
|
|
|
|
|
|
},
|
2026-04-24 00:44:20 +00:00
|
|
|
|
addContoursLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
addContours(map)
|
|
|
|
|
|
activeLayersRef.current.contours = true
|
|
|
|
|
|
},
|
|
|
|
|
|
removeContoursLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
removeContours(map)
|
|
|
|
|
|
activeLayersRef.current.contours = false
|
|
|
|
|
|
},
|
2026-04-26 00:03:37 +00:00
|
|
|
|
addContoursTestLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
addContoursTest(map)
|
|
|
|
|
|
activeLayersRef.current.contoursTest = true
|
|
|
|
|
|
},
|
|
|
|
|
|
removeContoursTestLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
removeContoursTest(map)
|
|
|
|
|
|
activeLayersRef.current.contoursTest = false
|
|
|
|
|
|
},
|
2026-04-26 04:03:52 +00:00
|
|
|
|
addContoursTest10ftLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
addContoursTest10ft(map)
|
|
|
|
|
|
activeLayersRef.current.contoursTest10ft = true
|
|
|
|
|
|
},
|
|
|
|
|
|
removeContoursTest10ftLayer() {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
removeContoursTest10ft(map)
|
|
|
|
|
|
activeLayersRef.current.contoursTest10ft = false
|
|
|
|
|
|
},
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize map
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const protocol = new Protocol()
|
|
|
|
|
|
maplibregl.addProtocol('pmtiles', protocol.tile)
|
|
|
|
|
|
|
2026-04-20 23:36:02 +00:00
|
|
|
|
const config = getConfig()
|
|
|
|
|
|
const DEFAULT_CENTER = config?.defaults?.center
|
|
|
|
|
|
? [config.defaults.center[1], config.defaults.center[0]] // config is [lat,lon], MapLibre wants [lon,lat]
|
|
|
|
|
|
: [-114.6066, 42.5736]
|
|
|
|
|
|
const DEFAULT_ZOOM = config?.defaults?.zoom || 10
|
2026-04-20 18:43:36 +00:00
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
const initialTheme = document.documentElement.getAttribute('data-theme') || 'dark'
|
|
|
|
|
|
currentThemeRef.current = initialTheme
|
|
|
|
|
|
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
const map = new maplibregl.Map({
|
|
|
|
|
|
container: mapRef.current,
|
2026-04-20 20:59:18 +00:00
|
|
|
|
style: buildStyle(initialTheme),
|
2026-04-20 18:43:36 +00:00
|
|
|
|
center: DEFAULT_CENTER,
|
|
|
|
|
|
zoom: DEFAULT_ZOOM,
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
2026-04-20 18:43:36 +00:00
|
|
|
|
|
2026-04-26 07:17:33 +00:00
|
|
|
|
// Map click — two-click selection model
|
2026-04-20 21:27:07 +00:00
|
|
|
|
map.on('click', (e) => {
|
2026-04-26 07:17:33 +00:00
|
|
|
|
// If a stop pin was just clicked, skip
|
2026-04-20 21:27:07 +00:00
|
|
|
|
if (pinClickedRef.current) {
|
|
|
|
|
|
pinClickedRef.current = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 07:17:33 +00:00
|
|
|
|
const store = useStore.getState()
|
|
|
|
|
|
const marker = store.clickMarker
|
|
|
|
|
|
|
|
|
|
|
|
if (marker) {
|
|
|
|
|
|
// State B: marker present — check if click is inside the circle
|
|
|
|
|
|
const markerScreen = map.project([marker.lon, marker.lat])
|
|
|
|
|
|
const dx = e.point.x - markerScreen.x
|
|
|
|
|
|
const dy = e.point.y - markerScreen.y
|
|
|
|
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
|
|
|
|
|
|
|
|
|
|
if (dist <= marker.circleRadiusPx) {
|
|
|
|
|
|
// Inside circle → open radial at marker location
|
|
|
|
|
|
const rect = mapRef.current?.getBoundingClientRect()
|
|
|
|
|
|
const screenX = rect ? markerScreen.x + rect.left : markerScreen.x
|
|
|
|
|
|
const screenY = rect ? markerScreen.y + rect.top : markerScreen.y
|
|
|
|
|
|
|
|
|
|
|
|
setRadialMenu({
|
|
|
|
|
|
open: true,
|
|
|
|
|
|
x: screenX,
|
|
|
|
|
|
y: screenY,
|
|
|
|
|
|
lat: marker.lat,
|
|
|
|
|
|
lon: marker.lon,
|
|
|
|
|
|
centerLabel: store.selectedPlace?.name || null,
|
|
|
|
|
|
})
|
2026-04-20 21:27:07 +00:00
|
|
|
|
|
2026-04-26 07:17:33 +00:00
|
|
|
|
// Fetch reverse geocode for center label if not already loaded
|
|
|
|
|
|
if (!store.selectedPlace?.name || store.selectedPlace.name === 'Dropped pin') {
|
|
|
|
|
|
fetchReverse(marker.lat, marker.lon).then((place) => {
|
|
|
|
|
|
if (place) {
|
|
|
|
|
|
setRadialMenu((m) => {
|
|
|
|
|
|
if (m.open && Math.abs(m.lat - marker.lat) < 0.00001) {
|
|
|
|
|
|
return { ...m, centerLabel: place.name }
|
|
|
|
|
|
}
|
|
|
|
|
|
return m
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Outside circle → deselect, no new selection
|
|
|
|
|
|
store.clearClickMarker()
|
|
|
|
|
|
store.clearSelectedPlace()
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// State A: nothing selected → select
|
|
|
|
|
|
if (window.innerWidth < 768) setSheetState('collapsed')
|
2026-04-20 21:27:07 +00:00
|
|
|
|
|
2026-04-26 07:17:33 +00:00
|
|
|
|
const { lng, lat } = e.lngLat
|
|
|
|
|
|
const MARKER_RADIUS_PX = 14 // half of 28px preview marker
|
2026-04-20 21:27:07 +00:00
|
|
|
|
|
2026-04-26 07:49:08 +00:00
|
|
|
|
// Query rendered features at click point (label/POI priority)
|
|
|
|
|
|
const labelLayers = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country']
|
|
|
|
|
|
const features = map.queryRenderedFeatures(e.point, { layers: labelLayers })
|
|
|
|
|
|
|
|
|
|
|
|
// Find first feature with a name (respects layer order = priority)
|
|
|
|
|
|
const labelFeature = features.find(f => f.properties?.name)
|
|
|
|
|
|
|
2026-04-26 08:15:09 +00:00
|
|
|
|
// Clear previous feature highlight
|
|
|
|
|
|
if (highlightedFeatureRef.current) {
|
|
|
|
|
|
const { source, sourceLayer, id } = highlightedFeatureRef.current
|
|
|
|
|
|
try {
|
|
|
|
|
|
map.setFeatureState({ source, sourceLayer, id }, { selected: false })
|
|
|
|
|
|
} catch (e) { /* ignore if layer removed */ }
|
|
|
|
|
|
highlightedFeatureRef.current = null
|
|
|
|
|
|
}
|
2026-04-26 07:17:33 +00:00
|
|
|
|
|
2026-04-26 08:15:09 +00:00
|
|
|
|
if (labelFeature) {
|
|
|
|
|
|
// Clicked a labeled feature — snap to geometry and highlight
|
2026-04-26 07:49:08 +00:00
|
|
|
|
const props = labelFeature.properties
|
2026-04-26 08:15:09 +00:00
|
|
|
|
const geom = labelFeature.geometry
|
|
|
|
|
|
|
|
|
|
|
|
// Get feature coordinates (Point geometry)
|
|
|
|
|
|
let featureLat = lat
|
|
|
|
|
|
let featureLon = lng
|
|
|
|
|
|
if (geom && geom.type === 'Point' && geom.coordinates) {
|
|
|
|
|
|
featureLon = geom.coordinates[0]
|
|
|
|
|
|
featureLat = geom.coordinates[1]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Apply feature state highlight
|
|
|
|
|
|
const featureId = labelFeature.id ?? props.mvt_id
|
|
|
|
|
|
const sourceLayer = labelFeature.sourceLayer
|
|
|
|
|
|
const source = labelFeature.source
|
|
|
|
|
|
if (featureId != null && source) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
map.setFeatureState({ source, sourceLayer, id: featureId }, { selected: true })
|
|
|
|
|
|
highlightedFeatureRef.current = { source, sourceLayer, id: featureId }
|
|
|
|
|
|
} catch (e) { console.warn('setFeatureState error:', e) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For feature clicks, don't show pin marker
|
|
|
|
|
|
store.clearClickMarker()
|
|
|
|
|
|
|
2026-04-26 07:49:08 +00:00
|
|
|
|
store.setSelectedPlace({
|
2026-04-26 08:15:09 +00:00
|
|
|
|
lat: featureLat,
|
|
|
|
|
|
lon: featureLon,
|
2026-04-26 07:49:08 +00:00
|
|
|
|
name: props.name || 'Unknown',
|
|
|
|
|
|
address: null,
|
|
|
|
|
|
type: props.kind_detail || props.kind || null,
|
|
|
|
|
|
source: 'basemap_label',
|
|
|
|
|
|
matchCode: null,
|
2026-04-26 08:15:09 +00:00
|
|
|
|
mode: 'feature',
|
|
|
|
|
|
featureId: featureId,
|
|
|
|
|
|
featureLayer: labelFeature.layer?.id || null,
|
|
|
|
|
|
wikidata: props.wikidata || null,
|
2026-04-26 07:49:08 +00:00
|
|
|
|
raw: {
|
|
|
|
|
|
wikidata: props.wikidata || null,
|
|
|
|
|
|
population: props.population || null,
|
|
|
|
|
|
kind: props.kind || null,
|
|
|
|
|
|
kind_detail: props.kind_detail || null,
|
|
|
|
|
|
elevation: props.elevation || null,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
2026-04-26 08:15:09 +00:00
|
|
|
|
// No labeled feature — show reticle at click point
|
|
|
|
|
|
store.setClickMarker({
|
|
|
|
|
|
lat,
|
|
|
|
|
|
lon: lng,
|
|
|
|
|
|
circleRadiusPx: MARKER_RADIUS_PX,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-26 07:49:08 +00:00
|
|
|
|
store.setSelectedPlace({
|
|
|
|
|
|
lat,
|
|
|
|
|
|
lon: lng,
|
|
|
|
|
|
name: 'Dropped pin',
|
|
|
|
|
|
address: null,
|
|
|
|
|
|
type: null,
|
|
|
|
|
|
source: 'map_click',
|
|
|
|
|
|
matchCode: null,
|
2026-04-26 08:15:09 +00:00
|
|
|
|
mode: 'reticle',
|
2026-04-26 07:49:08 +00:00
|
|
|
|
raw: {},
|
|
|
|
|
|
})
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
|
2026-04-26 07:49:08 +00:00
|
|
|
|
// Reverse geocode in background
|
|
|
|
|
|
fetchReverse(lat, lng).then((place) => {
|
|
|
|
|
|
if (!place) return
|
|
|
|
|
|
const current = useStore.getState().selectedPlace
|
|
|
|
|
|
if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lng) < 0.00001) {
|
|
|
|
|
|
useStore.getState().setSelectedPlace({
|
|
|
|
|
|
...place,
|
|
|
|
|
|
lat,
|
|
|
|
|
|
lon: lng,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-04-26 07:17:33 +00:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-04-26 21:43:58 +00:00
|
|
|
|
// Initialize mapCenter immediately when map loads (Fix 1: search viewport)
|
|
|
|
|
|
map.once('load', () => {
|
|
|
|
|
|
const center = map.getCenter()
|
|
|
|
|
|
const zoom = map.getZoom()
|
|
|
|
|
|
setMapCenter({ lat: center.lat, lon: center.lng, zoom })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
map.on('load', () => {
|
|
|
|
|
|
map.addSource(ROUTE_SOURCE, {
|
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
|
data: { type: 'FeatureCollection', features: [] },
|
|
|
|
|
|
})
|
2026-04-21 00:52:20 +00:00
|
|
|
|
|
2026-04-26 08:26:56 +00:00
|
|
|
|
// Boundary polygon source for selected places
|
|
|
|
|
|
map.addSource(BOUNDARY_SOURCE, {
|
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
|
data: { type: 'FeatureCollection', features: [] },
|
|
|
|
|
|
})
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: BOUNDARY_LAYER,
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
source: BOUNDARY_SOURCE,
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'line-color': 'var(--accent)',
|
|
|
|
|
|
'line-width': 2,
|
|
|
|
|
|
'line-opacity': 0.7,
|
|
|
|
|
|
'line-dasharray': [3, 2],
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-21 00:52:20 +00:00
|
|
|
|
// Restore overlay layers from localStorage prefs
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = localStorage.getItem('navi-layer-prefs')
|
|
|
|
|
|
if (raw) {
|
|
|
|
|
|
const prefs = JSON.parse(raw)
|
|
|
|
|
|
if (prefs.hillshade && hasFeature('has_hillshade')) {
|
|
|
|
|
|
addHillshade(map)
|
|
|
|
|
|
activeLayersRef.current.hillshade = true
|
|
|
|
|
|
}
|
|
|
|
|
|
if (prefs.traffic && hasFeature('has_traffic_overlay')) {
|
|
|
|
|
|
addTraffic(map)
|
|
|
|
|
|
activeLayersRef.current.traffic = true
|
|
|
|
|
|
}
|
2026-04-22 18:52:53 +00:00
|
|
|
|
if (prefs.publicLands && hasFeature('has_public_lands_layer')) {
|
|
|
|
|
|
addPublicLands(map)
|
|
|
|
|
|
activeLayersRef.current.publicLands = true
|
|
|
|
|
|
}
|
2026-04-24 00:44:20 +00:00
|
|
|
|
if (prefs.contours && hasFeature('has_contours')) {
|
|
|
|
|
|
addContours(map)
|
|
|
|
|
|
activeLayersRef.current.contours = true
|
|
|
|
|
|
}
|
2026-04-21 00:52:20 +00:00
|
|
|
|
} else if (hasFeature('has_hillshade')) {
|
|
|
|
|
|
// Default: hillshade ON if available
|
|
|
|
|
|
addHillshade(map)
|
|
|
|
|
|
activeLayersRef.current.hillshade = true
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
2026-04-26 07:28:53 +00:00
|
|
|
|
|
|
|
|
|
|
// POI/label hover affordance — cursor pointer
|
|
|
|
|
|
const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace']
|
|
|
|
|
|
|
|
|
|
|
|
interactiveLayers.forEach(layerId => {
|
|
|
|
|
|
map.on('mouseenter', layerId, () => {
|
|
|
|
|
|
map.getCanvas().style.cursor = 'pointer'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
map.on('mouseleave', layerId, () => {
|
|
|
|
|
|
map.getCanvas().style.cursor = ''
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
mapInstance.current = map
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
2026-04-20 20:59:18 +00:00
|
|
|
|
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
|
|
|
|
|
|
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
maplibregl.removeProtocol('pmtiles')
|
|
|
|
|
|
map.remove()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [setSheetState])
|
|
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
/** Create or update the GPS chevron/dot marker */
|
|
|
|
|
|
function createOrUpdateGpsMarker(map, lat, lon, heading) {
|
|
|
|
|
|
if (!gpsMarkerRef.current) {
|
|
|
|
|
|
const el = document.createElement('div')
|
|
|
|
|
|
if (heading != null && !isNaN(heading)) {
|
|
|
|
|
|
el.className = 'navi-chevron'
|
|
|
|
|
|
el.innerHTML = CHEVRON_SVG
|
|
|
|
|
|
el.style.transform = `rotate(${heading}deg)`
|
|
|
|
|
|
} else {
|
|
|
|
|
|
el.className = 'navi-gps-dot'
|
|
|
|
|
|
}
|
|
|
|
|
|
gpsMarkerRef.current = new maplibregl.Marker({ element: el })
|
|
|
|
|
|
.setLngLat([lon, lat])
|
|
|
|
|
|
.addTo(map)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
gpsMarkerRef.current.setLngLat([lon, lat])
|
|
|
|
|
|
const el = gpsMarkerRef.current.getElement()
|
|
|
|
|
|
if (heading != null && !isNaN(heading)) {
|
|
|
|
|
|
if (!el.classList.contains('navi-chevron')) {
|
|
|
|
|
|
el.className = 'navi-chevron'
|
|
|
|
|
|
el.innerHTML = CHEVRON_SVG
|
|
|
|
|
|
}
|
|
|
|
|
|
el.style.transform = `rotate(${heading}deg)`
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (!el.classList.contains('navi-gps-dot')) {
|
|
|
|
|
|
el.className = 'navi-gps-dot'
|
|
|
|
|
|
el.innerHTML = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 03:27:21 +00:00
|
|
|
|
// React to permission changes from LocateButton (when user grants after initial denial)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map || geoPermission !== 'granted') return
|
|
|
|
|
|
|
|
|
|
|
|
// If marker already exists, watchPosition is already running — nothing to do
|
|
|
|
|
|
if (gpsMarkerRef.current) return
|
|
|
|
|
|
|
|
|
|
|
|
// Permission was just granted (likely from LocateButton) — create marker + start tracking
|
|
|
|
|
|
const loc = useStore.getState().userLocation
|
|
|
|
|
|
if (loc) {
|
|
|
|
|
|
createOrUpdateGpsMarker(map, loc.lat, loc.lon, null)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!watchIdRef.current) {
|
|
|
|
|
|
watchIdRef.current = navigator.geolocation.watchPosition(
|
|
|
|
|
|
(pos) => {
|
|
|
|
|
|
const { latitude, longitude, heading } = pos.coords
|
|
|
|
|
|
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
|
|
|
|
|
|
createOrUpdateGpsMarker(map, latitude, longitude, heading)
|
|
|
|
|
|
},
|
|
|
|
|
|
() => {},
|
|
|
|
|
|
{ enableHighAccuracy: true, maximumAge: 5000 }
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [geoPermission])
|
|
|
|
|
|
|
|
|
|
|
|
// Swap map theme when store.theme changes
|
2026-04-20 20:59:18 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map || currentThemeRef.current === theme) return
|
|
|
|
|
|
|
|
|
|
|
|
currentThemeRef.current = theme
|
|
|
|
|
|
const center = map.getCenter()
|
|
|
|
|
|
const zoom = map.getZoom()
|
|
|
|
|
|
const bearing = map.getBearing()
|
|
|
|
|
|
const pitch = map.getPitch()
|
|
|
|
|
|
|
|
|
|
|
|
map.setStyle(buildStyle(theme), { diff: false })
|
|
|
|
|
|
|
2026-04-21 00:52:20 +00:00
|
|
|
|
// Re-add sources/layers after style swap
|
2026-04-20 20:59:18 +00:00
|
|
|
|
map.once('style.load', () => {
|
|
|
|
|
|
map.addSource(ROUTE_SOURCE, {
|
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
|
data: { type: 'FeatureCollection', features: [] },
|
|
|
|
|
|
})
|
2026-04-21 00:52:20 +00:00
|
|
|
|
|
2026-04-26 08:26:56 +00:00
|
|
|
|
// Boundary polygon source
|
|
|
|
|
|
map.addSource(BOUNDARY_SOURCE, {
|
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
|
data: { type: 'FeatureCollection', features: [] },
|
|
|
|
|
|
})
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: BOUNDARY_LAYER,
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
source: BOUNDARY_SOURCE,
|
|
|
|
|
|
paint: {
|
|
|
|
|
|
'line-color': 'var(--accent)',
|
|
|
|
|
|
'line-width': 2,
|
|
|
|
|
|
'line-opacity': 0.7,
|
|
|
|
|
|
'line-dasharray': [3, 2],
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-21 00:52:20 +00:00
|
|
|
|
// Re-add active overlay layers
|
|
|
|
|
|
if (activeLayersRef.current.hillshade) addHillshade(map)
|
|
|
|
|
|
if (activeLayersRef.current.traffic) addTraffic(map)
|
2026-04-22 18:52:53 +00:00
|
|
|
|
if (activeLayersRef.current.publicLands) addPublicLands(map)
|
2026-04-24 00:44:20 +00:00
|
|
|
|
if (activeLayersRef.current.contours) addContours(map)
|
2026-04-21 00:52:20 +00:00
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
// Restore view
|
|
|
|
|
|
map.jumpTo({ center, zoom, bearing, pitch })
|
|
|
|
|
|
// Re-render route if exists
|
|
|
|
|
|
const currentRoute = useStore.getState().route
|
|
|
|
|
|
if (currentRoute) updateRoute(map, currentRoute)
|
|
|
|
|
|
})
|
|
|
|
|
|
}, [theme])
|
|
|
|
|
|
|
|
|
|
|
|
// Preview pin for selected place
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
|
|
|
|
|
|
// Remove old preview marker
|
|
|
|
|
|
if (previewMarkerRef.current) {
|
|
|
|
|
|
previewMarkerRef.current.remove()
|
|
|
|
|
|
previewMarkerRef.current = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!selectedPlace) return
|
|
|
|
|
|
|
2026-04-20 21:27:07 +00:00
|
|
|
|
// Only fly to place if it came from search (not map-click which already centered)
|
2026-04-26 08:15:09 +00:00
|
|
|
|
if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') {
|
2026-04-20 21:27:07 +00:00
|
|
|
|
map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
|
|
|
|
|
|
}
|
2026-04-20 20:59:18 +00:00
|
|
|
|
|
2026-04-26 08:15:09 +00:00
|
|
|
|
// Different visual feedback based on mode
|
|
|
|
|
|
const isFeatureMode = selectedPlace.mode === 'feature'
|
|
|
|
|
|
|
|
|
|
|
|
// Create marker element
|
2026-04-20 20:59:18 +00:00
|
|
|
|
const el = document.createElement('div')
|
2026-04-26 08:15:09 +00:00
|
|
|
|
if (isFeatureMode) {
|
|
|
|
|
|
// Feature mode: subtle ring indicator
|
|
|
|
|
|
el.className = 'navi-feature-highlight'
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Reticle mode: pin with center dot
|
|
|
|
|
|
el.className = 'navi-pin-preview'
|
|
|
|
|
|
const dot = document.createElement('div')
|
|
|
|
|
|
dot.className = 'navi-pin-center-dot'
|
|
|
|
|
|
el.appendChild(dot)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
previewMarkerRef.current = new maplibregl.Marker({ element: el })
|
|
|
|
|
|
.setLngLat([selectedPlace.lon, selectedPlace.lat])
|
|
|
|
|
|
.addTo(map)
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (previewMarkerRef.current) {
|
|
|
|
|
|
previewMarkerRef.current.remove()
|
|
|
|
|
|
previewMarkerRef.current = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedPlace])
|
|
|
|
|
|
|
2026-04-26 08:26:56 +00:00
|
|
|
|
// Boundary polygon and zoom-to-feature
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map || !map.isStyleLoaded()) return
|
|
|
|
|
|
|
|
|
|
|
|
const source = map.getSource(BOUNDARY_SOURCE)
|
|
|
|
|
|
if (!source) return
|
|
|
|
|
|
|
|
|
|
|
|
// Clear boundary if no place selected
|
|
|
|
|
|
if (!selectedPlace) {
|
|
|
|
|
|
source.setData({ type: 'FeatureCollection', features: [] })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get boundary from selectedPlace (may come from API response)
|
|
|
|
|
|
const boundary = selectedPlace.boundary || selectedPlace.raw?.boundary
|
|
|
|
|
|
|
|
|
|
|
|
// Update boundary layer
|
|
|
|
|
|
if (boundary && (boundary.type === 'Polygon' || boundary.type === 'MultiPolygon')) {
|
|
|
|
|
|
source.setData({
|
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
|
geometry: boundary,
|
|
|
|
|
|
properties: {},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Zoom to fit boundary
|
|
|
|
|
|
try {
|
|
|
|
|
|
const coords = boundary.type === 'Polygon'
|
|
|
|
|
|
? boundary.coordinates[0]
|
|
|
|
|
|
: boundary.coordinates.flat(1)
|
|
|
|
|
|
|
|
|
|
|
|
if (coords.length > 0) {
|
|
|
|
|
|
let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity
|
|
|
|
|
|
for (const [lng, lat] of coords) {
|
|
|
|
|
|
if (lng < minLng) minLng = lng
|
|
|
|
|
|
if (lng > maxLng) maxLng = lng
|
|
|
|
|
|
if (lat < minLat) minLat = lat
|
|
|
|
|
|
if (lat > maxLat) maxLat = lat
|
|
|
|
|
|
}
|
|
|
|
|
|
map.fitBounds([[minLng, minLat], [maxLng, maxLat]], {
|
|
|
|
|
|
padding: 50,
|
|
|
|
|
|
duration: 700,
|
|
|
|
|
|
maxZoom: 16,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('fitBounds error:', e)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// No boundary - clear the layer and zoom based on feature kind
|
|
|
|
|
|
source.setData({ type: 'FeatureCollection', features: [] })
|
|
|
|
|
|
|
|
|
|
|
|
// Only zoom for feature mode selections (not terrain clicks)
|
|
|
|
|
|
if (selectedPlace.mode === 'feature' && selectedPlace.source === 'basemap_label') {
|
|
|
|
|
|
const kind = selectedPlace.raw?.kind || selectedPlace.type || ''
|
|
|
|
|
|
let targetZoom = null
|
|
|
|
|
|
|
|
|
|
|
|
if (kind.includes('country')) targetZoom = 5
|
|
|
|
|
|
else if (kind.includes('region' ) || kind.includes('state')) targetZoom = 7
|
|
|
|
|
|
else if (kind.includes('locality' ) || kind.includes('city')) targetZoom = 11
|
|
|
|
|
|
else if (kind.includes('subplace' ) || kind.includes('neighbourhood') || kind.includes('neighborhood')) targetZoom = 13
|
|
|
|
|
|
else if (kind.includes('poi')) targetZoom = 16
|
|
|
|
|
|
|
|
|
|
|
|
// Only zoom in, never zoom out
|
|
|
|
|
|
if (targetZoom && map.getZoom() < targetZoom) {
|
|
|
|
|
|
map.flyTo({
|
|
|
|
|
|
center: [selectedPlace.lon, selectedPlace.lat],
|
|
|
|
|
|
zoom: targetZoom,
|
|
|
|
|
|
duration: 700,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedPlace])
|
|
|
|
|
|
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
// Update route polyline when route changes
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const map = mapInstance.current
|
2026-04-20 20:59:18 +00:00
|
|
|
|
if (!map) return
|
|
|
|
|
|
if (!map.isStyleLoaded()) {
|
|
|
|
|
|
const handler = () => updateRoute(map, route)
|
|
|
|
|
|
map.once('load', handler)
|
|
|
|
|
|
return () => map.off('load', handler)
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
}
|
2026-04-20 20:59:18 +00:00
|
|
|
|
updateRoute(map, route)
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
}, [route])
|
|
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
function updateRoute(map, routeData) {
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
if (!map) return
|
|
|
|
|
|
|
|
|
|
|
|
// Remove old route layers
|
|
|
|
|
|
const style = map.getStyle()
|
|
|
|
|
|
if (style) {
|
|
|
|
|
|
for (const layer of style.layers) {
|
|
|
|
|
|
if (layer.id.startsWith(ROUTE_LAYER_PREFIX)) {
|
|
|
|
|
|
map.removeLayer(layer.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
if (!routeData || !routeData.legs) {
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
if (map.getSource(ROUTE_SOURCE)) {
|
|
|
|
|
|
map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] })
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const features = []
|
2026-04-20 20:59:18 +00:00
|
|
|
|
for (let i = 0; i < routeData.legs.length; i++) {
|
|
|
|
|
|
const leg = routeData.legs[i]
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
if (!leg.shape) continue
|
|
|
|
|
|
const coords = decodePolyline(leg.shape, 6)
|
|
|
|
|
|
features.push({
|
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
|
properties: { legIndex: i },
|
|
|
|
|
|
geometry: { type: 'LineString', coordinates: coords },
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const source = map.getSource(ROUTE_SOURCE)
|
|
|
|
|
|
if (source) {
|
|
|
|
|
|
source.setData({ type: 'FeatureCollection', features })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
map.addSource(ROUTE_SOURCE, {
|
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
|
data: { type: 'FeatureCollection', features },
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
// Use CSS variable for route color (read computed value)
|
|
|
|
|
|
const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim()
|
|
|
|
|
|
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
for (let i = 0; i < features.length; i++) {
|
|
|
|
|
|
const layerId = `${ROUTE_LAYER_PREFIX}${i}`
|
|
|
|
|
|
if (!map.getLayer(layerId)) {
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
|
id: layerId,
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
source: ROUTE_SOURCE,
|
|
|
|
|
|
filter: ['==', ['get', 'legIndex'], i],
|
2026-04-20 20:59:18 +00:00
|
|
|
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
paint: {
|
2026-04-20 20:59:18 +00:00
|
|
|
|
'line-color': routeColor || '#7a9a6b',
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
'line-width': 5,
|
|
|
|
|
|
'line-opacity': 0.85,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fit bounds to route
|
|
|
|
|
|
if (features.length > 0) {
|
|
|
|
|
|
const allCoords = features.flatMap((f) => f.geometry.coordinates)
|
|
|
|
|
|
const bounds = allCoords.reduce(
|
|
|
|
|
|
(b, c) => b.extend(c),
|
|
|
|
|
|
new maplibregl.LngLatBounds(allCoords[0], allCoords[0])
|
|
|
|
|
|
)
|
2026-04-26 21:14:39 +00:00
|
|
|
|
// Single-panel: no floating detail
|
|
|
|
|
|
const leftPad = 420 // 360px panel + margin
|
2026-04-20 20:59:18 +00:00
|
|
|
|
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } })
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update stop markers when stops change
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
|
|
|
|
|
|
// Remove old markers
|
|
|
|
|
|
for (const m of markersRef.current) m.remove()
|
|
|
|
|
|
markersRef.current = []
|
|
|
|
|
|
if (popupRef.current) {
|
|
|
|
|
|
popupRef.current.remove()
|
|
|
|
|
|
popupRef.current = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
|
|
|
|
|
const indexOffset = hasGpsOrigin ? 1 : 0
|
|
|
|
|
|
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
stops.forEach((stop, i) => {
|
2026-04-20 20:59:18 +00:00
|
|
|
|
const displayIndex = i + indexOffset
|
|
|
|
|
|
const effectiveTotal = stops.length + indexOffset
|
|
|
|
|
|
|
|
|
|
|
|
let pinClass = 'navi-pin navi-pin--intermediate'
|
|
|
|
|
|
if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin'
|
|
|
|
|
|
else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination'
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
|
2026-04-20 20:59:18 +00:00
|
|
|
|
const label = String.fromCharCode(65 + Math.min(displayIndex, 25))
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
|
|
|
|
|
|
const el = document.createElement('div')
|
2026-04-20 20:59:18 +00:00
|
|
|
|
el.className = pinClass
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
el.textContent = label
|
|
|
|
|
|
|
|
|
|
|
|
el.addEventListener('click', (e) => {
|
|
|
|
|
|
e.stopPropagation()
|
2026-04-20 21:27:07 +00:00
|
|
|
|
// Flag so the map-level click handler doesn't fire
|
|
|
|
|
|
pinClickedRef.current = true
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
if (popupRef.current) popupRef.current.remove()
|
|
|
|
|
|
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
|
|
|
|
|
|
.setLngLat([stop.lon, stop.lat])
|
|
|
|
|
|
.setHTML(
|
2026-04-20 20:59:18 +00:00
|
|
|
|
`<div style="font-size:12px;max-width:200px">
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
<strong>${stop.name}</strong>
|
2026-04-20 20:59:18 +00:00
|
|
|
|
<br/><button id="remove-stop-${stop.id}" style="margin-top:4px;padding:2px 8px;background:var(--status-danger);border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px">Remove</button>
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
</div>`
|
|
|
|
|
|
)
|
|
|
|
|
|
.addTo(map)
|
|
|
|
|
|
|
|
|
|
|
|
popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => {
|
|
|
|
|
|
useStore.getState().removeStop(stop.id)
|
|
|
|
|
|
popup.remove()
|
|
|
|
|
|
})
|
|
|
|
|
|
popupRef.current = popup
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const marker = new maplibregl.Marker({ element: el })
|
|
|
|
|
|
.setLngLat([stop.lon, stop.lat])
|
|
|
|
|
|
.addTo(map)
|
|
|
|
|
|
|
|
|
|
|
|
markersRef.current.push(marker)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// If stops but no route yet, fit to stops
|
|
|
|
|
|
if (stops.length > 0 && !route) {
|
|
|
|
|
|
if (stops.length === 1) {
|
|
|
|
|
|
map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const bounds = stops.reduce(
|
|
|
|
|
|
(b, s) => b.extend([s.lon, s.lat]),
|
|
|
|
|
|
new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
|
|
|
|
|
|
)
|
2026-04-26 21:14:39 +00:00
|
|
|
|
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 420, right: 60 } })
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-20 20:59:18 +00:00
|
|
|
|
}, [stops, route, gpsOrigin, geoPermission])
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
|
2026-04-26 00:20:31 +00:00
|
|
|
|
// Track zoom level for indicator
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
|
|
|
|
|
|
const updateZoom = () => setZoomLevel(map.getZoom())
|
|
|
|
|
|
|
|
|
|
|
|
// Set initial zoom
|
|
|
|
|
|
if (map.loaded()) {
|
|
|
|
|
|
updateZoom()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
map.once("load", updateZoom)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Subscribe to zoom changes
|
|
|
|
|
|
map.on("zoom", updateZoom)
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
map.off("zoom", updateZoom)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
2026-04-26 00:03:37 +00:00
|
|
|
|
|
2026-04-26 04:03:52 +00:00
|
|
|
|
|
|
|
|
|
|
// Track map center for search viewport bias
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const map = mapInstance.current
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
|
|
|
|
|
|
const updateCenter = () => {
|
|
|
|
|
|
const center = map.getCenter()
|
|
|
|
|
|
const zoom = map.getZoom()
|
|
|
|
|
|
setMapCenter({ lat: center.lat, lon: center.lng, zoom })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set initial center
|
|
|
|
|
|
if (map.loaded()) {
|
|
|
|
|
|
updateCenter()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
map.once("load", updateCenter)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update on move end (not every frame)
|
|
|
|
|
|
map.on("moveend", updateCenter)
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
map.off("moveend", updateCenter)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [setMapCenter])
|
|
|
|
|
|
|
2026-04-26 00:03:37 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="relative w-full h-full">
|
2026-04-26 05:42:48 +00:00
|
|
|
|
<div ref={mapRef} className="w-full h-full" {...contextMenuHandlers} />
|
2026-04-26 00:03:37 +00:00
|
|
|
|
{/* Zoom level indicator - bottom-left corner */}
|
|
|
|
|
|
<div
|
2026-04-26 00:07:54 +00:00
|
|
|
|
className="absolute bottom-4 left-4 z-50 px-2 py-1 rounded-full text-xs font-mono pointer-events-none"
|
2026-04-26 00:03:37 +00:00
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
|
|
|
|
|
color: "white",
|
|
|
|
|
|
fontSize: "12px",
|
|
|
|
|
|
padding: "4px 8px",
|
|
|
|
|
|
borderRadius: "12px",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Z {zoomLevel.toFixed(1)}
|
|
|
|
|
|
</div>
|
2026-04-26 05:42:48 +00:00
|
|
|
|
{/* Radial context menu */}
|
|
|
|
|
|
<RadialMenu
|
|
|
|
|
|
open={radialMenu.open}
|
|
|
|
|
|
x={radialMenu.x}
|
|
|
|
|
|
y={radialMenu.y}
|
|
|
|
|
|
lat={radialMenu.lat}
|
|
|
|
|
|
lon={radialMenu.lon}
|
|
|
|
|
|
wedges={radialWedges}
|
|
|
|
|
|
centerLabel={radialMenu.centerLabel}
|
|
|
|
|
|
onDismiss={() => setRadialMenu((m) => ({ ...m, open: false }))}
|
|
|
|
|
|
/>
|
2026-04-26 00:03:37 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
feat: search, multi-stop routing, and route display
Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints
Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:50:53 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
export default MapView
|