fix: resolve 5 confirmed bugs from code review

- MapView.jsx: extract addBoundaryLayer function, use getComputedStyle
  for accent color (MapLibre rejects CSS vars in paint properties)
- PlaceCard.jsx: gate fetchNearbyContacts on auth.authenticated
- PlaceDetail.jsx: gate fetchNearbyContacts on auth.authenticated
- api.js: replace invalid timeout option with AbortSignal.timeout()
- RadialMenu.jsx: remove user-select from SVG style (Firefox rejects)
- Panel.jsx: add Cancel button for pending directions state
This commit is contained in:
Matt 2026-04-27 02:50:46 +00:00
commit a40f68fa26
39 changed files with 728 additions and 21085 deletions

View file

@ -25,7 +25,7 @@ export async function searchGeocode(query, limit = 6, signal) {
if (mapCenter?.zoom != null && Number.isFinite(mapCenter.zoom)) { if (mapCenter?.zoom != null && Number.isFinite(mapCenter.zoom)) {
params.set('zoom', String(Math.round(mapCenter.zoom))) params.set('zoom', String(Math.round(mapCenter.zoom)))
} }
const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal, timeout: 5000 }) const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal: signal ?? AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`) if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`)
return resp.json() return resp.json()
} }
@ -135,7 +135,7 @@ const REVERSE_URL = "/api/reverse"
export async function fetchReverse(lat, lon) { export async function fetchReverse(lat, lon) {
try { try {
const params = new URLSearchParams({ lat: String(lat), lon: String(lon) }) const params = new URLSearchParams({ lat: String(lat), lon: String(lon) })
const resp = await fetch(`${REVERSE_URL}?${params}`, { timeout: 5000 }) const resp = await fetch(`${REVERSE_URL}?${params}`, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) return null if (!resp.ok) return null
const data = await resp.json() const data = await resp.json()
if (!data.results || data.results.length === 0) return null if (!data.results || data.results.length === 0) return null

View file

@ -1,278 +0,0 @@
const GEOCODE_URL = '/api/geocode'
const VALHALLA_URL = '/valhalla/route'
const VALHALLA_OPTIMIZED_URL = '/valhalla/optimized_route'
const VALHALLA_HEIGHT_URL = '/valhalla/height'
/**
* Search geocode API with abort support.
* @param {string} query
* @param {number} limit
* @param {AbortSignal} signal
* @returns {Promise<{query, results, count}>}
*/
export async function searchGeocode(query, limit = 6, signal, viewport = null) {
const params = new URLSearchParams({ q: query, limit: String(limit) })
if (viewport?.lat != null) params.set('lat', String(viewport.lat))
if (viewport?.lon != null) params.set('lon', String(viewport.lon))
if (viewport?.zoom != null) params.set('zoom', String(Math.round(viewport.zoom)))
const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal, timeout: 5000 })
if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`)
return resp.json()
}
/**
* Request a route from Valhalla.
* @param {Array<{lat, lon}>} locations
* @param {string} costing - 'auto' | 'pedestrian' | 'bicycle'
* @returns {Promise<object>} Valhalla trip response
*/
export async function requestRoute(locations, costing = 'auto') {
const body = {
locations: locations.map((l) => ({ lat: l.lat, lon: l.lon })),
costing,
units: 'miles',
directions_options: { units: 'miles' },
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000)
try {
const resp = await fetch(VALHALLA_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
if (!resp.ok) {
const errBody = await resp.json().catch(() => ({}))
throw new Error(errBody.error || errBody.status_message || `Route error: ${resp.status}`)
}
return resp.json()
} finally {
clearTimeout(timeout)
}
}
/**
* Request an optimized route from Valhalla.
* @param {Array<{lat, lon}>} locations
* @param {string} costing
* @returns {Promise<object>} Valhalla optimized trip response
*/
export async function requestOptimizedRoute(locations, costing = 'auto') {
const body = {
locations: locations.map((l) => ({ lat: l.lat, lon: l.lon })),
costing,
units: 'miles',
directions_options: { units: 'miles' },
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000)
try {
const resp = await fetch(VALHALLA_OPTIMIZED_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
if (!resp.ok) {
const errBody = await resp.json().catch(() => ({}))
throw new Error(errBody.error || errBody.status_message || `Optimize error: ${resp.status}`)
}
return resp.json()
} finally {
clearTimeout(timeout)
}
}
/**
* Fetch elevation for a point via Valhalla height API.
* @param {number} lat
* @param {number} lon
* @returns {Promise<number|null>} Height in meters, or null on error
*/
export async function fetchElevation(lat, lon) {
try {
const resp = await fetch(VALHALLA_HEIGHT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shape: [{ lat, lon }], resample_distance: 100 }),
})
if (!resp.ok) return null
const data = await resp.json()
if (data.height && data.height.length > 0) return data.height[0]
return null
} catch {
return null
}
}
const REVERSE_URL = "/api/reverse"
/**
* Reverse geocode a point. Returns a place object or null.
* @param {number} lat
* @param {number} lon
* @returns {Promise<{lat, lon, name, address, type, source, raw}|null>}
*/
export async function fetchReverse(lat, lon) {
try {
const params = new URLSearchParams({ lat: String(lat), lon: String(lon) })
const resp = await fetch(`${REVERSE_URL}?${params}`, { timeout: 5000 })
if (!resp.ok) return null
const data = await resp.json()
if (!data.results || data.results.length === 0) return null
const r = data.results[0]
return {
lat: r.lat,
lon: r.lon,
name: r.name,
address: null,
type: r.type,
source: r.source,
matchCode: null,
raw: r.raw || {},
}
} catch {
return null
}
}
/**
* Fetch drive time between two points via Valhalla route.
* @param {number} oLat - Origin latitude
* @param {number} oLon - Origin longitude
* @param {number} dLat - Destination latitude
* @param {number} dLon - Destination longitude
* @param {AbortSignal} signal - AbortController signal
* @returns {Promise<number|null>} Drive time in seconds, or null on error
*/
export async function fetchDriveTime(oLat, oLon, dLat, dLon, signal) {
try {
const resp = await fetch(VALHALLA_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
locations: [{ lat: oLat, lon: oLon }, { lat: dLat, lon: dLon }],
costing: 'auto',
}),
signal,
})
if (!resp.ok) return null
const data = await resp.json()
return data.trip?.summary?.time ?? null
} catch {
return null
}
}
/**
* Fetch enriched place details from the place detail proxy.
* @param {string} osmType - N, W, or R
* @param {number} osmId - OSM element ID
* @param {AbortSignal} signal - AbortController signal for cancellation
* @returns {Promise<object|null>} Cleaned place detail object, or null on error
*/
export async function fetchPlaceDetails(osmType, osmId, signal) {
try {
const resp = await fetch(`/api/place/${osmType}/${osmId}`, {
signal,
headers: { 'Accept': 'application/json' },
})
if (!resp.ok) return null
return resp.json()
} catch {
return null
}
}
export async function fetchPlaceByWikidata(wikidataId, signal) {
try {
const resp = await fetch(`/api/place/wikidata/${wikidataId}`, {
signal,
headers: { "Accept": "application/json" },
})
if (!resp.ok) return null
return resp.json()
} catch {
return null
}
}
// ── Contacts API ──
export async function fetchContacts(signal) {
try {
const resp = await fetch('/api/contacts', { signal })
if (resp.status === 401) return { auth: false }
if (!resp.ok) throw new Error(`Contacts error: ${resp.status}`)
return resp.json()
} catch (e) {
if (e.name === 'AbortError') throw e
return []
}
}
export async function createContact(data) {
const resp = await fetch('/api/contacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (resp.status === 401) return { auth: false }
return resp.json().then((d) => ({ ...d, _status: resp.status }))
}
export async function updateContact(id, data) {
const resp = await fetch(`/api/contacts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (resp.status === 401) return { auth: false }
return resp.json()
}
export async function deleteContact(id) {
const resp = await fetch(`/api/contacts/${id}`, { method: 'DELETE' })
if (resp.status === 401) return { auth: false }
return resp.json()
}
export async function fetchNearbyContacts(lat, lon, radiusM, signal) {
try {
const params = new URLSearchParams({ lat: String(lat), lon: String(lon), radius_m: String(radiusM) })
const resp = await fetch(`/api/contacts/nearby?${params}`, { signal })
if (resp.status === 401) return []
if (!resp.ok) return []
return resp.json()
} catch {
return []
}
}
/**
* Fetch PAD-US land classification for a point.
* @param {number} lat
* @param {number} lon
* @param {AbortSignal} signal
* @returns {Promise<object|null>} Classification data or null on error
*/
export async function fetchLandclass(lat, lon, signal) {
try {
const params = new URLSearchParams({ lat: String(lat), lon: String(lon) })
const resp = await fetch(`/api/landclass?${params}`, { signal })
if (!resp.ok) return null
return resp.json()
} catch {
return null
}
}

View file

@ -1,189 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { Layers, Trees } from 'lucide-react'
import { hasFeature, getConfig } from '../config'
const STORAGE_KEY = 'navi-layer-prefs'
function loadPrefs() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw)
} catch {}
return null
}
function savePrefs(prefs) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
}
export default function LayerControl({ mapRef }) {
const [open, setOpen] = useState(false)
const [hillshade, setHillshade] = useState(false)
const [traffic, setTraffic] = useState(false)
const [publicLands, setPublicLands] = useState(false)
const panelRef = useRef(null)
// Initialize from localStorage or defaults on mount
useEffect(() => {
const saved = loadPrefs()
const hsAvailable = hasFeature('has_hillshade')
const trAvailable = hasFeature('has_traffic_overlay')
const plAvailable = hasFeature('has_public_lands_layer')
if (saved) {
setHillshade(hsAvailable && (saved.hillshade ?? true))
setTraffic(trAvailable && (saved.traffic ?? false))
setPublicLands(plAvailable && (saved.publicLands ?? false))
} else {
// Defaults: hillshade ON if available, traffic + publicLands OFF
setHillshade(hsAvailable)
setTraffic(false)
setPublicLands(false)
}
}, [])
// Apply layers when prefs change
useEffect(() => {
const mapView = mapRef?.current
if (!mapView) return
const map = mapView.getMap?.()
if (!map) return
const apply = () => {
if (hillshade && hasFeature('has_hillshade')) {
mapView.addHillshadeLayer?.()
} else {
mapView.removeHillshadeLayer?.()
}
}
if (map.isStyleLoaded()) {
apply()
} else {
map.once('style.load', apply)
}
savePrefs({ hillshade, traffic, publicLands })
return () => map.off('style.load', apply)
}, [hillshade, mapRef])
useEffect(() => {
const mapView = mapRef?.current
if (!mapView) return
const map = mapView.getMap?.()
if (!map) return
const apply = () => {
if (traffic && hasFeature('has_traffic_overlay')) {
mapView.addTrafficLayer?.()
} else {
mapView.removeTrafficLayer?.()
}
}
if (map.isStyleLoaded()) {
apply()
} else {
map.once('style.load', apply)
}
savePrefs({ hillshade, traffic, publicLands })
return () => map.off('style.load', apply)
}, [traffic, mapRef])
useEffect(() => {
const mapView = mapRef?.current
if (!mapView) return
const map = mapView.getMap?.()
if (!map) return
const apply = () => {
if (publicLands && hasFeature('has_public_lands_layer')) {
mapView.addPublicLandsLayer?.()
} else {
mapView.removePublicLandsLayer?.()
}
}
if (map.isStyleLoaded()) {
apply()
} else {
map.once('style.load', apply)
}
savePrefs({ hillshade, traffic, publicLands })
return () => map.off('style.load', apply)
}, [publicLands, mapRef])
// Close on outside click
useEffect(() => {
if (!open) return
function handleClick(e) {
if (panelRef.current && !panelRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('pointerdown', handleClick)
return () => document.removeEventListener('pointerdown', handleClick)
}, [open])
const showHillshade = hasFeature('has_hillshade')
const showTraffic = hasFeature('has_traffic_overlay')
const showPublicLands = hasFeature('has_public_lands_layer')
// Don't render if no overlay features available
if (!showHillshade && !showTraffic && !showPublicLands) return null
return (
<div ref={panelRef} className="layer-control">
<button
className="layer-control-btn"
onClick={() => setOpen((v) => !v)}
title="Map layers"
aria-label="Toggle map layers"
>
<Layers size={18} />
</button>
{open && (
<div className="layer-control-popover">
<div className="layer-control-header">Layers</div>
{showHillshade && (
<label className="layer-control-item">
<span className="layer-control-label">Hillshade</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={hillshade}
onChange={(e) => setHillshade(e.target.checked)}
/>
</label>
)}
{showTraffic && (
<label className="layer-control-item">
<span className="layer-control-label">Traffic</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={traffic}
onChange={(e) => setTraffic(e.target.checked)}
/>
</label>
)}
{showPublicLands && (
<label className="layer-control-item">
<span className="layer-control-label">Public Lands</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={publicLands}
onChange={(e) => setPublicLands(e.target.checked)}
/>
</label>
)}
</div>
)}
</div>
)
}

View file

@ -1,229 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { Layers, Trees, Mountain } from 'lucide-react'
import { hasFeature, getConfig } from '../config'
const STORAGE_KEY = 'navi-layer-prefs'
function loadPrefs() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw)
} catch {}
return null
}
function savePrefs(prefs) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
}
export default function LayerControl({ mapRef }) {
const [open, setOpen] = useState(false)
const [hillshade, setHillshade] = useState(false)
const [traffic, setTraffic] = useState(false)
const [publicLands, setPublicLands] = useState(false)
const [contours, setContours] = useState(false)
const panelRef = useRef(null)
// Initialize from localStorage or defaults on mount
useEffect(() => {
const saved = loadPrefs()
const hsAvailable = hasFeature('has_hillshade')
const trAvailable = hasFeature('has_traffic_overlay')
const plAvailable = hasFeature('has_public_lands_layer')
const ctAvailable = hasFeature('has_contours')
if (saved) {
setHillshade(hsAvailable && (saved.hillshade ?? true))
setTraffic(trAvailable && (saved.traffic ?? false))
setPublicLands(plAvailable && (saved.publicLands ?? false))
setContours(ctAvailable && (saved.contours ?? false))
} else {
// Defaults: hillshade ON if available, others OFF
setHillshade(hsAvailable)
setTraffic(false)
setPublicLands(false)
setContours(false)
}
}, [])
// Apply layers when prefs change
useEffect(() => {
const mapView = mapRef?.current
if (!mapView) return
const map = mapView.getMap?.()
if (!map) return
const apply = () => {
if (hillshade && hasFeature('has_hillshade')) {
mapView.addHillshadeLayer?.()
} else {
mapView.removeHillshadeLayer?.()
}
}
if (map.isStyleLoaded()) {
apply()
} else {
map.once('style.load', apply)
}
savePrefs({ hillshade, traffic, publicLands, contours })
return () => map.off('style.load', apply)
}, [hillshade, mapRef])
useEffect(() => {
const mapView = mapRef?.current
if (!mapView) return
const map = mapView.getMap?.()
if (!map) return
const apply = () => {
if (traffic && hasFeature('has_traffic_overlay')) {
mapView.addTrafficLayer?.()
} else {
mapView.removeTrafficLayer?.()
}
}
if (map.isStyleLoaded()) {
apply()
} else {
map.once('style.load', apply)
}
savePrefs({ hillshade, traffic, publicLands, contours })
return () => map.off('style.load', apply)
}, [traffic, mapRef])
useEffect(() => {
const mapView = mapRef?.current
if (!mapView) return
const map = mapView.getMap?.()
if (!map) return
const apply = () => {
if (publicLands && hasFeature('has_public_lands_layer')) {
mapView.addPublicLandsLayer?.()
} else {
mapView.removePublicLandsLayer?.()
}
}
if (map.isStyleLoaded()) {
apply()
} else {
map.once('style.load', apply)
}
savePrefs({ hillshade, traffic, publicLands, contours })
return () => map.off('style.load', apply)
}, [publicLands, mapRef])
useEffect(() => {
const mapView = mapRef?.current
if (!mapView) return
const map = mapView.getMap?.()
if (!map) return
const apply = () => {
if (contours && hasFeature('has_contours')) {
mapView.addContoursLayer?.()
} else {
mapView.removeContoursLayer?.()
}
}
if (map.isStyleLoaded()) {
apply()
} else {
map.once('style.load', apply)
}
savePrefs({ hillshade, traffic, publicLands, contours })
return () => map.off('style.load', apply)
}, [contours, mapRef])
// Close on outside click
useEffect(() => {
if (!open) return
function handleClick(e) {
if (panelRef.current && !panelRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('pointerdown', handleClick)
return () => document.removeEventListener('pointerdown', handleClick)
}, [open])
const showHillshade = hasFeature('has_hillshade')
const showTraffic = hasFeature('has_traffic_overlay')
const showPublicLands = hasFeature('has_public_lands_layer')
const showContours = hasFeature('has_contours')
// Don't render if no overlay features available
if (!showHillshade && !showTraffic && !showPublicLands && !showContours) return null
return (
<div ref={panelRef} className="layer-control">
<button
className="layer-control-btn"
onClick={() => setOpen((v) => !v)}
title="Map layers"
aria-label="Toggle map layers"
>
<Layers size={18} />
</button>
{open && (
<div className="layer-control-popover">
<div className="layer-control-header">Layers</div>
{showHillshade && (
<label className="layer-control-item">
<span className="layer-control-label">Hillshade</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={hillshade}
onChange={(e) => setHillshade(e.target.checked)}
/>
</label>
)}
{showTraffic && (
<label className="layer-control-item">
<span className="layer-control-label">Traffic</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={traffic}
onChange={(e) => setTraffic(e.target.checked)}
/>
</label>
)}
{showPublicLands && (
<label className="layer-control-item">
<span className="layer-control-label">Public Lands</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={publicLands}
onChange={(e) => setPublicLands(e.target.checked)}
/>
</label>
)}
{showContours && (
<label className="layer-control-item">
<span className="layer-control-label">Contours</span>
<input
type="checkbox"
className="layer-control-toggle"
checked={contours}
onChange={(e) => setContours(e.target.checked)}
/>
</label>
)}
</div>
)}
</div>
)
}

View file

@ -593,6 +593,28 @@ function removeContoursTest10ft(map) {
if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR) 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) if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE)
} }
/** Add boundary polygon layer with computed accent color (MapLibre rejects CSS vars in paint) */
function addBoundaryLayer(map) {
if (!map || map.getLayer(BOUNDARY_LAYER)) return
if (!map.getSource(BOUNDARY_SOURCE)) {
map.addSource(BOUNDARY_SOURCE, {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
})
}
const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b"
map.addLayer({
id: BOUNDARY_LAYER,
type: "line",
source: BOUNDARY_SOURCE,
paint: {
"line-color": accentColor,
"line-width": 2,
"line-opacity": 0.7,
"line-dasharray": [3, 2],
},
})
}
const MapView = forwardRef(function MapView(_, ref) { const MapView = forwardRef(function MapView(_, ref) {
const mapRef = useRef(null) const mapRef = useRef(null)
@ -977,22 +999,8 @@ const MapView = forwardRef(function MapView(_, ref) {
data: { type: 'FeatureCollection', features: [] }, data: { type: 'FeatureCollection', features: [] },
}) })
// Boundary polygon source for selected places // Boundary polygon layer for selected places
map.addSource(BOUNDARY_SOURCE, { addBoundaryLayer(map)
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],
},
})
// Restore overlay layers from localStorage prefs // Restore overlay layers from localStorage prefs
try { try {
@ -1125,22 +1133,8 @@ const MapView = forwardRef(function MapView(_, ref) {
data: { type: 'FeatureCollection', features: [] }, data: { type: 'FeatureCollection', features: [] },
}) })
// Boundary polygon source // Boundary polygon layer
map.addSource(BOUNDARY_SOURCE, { addBoundaryLayer(map)
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],
},
})
// Re-add active overlay layers // Re-add active overlay layers
if (activeLayersRef.current.hillshade) addHillshade(map) if (activeLayersRef.current.hillshade) addHillshade(map)

View file

@ -1,729 +0,0 @@
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'
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'
import { fetchReverse } from '../api'
import { getConfig, hasFeature } from '../config'
const ROUTE_SOURCE = 'route-source'
const ROUTE_LAYER_PREFIX = 'route-layer-'
const HILLSHADE_SOURCE = 'hillshade-dem'
const HILLSHADE_LAYER = 'hillshade-layer'
const TRAFFIC_SOURCE = 'traffic-tiles'
const TRAFFIC_LAYER = 'traffic-layer'
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'
/** Build a full MapLibre style object for the given theme */
function buildStyle(themeName) {
const config = getConfig()
const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles'
const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM'
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',
url: `pmtiles://${tileUrl}`,
attribution,
},
},
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>`
/** 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)
}
/** 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)
}
const MapView = forwardRef(function MapView(_, ref) {
const mapRef = useRef(null)
const mapInstance = useRef(null)
const markersRef = useRef([])
const popupRef = useRef(null)
const gpsMarkerRef = useRef(null)
const previewMarkerRef = useRef(null)
const watchIdRef = useRef(null)
const currentThemeRef = useRef('dark')
// Track which overlay layers are currently active (for theme swap re-add)
const activeLayersRef = useRef({ hillshade: false, traffic: false })
// Flag to suppress map-click when a stop pin was clicked
const pinClickedRef = useRef(false)
const stops = useStore((s) => s.stops)
const route = useStore((s) => s.route)
const theme = useStore((s) => s.theme)
const selectedPlace = useStore((s) => s.selectedPlace)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const setSheetState = useStore((s) => s.setSheetState)
// Expose map methods to parent
useImperativeHandle(ref, () => ({
flyTo(lat, lon, zoom = 14) {
mapInstance.current?.flyTo({ center: [lon, lat], zoom })
},
getMap() {
return mapInstance.current
},
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
},
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
},
}))
// Initialize map
useEffect(() => {
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
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
const initialTheme = document.documentElement.getAttribute('data-theme') || 'dark'
currentThemeRef.current = initialTheme
const map = new maplibregl.Map({
container: mapRef.current,
style: buildStyle(initialTheme),
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
// Map click drop pin and reverse geocode
map.on('click', (e) => {
// If a stop pin was just clicked, skip the pin-drop
if (pinClickedRef.current) {
pinClickedRef.current = false
return
}
if (window.innerWidth < 768) setSheetState('collapsed')
const { lng, lat } = e.lngLat
// Immediately set a "Dropped pin" placeholder so PlaceDetail opens with coords
useStore.getState().setSelectedPlace({
lat,
lon: lng,
name: 'Dropped pin',
address: null,
type: null,
source: 'map_click',
matchCode: null,
raw: {},
})
// Reverse geocode in background update place when result arrives
fetchReverse(lat, lng).then((place) => {
if (!place) return
// Only update if the selected place is still this pin (user hasn't clicked elsewhere)
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,
})
}
})
})
map.on('load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// 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
}
if (prefs.publicLands && hasFeature('has_public_lands_layer')) {
addPublicLands(map)
activeLayersRef.current.publicLands = true
}
} else if (hasFeature('has_hillshade')) {
// Default: hillshade ON if available
addHillshade(map)
activeLayersRef.current.hillshade = true
}
} catch {}
})
mapInstance.current = map
return () => {
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
maplibregl.removeProtocol('pmtiles')
map.remove()
}
}, [setSheetState])
/** 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 = ''
}
}
}
}
// 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
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 })
// Re-add sources/layers after style swap
map.once('style.load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// Re-add active overlay layers
if (activeLayersRef.current.hillshade) addHillshade(map)
if (activeLayersRef.current.traffic) addTraffic(map)
if (activeLayersRef.current.publicLands) addPublicLands(map)
// 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
// Only fly to place if it came from search (not map-click which already centered)
if (selectedPlace.source !== 'map_click') {
map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
}
// Create preview marker
const el = document.createElement('div')
el.className = 'navi-pin-preview'
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])
// Update route polyline when route changes
useEffect(() => {
const map = mapInstance.current
if (!map) return
if (!map.isStyleLoaded()) {
const handler = () => updateRoute(map, route)
map.once('load', handler)
return () => map.off('load', handler)
}
updateRoute(map, route)
}, [route])
function updateRoute(map, routeData) {
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)
}
}
}
if (!routeData || !routeData.legs) {
if (map.getSource(ROUTE_SOURCE)) {
map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] })
}
return
}
const features = []
for (let i = 0; i < routeData.legs.length; i++) {
const leg = routeData.legs[i]
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 },
})
}
// Use CSS variable for route color (read computed value)
const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim()
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],
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': routeColor || '#7a9a6b',
'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])
)
const hasDetail = useStore.getState().selectedPlace != null
const leftPad = hasDetail ? 700 : 340
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } })
}
}
// 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
}
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const indexOffset = hasGpsOrigin ? 1 : 0
stops.forEach((stop, i) => {
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'
const label = String.fromCharCode(65 + Math.min(displayIndex, 25))
const el = document.createElement('div')
el.className = pinClass
el.textContent = label
el.addEventListener('click', (e) => {
e.stopPropagation()
// Flag so the map-level click handler doesn't fire
pinClickedRef.current = true
if (popupRef.current) popupRef.current.remove()
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
.setLngLat([stop.lon, stop.lat])
.setHTML(
`<div style="font-size:12px;max-width:200px">
<strong>${stop.name}</strong>
<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>
</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])
)
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } })
}
}
}, [stops, route, gpsOrigin, geoPermission])
return <div ref={mapRef} className="w-full h-full" />
})
export default MapView

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,854 +0,0 @@
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'
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'
import { fetchReverse } from '../api'
import { getConfig, hasFeature } from '../config'
const ROUTE_SOURCE = 'route-source'
const ROUTE_LAYER_PREFIX = 'route-layer-'
const HILLSHADE_SOURCE = 'hillshade-dem'
const HILLSHADE_LAYER = 'hillshade-layer'
const TRAFFIC_SOURCE = 'traffic-tiles'
const TRAFFIC_LAYER = 'traffic-layer'
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'
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'
/** Build a full MapLibre style object for the given theme */
function buildStyle(themeName) {
const config = getConfig()
const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles'
const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM'
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',
url: `pmtiles://${tileUrl}`,
attribution,
},
},
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>`
/** 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)
}
/** 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)
}
/** 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)
}
const MapView = forwardRef(function MapView(_, ref) {
const mapRef = useRef(null)
const mapInstance = useRef(null)
const markersRef = useRef([])
const popupRef = useRef(null)
const gpsMarkerRef = useRef(null)
const previewMarkerRef = useRef(null)
const watchIdRef = useRef(null)
const currentThemeRef = useRef('dark')
// Track which overlay layers are currently active (for theme swap re-add)
const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false })
// Flag to suppress map-click when a stop pin was clicked
const pinClickedRef = useRef(false)
const stops = useStore((s) => s.stops)
const route = useStore((s) => s.route)
const theme = useStore((s) => s.theme)
const selectedPlace = useStore((s) => s.selectedPlace)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const setSheetState = useStore((s) => s.setSheetState)
// Expose map methods to parent
useImperativeHandle(ref, () => ({
flyTo(lat, lon, zoom = 14) {
mapInstance.current?.flyTo({ center: [lon, lat], zoom })
},
getMap() {
return mapInstance.current
},
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
},
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
},
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
},
}))
// Initialize map
useEffect(() => {
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
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
const initialTheme = document.documentElement.getAttribute('data-theme') || 'dark'
currentThemeRef.current = initialTheme
const map = new maplibregl.Map({
container: mapRef.current,
style: buildStyle(initialTheme),
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
// Map click — drop pin and reverse geocode
map.on('click', (e) => {
// If a stop pin was just clicked, skip the pin-drop
if (pinClickedRef.current) {
pinClickedRef.current = false
return
}
if (window.innerWidth < 768) setSheetState('collapsed')
const { lng, lat } = e.lngLat
// Immediately set a "Dropped pin" placeholder so PlaceDetail opens with coords
useStore.getState().setSelectedPlace({
lat,
lon: lng,
name: 'Dropped pin',
address: null,
type: null,
source: 'map_click',
matchCode: null,
raw: {},
})
// Reverse geocode in background — update place when result arrives
fetchReverse(lat, lng).then((place) => {
if (!place) return
// Only update if the selected place is still this pin (user hasn't clicked elsewhere)
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,
})
}
})
})
map.on('load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// 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
}
if (prefs.publicLands && hasFeature('has_public_lands_layer')) {
addPublicLands(map)
activeLayersRef.current.publicLands = true
}
if (prefs.contours && hasFeature('has_contours')) {
addContours(map)
activeLayersRef.current.contours = true
}
} else if (hasFeature('has_hillshade')) {
// Default: hillshade ON if available
addHillshade(map)
activeLayersRef.current.hillshade = true
}
} catch {}
})
mapInstance.current = map
return () => {
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
maplibregl.removeProtocol('pmtiles')
map.remove()
}
}, [setSheetState])
/** 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 = ''
}
}
}
}
// 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
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 })
// Re-add sources/layers after style swap
map.once('style.load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// Re-add active overlay layers
if (activeLayersRef.current.hillshade) addHillshade(map)
if (activeLayersRef.current.traffic) addTraffic(map)
if (activeLayersRef.current.publicLands) addPublicLands(map)
if (activeLayersRef.current.contours) addContours(map)
// 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
// Only fly to place if it came from search (not map-click which already centered)
if (selectedPlace.source !== 'map_click') {
map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
}
// Create preview marker
const el = document.createElement('div')
el.className = 'navi-pin-preview'
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])
// Update route polyline when route changes
useEffect(() => {
const map = mapInstance.current
if (!map) return
if (!map.isStyleLoaded()) {
const handler = () => updateRoute(map, route)
map.once('load', handler)
return () => map.off('load', handler)
}
updateRoute(map, route)
}, [route])
function updateRoute(map, routeData) {
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)
}
}
}
if (!routeData || !routeData.legs) {
if (map.getSource(ROUTE_SOURCE)) {
map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] })
}
return
}
const features = []
for (let i = 0; i < routeData.legs.length; i++) {
const leg = routeData.legs[i]
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 },
})
}
// Use CSS variable for route color (read computed value)
const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim()
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],
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': routeColor || '#7a9a6b',
'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])
)
const hasDetail = useStore.getState().selectedPlace != null
const leftPad = hasDetail ? 700 : 340
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } })
}
}
// 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
}
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const indexOffset = hasGpsOrigin ? 1 : 0
stops.forEach((stop, i) => {
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'
const label = String.fromCharCode(65 + Math.min(displayIndex, 25))
const el = document.createElement('div')
el.className = pinClass
el.textContent = label
el.addEventListener('click', (e) => {
e.stopPropagation()
// Flag so the map-level click handler doesn't fire
pinClickedRef.current = true
if (popupRef.current) popupRef.current.remove()
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
.setLngLat([stop.lon, stop.lat])
.setHTML(
`<div style="font-size:12px;max-width:200px">
<strong>${stop.name}</strong>
<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>
</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])
)
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } })
}
}
}, [stops, route, gpsOrigin, geoPermission])
return <div ref={mapRef} className="w-full h-full" />
})
export default MapView

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,973 +0,0 @@
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'
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'
import { fetchReverse } from '../api'
import { getConfig, hasFeature } from '../config'
const ROUTE_SOURCE = 'route-source'
const ROUTE_LAYER_PREFIX = 'route-layer-'
const HILLSHADE_SOURCE = 'hillshade-dem'
const HILLSHADE_LAYER = 'hillshade-layer'
const TRAFFIC_SOURCE = 'traffic-tiles'
const TRAFFIC_LAYER = 'traffic-layer'
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'
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'
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'
/** Build a full MapLibre style object for the given theme */
function buildStyle(themeName) {
const config = getConfig()
const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles'
const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM'
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',
url: `pmtiles://${tileUrl}`,
attribution,
},
},
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>`
/** 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)
}
/** 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)
}
/** 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)
}
/** 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)
}
const MapView = forwardRef(function MapView(_, ref) {
const mapRef = useRef(null)
const mapInstance = useRef(null)
const markersRef = useRef([])
const popupRef = useRef(null)
const gpsMarkerRef = useRef(null)
const previewMarkerRef = useRef(null)
const watchIdRef = useRef(null)
const currentThemeRef = useRef('dark')
// Track which overlay layers are currently active (for theme swap re-add)
const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false })
// Flag to suppress map-click when a stop pin was clicked
const pinClickedRef = useRef(false)
const stops = useStore((s) => s.stops)
const route = useStore((s) => s.route)
const theme = useStore((s) => s.theme)
const selectedPlace = useStore((s) => s.selectedPlace)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const setSheetState = useStore((s) => s.setSheetState)
// Expose map methods to parent
useImperativeHandle(ref, () => ({
flyTo(lat, lon, zoom = 14) {
mapInstance.current?.flyTo({ center: [lon, lat], zoom })
},
getMap() {
return mapInstance.current
},
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
},
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
},
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
},
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
},
}))
// Initialize map
useEffect(() => {
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
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
const initialTheme = document.documentElement.getAttribute('data-theme') || 'dark'
currentThemeRef.current = initialTheme
const map = new maplibregl.Map({
container: mapRef.current,
style: buildStyle(initialTheme),
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
// Map click — drop pin and reverse geocode
map.on('click', (e) => {
// If a stop pin was just clicked, skip the pin-drop
if (pinClickedRef.current) {
pinClickedRef.current = false
return
}
if (window.innerWidth < 768) setSheetState('collapsed')
const { lng, lat } = e.lngLat
// Immediately set a "Dropped pin" placeholder so PlaceDetail opens with coords
useStore.getState().setSelectedPlace({
lat,
lon: lng,
name: 'Dropped pin',
address: null,
type: null,
source: 'map_click',
matchCode: null,
raw: {},
})
// Reverse geocode in background — update place when result arrives
fetchReverse(lat, lng).then((place) => {
if (!place) return
// Only update if the selected place is still this pin (user hasn't clicked elsewhere)
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,
})
}
})
})
map.on('load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// 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
}
if (prefs.publicLands && hasFeature('has_public_lands_layer')) {
addPublicLands(map)
activeLayersRef.current.publicLands = true
}
if (prefs.contours && hasFeature('has_contours')) {
addContours(map)
activeLayersRef.current.contours = true
}
} else if (hasFeature('has_hillshade')) {
// Default: hillshade ON if available
addHillshade(map)
activeLayersRef.current.hillshade = true
}
} catch {}
})
mapInstance.current = map
return () => {
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
maplibregl.removeProtocol('pmtiles')
map.remove()
}
}, [setSheetState])
/** 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 = ''
}
}
}
}
// 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
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 })
// Re-add sources/layers after style swap
map.once('style.load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// Re-add active overlay layers
if (activeLayersRef.current.hillshade) addHillshade(map)
if (activeLayersRef.current.traffic) addTraffic(map)
if (activeLayersRef.current.publicLands) addPublicLands(map)
if (activeLayersRef.current.contours) addContours(map)
// 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
// Only fly to place if it came from search (not map-click which already centered)
if (selectedPlace.source !== 'map_click') {
map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
}
// Create preview marker
const el = document.createElement('div')
el.className = 'navi-pin-preview'
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])
// Update route polyline when route changes
useEffect(() => {
const map = mapInstance.current
if (!map) return
if (!map.isStyleLoaded()) {
const handler = () => updateRoute(map, route)
map.once('load', handler)
return () => map.off('load', handler)
}
updateRoute(map, route)
}, [route])
function updateRoute(map, routeData) {
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)
}
}
}
if (!routeData || !routeData.legs) {
if (map.getSource(ROUTE_SOURCE)) {
map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] })
}
return
}
const features = []
for (let i = 0; i < routeData.legs.length; i++) {
const leg = routeData.legs[i]
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 },
})
}
// Use CSS variable for route color (read computed value)
const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim()
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],
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': routeColor || '#7a9a6b',
'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])
)
const hasDetail = useStore.getState().selectedPlace != null
const leftPad = hasDetail ? 700 : 340
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } })
}
}
// 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
}
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const indexOffset = hasGpsOrigin ? 1 : 0
stops.forEach((stop, i) => {
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'
const label = String.fromCharCode(65 + Math.min(displayIndex, 25))
const el = document.createElement('div')
el.className = pinClass
el.textContent = label
el.addEventListener('click', (e) => {
e.stopPropagation()
// Flag so the map-level click handler doesn't fire
pinClickedRef.current = true
if (popupRef.current) popupRef.current.remove()
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
.setLngLat([stop.lon, stop.lat])
.setHTML(
`<div style="font-size:12px;max-width:200px">
<strong>${stop.name}</strong>
<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>
</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])
)
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } })
}
}
}, [stops, route, gpsOrigin, geoPermission])
return <div ref={mapRef} className="w-full h-full" />
})
export default MapView

View file

@ -14,6 +14,7 @@ export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace) const selectedPlace = useStore((s) => s.selectedPlace)
const pendingDestination = useStore((s) => s.pendingDestination) const pendingDestination = useStore((s) => s.pendingDestination)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
const stops = useStore((s) => s.stops) const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode) const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route) const route = useStore((s) => s.route)
@ -174,6 +175,14 @@ export default function Panel({ onManeuverClick }) {
{optimizing ? 'Optimizing...' : 'Optimize stop order'} {optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button> </button>
)} )}
{pendingDestination && stops.length === 0 && (
<button
onClick={clearPendingDestination}
className="navi-btn-secondary w-full"
>
Cancel
</button>
)}
</div> </div>
</> </>
)} )}

View file

@ -1,283 +0,0 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError)
const setStops = useStore((s) => s.setStops)
const setRoute = useStore((s) => s.setRoute)
const setRouteError = useStore((s) => s.setRouteError)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const activeTab = useStore((s) => s.activeTab)
const setActiveTab = useStore((s) => s.setActiveTab)
const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
const showContacts = hasFeature('has_contacts')
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
for (const s of stops) {
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
if (d < minDist) {
minDist = d
closest = s
}
}
return closest
})
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
if (unique.length === stops.length) {
setStops(unique)
}
}
setRoute(data.trip)
}
} catch (e) {
setRouteError(e.message)
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState
}, [sheetState])
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = effectiveCount >= 3
// Determine what to show based on panel state
const showPreviewCard = panelState === 'PREVIEW' || panelState === 'PREVIEW_ROUTING'
const showRouteSection = panelState === 'ROUTING' || panelState === 'PREVIEW_ROUTING' || panelState === 'ROUTE_CALCULATED'
const showManeuvers = panelState === 'ROUTE_CALCULATED'
const showEmptyState = panelState === 'IDLE'
// Routes tab content - now state-driven
const routesContent = (
<>
<SearchBar />
{/* Preview card when place is selected */}
{showPreviewCard && selectedPlace && (
<div className="mt-3">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
{/* Route section with stops */}
{showRouteSection && (
<>
<div className="mt-3">
<StopList />
</div>
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
</>
)}
{/* Maneuvers when route is calculated */}
{showManeuvers && (route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p>
</div>
)}
</>
)
const content = (
<>
{showContacts && (
<div className="navi-tab-bar mb-3">
<button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('routes')}
>
Routes
</button>
<button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('contacts')}
>
Contacts
</button>
</div>
)}
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
</>
)
const header = (
<div className="flex items-center justify-between mb-3">
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
<button
onClick={toggleTheme}
className="p-1.5 rounded"
style={{ color: 'var(--text-secondary)' }}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</div>
)
// Desktop: side panel (now 360px to accommodate PlaceCard)
if (!isMobile) {
return (
<div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
width: '400px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>
)}
</div>
)
}

View file

@ -1,283 +0,0 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError)
const setStops = useStore((s) => s.setStops)
const setRoute = useStore((s) => s.setRoute)
const setRouteError = useStore((s) => s.setRouteError)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const activeTab = useStore((s) => s.activeTab)
const setActiveTab = useStore((s) => s.setActiveTab)
const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
const showContacts = hasFeature('has_contacts')
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
for (const s of stops) {
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
if (d < minDist) {
minDist = d
closest = s
}
}
return closest
})
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
if (unique.length === stops.length) {
setStops(unique)
}
}
setRoute(data.trip)
}
} catch (e) {
setRouteError(e.message)
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState
}, [sheetState])
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = effectiveCount >= 3
// Determine what to show based on panel state
const showPreviewCard = panelState === 'PREVIEW' || panelState === 'PREVIEW_ROUTING'
const showRouteSection = panelState === 'ROUTING' || panelState === 'PREVIEW_ROUTING' || panelState === 'ROUTE_CALCULATED'
const showManeuvers = panelState === 'ROUTE_CALCULATED'
const showEmptyState = panelState === 'IDLE'
// Routes tab content - now state-driven
const routesContent = (
<>
<SearchBar />
{/* Preview card when place is selected */}
{showPreviewCard && selectedPlace && (
<div className="mt-3">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
{/* Route section with stops */}
{showRouteSection && (
<>
<div className="mt-3">
<StopList />
</div>
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
</>
)}
{/* Maneuvers when route is calculated */}
{showManeuvers && (route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p>
</div>
)}
</>
)
const content = (
<>
{showContacts && (
<div className="navi-tab-bar mb-3">
<button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('routes')}
>
Routes
</button>
<button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('contacts')}
>
Contacts
</button>
</div>
)}
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
</>
)
const header = (
<div className="flex items-center justify-between mb-3">
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
<button
onClick={toggleTheme}
className="p-1.5 rounded"
style={{ color: 'var(--text-secondary)' }}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</div>
)
// Desktop: side panel (now 360px to accommodate PlaceCard)
if (!isMobile) {
return (
<div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
width: '360px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>
)}
</div>
)
}

View file

@ -1,283 +0,0 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError)
const setStops = useStore((s) => s.setStops)
const setRoute = useStore((s) => s.setRoute)
const setRouteError = useStore((s) => s.setRouteError)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const activeTab = useStore((s) => s.activeTab)
const setActiveTab = useStore((s) => s.setActiveTab)
const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
const showContacts = hasFeature('has_contacts')
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
for (const s of stops) {
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
if (d < minDist) {
minDist = d
closest = s
}
}
return closest
})
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
if (unique.length === stops.length) {
setStops(unique)
}
}
setRoute(data.trip)
}
} catch (e) {
setRouteError(e.message)
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState
}, [sheetState])
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = effectiveCount >= 3
// Determine what to show based on panel state
const showPreviewCard = panelState === 'PREVIEW' || panelState === 'PREVIEW_ROUTING'
const showRouteSection = panelState === 'ROUTING' || panelState === 'PREVIEW_ROUTING' || panelState === 'ROUTE_CALCULATED'
const showManeuvers = panelState === 'ROUTE_CALCULATED'
const showEmptyState = panelState === 'IDLE'
// Routes tab content - now state-driven
const routesContent = (
<>
<SearchBar />
{/* Preview card when place is selected */}
{showPreviewCard && selectedPlace && (
<div className="mt-3">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
{/* Route section with stops */}
{showRouteSection && (
<>
<div className="mt-3">
<StopList />
</div>
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
</>
)}
{/* Maneuvers when route is calculated */}
{showManeuvers && (route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p>
</div>
)}
</>
)
const content = (
<>
{showContacts && (
<div className="navi-tab-bar mb-3">
<button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('routes')}
>
Routes
</button>
<button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('contacts')}
>
Contacts
</button>
</div>
)}
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
</>
)
const header = (
<div className="flex items-center justify-between mb-3">
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
<button
onClick={toggleTheme}
className="p-1.5 rounded"
style={{ color: 'var(--text-secondary)' }}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</div>
)
// Desktop: side panel (now 360px to accommodate PlaceCard)
if (!isMobile) {
return (
<div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
width: '360px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>
)}
</div>
)
}

View file

@ -1,283 +0,0 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError)
const setStops = useStore((s) => s.setStops)
const setRoute = useStore((s) => s.setRoute)
const setRouteError = useStore((s) => s.setRouteError)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const activeTab = useStore((s) => s.activeTab)
const setActiveTab = useStore((s) => s.setActiveTab)
const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
const showContacts = hasFeature('has_contacts')
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
for (const s of stops) {
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
if (d < minDist) {
minDist = d
closest = s
}
}
return closest
})
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
if (unique.length === stops.length) {
setStops(unique)
}
}
setRoute(data.trip)
}
} catch (e) {
setRouteError(e.message)
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState
}, [sheetState])
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = effectiveCount >= 3
// Determine what to show based on panel state
const showPreviewCard = panelState.startsWith('PREVIEW')
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState)
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
const showEmptyState = panelState === 'IDLE'
// Routes tab content - now state-driven
const routesContent = (
<>
<SearchBar />
{/* Preview card when place is selected */}
{showPreviewCard && selectedPlace && (
<div className="mt-3">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
{/* Route section with stops */}
{showRouteSection && (
<>
<div className="mt-3">
<StopList />
</div>
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
</>
)}
{/* Maneuvers when route is calculated */}
{showManeuvers && (route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p>
</div>
)}
</>
)
const content = (
<>
{showContacts && (
<div className="navi-tab-bar mb-3">
<button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('routes')}
>
Routes
</button>
<button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('contacts')}
>
Contacts
</button>
</div>
)}
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
</>
)
const header = (
<div className="flex items-center justify-between mb-3">
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
<button
onClick={toggleTheme}
className="p-1.5 rounded"
style={{ color: 'var(--text-secondary)' }}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</div>
)
// Desktop: side panel (now 360px to accommodate PlaceCard)
if (!isMobile) {
return (
<div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
width: '400px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>
)}
</div>
)
}

View file

@ -326,7 +326,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl
}, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon]) }, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon])
useEffect(() => { useEffect(() => {
if (!hasFeature("has_contacts") || placeLat == null || placeLon == null) { setNearbyLabel(null); return } if (!hasFeature("has_contacts") || !auth.authenticated || placeLat == null || placeLon == null) { setNearbyLabel(null); return }
const controller = new AbortController() const controller = new AbortController()
fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => { fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => {
if (!controller.signal.aborted && nearby.length > 0) setNearbyLabel(nearby[0].label) if (!controller.signal.aborted && nearby.length > 0) setNearbyLabel(nearby[0].label)

View file

@ -1,434 +0,0 @@
import { useEffect, useState, useRef, useCallback } from "react"
import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
} from "lucide-react"
import OpeningHours from "opening_hours"
import toast from "react-hot-toast"
import { useStore } from "../store"
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from "../api"
import { hasFeature } from "../config"
import { buildAddress } from "../utils/place"
const M_TO_FT = 3.28084
function formatDriveTime(seconds) {
const mins = Math.round(seconds / 60)
if (mins < 2) return "< 2 min"
if (mins < 120) return `${mins} min`
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
function parseHours(hoursStr) {
try {
const oh = new OpeningHours(hoursStr, { address: { country_code: "us", state: "Idaho" } })
const now = new Date()
const isOpen = oh.getState(now)
const nextChange = oh.getNextChange(now)
let todayStr = ""
if (isOpen) {
todayStr = "Open now"
if (nextChange) {
const closeTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
todayStr += " \u00b7 Closes " + closeTime
}
} else {
todayStr = "Closed"
if (nextChange) {
const openTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
const isTodayOpen = nextChange.getDate() === now.getDate()
todayStr += " \u00b7 Opens " + (isTodayOpen ? "at " : "tomorrow ") + openTime
}
}
const week = []
for (let d = 0; d < 7; d++) {
const date = new Date(now)
const diff = (d - now.getDay() + 7) % 7
date.setDate(now.getDate() + diff)
date.setHours(0, 0, 0, 0)
const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000))
if (intervals.length === 0) {
week.push({ day: DAY_SHORT[d], hours: "Closed", isTodayRow: d === now.getDay() })
} else {
const parts = intervals.map(([start, end]) => {
const s = start.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
const e = end.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
return s + " \u2013 " + e
})
week.push({ day: DAY_SHORT[d], hours: parts.join(", "), isTodayRow: d === now.getDay() })
}
}
return { isOpen, todayStr, week }
} catch {
return null
}
}
function formatPhone(phone) {
if (!phone) return null
const digits = phone.replace(/[^\d]/g, "")
if (digits.length === 11 && digits[0] === "1") {
return "(" + digits.slice(1, 4) + ") " + digits.slice(4, 7) + "-" + digits.slice(7)
}
if (digits.length === 10) {
return "(" + digits.slice(0, 3) + ") " + digits.slice(3, 6) + "-" + digits.slice(6)
}
return phone
}
function wheelchairLabel(val) {
if (!val) return null
const map = { yes: "Accessible", limited: "Limited access", no: "Not accessible" }
return map[val.toLowerCase()] || null
}
function wikiUrl(wp) {
if (!wp) return null
const [lang, ...rest] = wp.split(":")
const title = rest.join(":").replace(/ /g, "_")
return "https://" + lang + ".wikipedia.org/wiki/" + encodeURIComponent(title)
}
function wikiLabel(wp) {
if (!wp) return null
const [, ...rest] = wp.split(":")
return rest.join(":").replace(/_/g, " ")
}
function DetailSection({ label, icon: Icon, first, children }) {
return (
<div className="text-xs" style={{ paddingTop: first ? 0 : "0.5rem", borderTop: first ? "none" : "1px solid var(--border)" }}>
<div className="flex items-center gap-1.5 mb-1.5" style={{ color: "var(--text-tertiary)" }}>
<Icon size={12} />
<span className="uppercase text-[10px] font-medium tracking-wide">{label}</span>
</div>
{children}
</div>
)
}
function HoursDisplay({ hoursStr, first }) {
const [expanded, setExpanded] = useState(false)
const parsed = parseHours(hoursStr)
if (!parsed) return null
const { isOpen, todayStr, week } = parsed
return (
<DetailSection label="Hours" icon={Clock} first={first}>
<button onClick={() => setExpanded((v) => !v)} className="w-full flex items-center justify-between text-xs" style={{ color: "var(--text-primary)" }}>
<span style={{ color: isOpen ? "var(--success)" : "var(--text-tertiary)" }}>{todayStr}</span>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{expanded && (
<div className="mt-2 flex flex-col gap-0.5 text-[11px]">
{week.map((w) => (
<div key={w.day} className="flex justify-between" style={{ color: w.isTodayRow ? "var(--text-primary)" : "var(--text-secondary)", fontWeight: w.isTodayRow ? 500 : 400 }}>
<span>{w.day}</span>
<span>{w.hours}</span>
</div>
))}
</div>
)}
</DetailSection>
)
}
function LandclassSection({ data }) {
if (!data || !data.summary) return null
return (
<div className="mt-2 flex items-start gap-2 text-xs" style={{ color: "var(--text-secondary)" }}>
<Trees size={14} style={{ color: "var(--text-tertiary)", flexShrink: 0, marginTop: 1 }} />
<div className="flex flex-col gap-0.5">
<span>{data.summary}</span>
{data.unit_name && <span style={{ color: "var(--text-tertiary)" }}>{data.unit_name}</span>}
</div>
</div>
)
}
function PrivateLandIndicator({ data }) {
if (!data || data.gap_status !== "4") return null
return (
<div className="mt-2 px-2 py-1.5 rounded text-xs" style={{ background: "var(--warning-muted)", color: "var(--warning)", border: "1px solid var(--warning)" }}>
Private land — permission required
</div>
)
}
function EnrichmentSkeleton() {
return (
<div className="mt-3 flex flex-col gap-3 animate-pulse">
<div className="h-3 rounded w-1/3" style={{ background: "var(--bg-inset)" }} />
<div className="h-3 rounded w-2/3" style={{ background: "var(--bg-inset)" }} />
<div className="h-3 rounded w-1/2" style={{ background: "var(--bg-inset)" }} />
</div>
)
}
function EnrichmentSections({ details }) {
if (!details) return null
const { category, extratags } = details
const et = extratags || {}
const hasAbout = category
const hasHours = et.opening_hours
const hasContact = et.phone || et.website || et.email
const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
const hasLinks = et.wikipedia || et.wikidata
if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
let idx = 0
return (
<div className="mt-3 flex flex-col gap-2.5">
{hasAbout && (
<DetailSection label="About" icon={Info} first={idx++ === 0}>
<span className="category-badge">{category}</span>
</DetailSection>
)}
{hasHours && <HoursDisplay hoursStr={et.opening_hours} first={idx++ === 0} />}
{hasContact && (
<DetailSection label="Contact" icon={Phone} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.phone && <a href={"tel:" + et.phone} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Phone size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{formatPhone(et.phone)}</a>}
{et.website && <a href={et.website.startsWith("http") ? et.website : "https://" + et.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs truncate" style={{ color: "var(--accent)" }}><Globe size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}</a>}
{et.email && <a href={"mailto:" + et.email} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Mail size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.email}</a>}
</div>
</DetailSection>
)}
{hasDetails && (
<DetailSection label="Details" icon={Info} first={idx++ === 0}>
<div className="flex flex-col gap-1 text-xs" style={{ color: "var(--text-secondary)" }}>
{et.cuisine && <span>Cuisine: {et.cuisine.replace(/_/g, " ").replace(/;/g, ", ")}</span>}
{et.operator && <span>Operated by {et.operator}</span>}
{et.fee && <span>{et.fee === "no" ? "Free" : "Fee: " + et.fee}</span>}
{et.wheelchair && wheelchairLabel(et.wheelchair) && <span>{wheelchairLabel(et.wheelchair)}</span>}
{et.takeaway === "yes" && <span>Takeaway available</span>}
</div>
</DetailSection>
)}
{hasLinks && (
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.wikipedia && wikiUrl(et.wikipedia) && <a href={wikiUrl(et.wikipedia)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}><BookOpen size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{wikiLabel(et.wikipedia)}</a>}
{et.wikidata && <a href={"https://www.wikidata.org/wiki/" + et.wikidata} target="_blank" rel="noopener noreferrer" className="text-[11px]" style={{ color: "var(--text-tertiary)", textDecoration: "underline" }}>View on Wikidata</a>}
</div>
</DetailSection>
)}
</div>
)
}
function CopyPopover({ address, place, onClose }) {
const ref = useRef(null)
useEffect(() => {
function handleClick(e) { if (ref.current && !ref.current.contains(e.target)) onClose() }
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [onClose])
const copyAddress = () => {
const text = [place.name, address].filter(Boolean).join("\n")
navigator.clipboard.writeText(text).then(() => toast("Address copied"), () => toast.error("Failed to copy"))
onClose()
}
const copyCoords = () => {
const text = place.lat.toFixed(6) + ", " + place.lon.toFixed(6)
navigator.clipboard.writeText(text).then(() => toast("Coordinates copied"), () => toast.error("Failed to copy"))
onClose()
}
return (
<div ref={ref} className="absolute bottom-full mb-1 right-0 rounded-lg py-1 z-50 min-w-[140px]" style={{ background: "var(--bg-overlay)", border: "1px solid var(--border)", boxShadow: "var(--shadow-lg)" }}>
<button onClick={address ? copyAddress : undefined} disabled={!address} className="w-full text-left px-3 py-1.5 text-xs" style={{ color: address ? "var(--text-primary)" : "var(--text-tertiary)", cursor: address ? "pointer" : "not-allowed" }}>Address</button>
<button onClick={copyCoords} className="w-full text-left px-3 py-1.5 text-xs hover:opacity-80" style={{ color: "var(--text-primary)" }}>Coordinates</button>
</div>
)
}
export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) {
const { contacts, userLocation, stops, geoPermission, addStop, startDirections, clearSelectedPlace, setEditingContact } = useStore()
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null)
const [nearbyLabel, setNearbyLabel] = useState(null)
const [landclass, setLandclass] = useState(null)
const [copyOpen, setCopyOpen] = useState(false)
const placeLat = place?.lat
const placeLon = place?.lon
const osmType = place?.raw?.osm_type
const osmId = place?.raw?.osm_id
const wikidataId = place?.wikidata || place?.raw?.wikidata
useEffect(() => {
if (placeLat == null || placeLon == null) return
let cancelled = false
fetchElevation(placeLat, placeLon).then((h) => { if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h }) })
return () => { cancelled = true }
}, [placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_nominatim_details") || !osmType || !osmId) { setPlaceDetails(null); return }
const controller = new AbortController()
setPlaceDetails("loading")
fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
if (!controller.signal.aborted) {
setPlaceDetails(data || null)
if (data?.boundary) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
}
}
}
})
return () => controller.abort()
}, [osmType, osmId, placeLat, placeLon])
useEffect(() => {
if (osmType && osmId) return
if (!wikidataId) return
const controller = new AbortController()
fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setPlaceDetails((prev) => ({
...(prev === "loading" ? {} : prev || {}),
description: data.description,
population: data.population,
osm_relation_id: data.osm_relation_id,
extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags },
}))
if (data?.boundary) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
}
}
}
})
return () => controller.abort()
}, [wikidataId, osmType, osmId, placeLat, placeLon])
useEffect(() => {
if (variant !== "preview" || !userLocation || placeLat == null || placeLon == null) { setDriveTime(null); return }
setDriveTime(null)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 3000)
fetchDriveTime(userLocation.lat, userLocation.lon, placeLat, placeLon, controller.signal).then((time) => { if (!controller.signal.aborted) setDriveTime(time) })
return () => { controller.abort(); clearTimeout(timeout) }
}, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_contacts") || placeLat == null || placeLon == null) { setNearbyLabel(null); return }
const controller = new AbortController()
fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => {
if (!controller.signal.aborted && nearby.length > 0) setNearbyLabel(nearby[0].label)
else if (!controller.signal.aborted) setNearbyLabel(null)
})
return () => controller.abort()
}, [placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_landclass") || placeLat == null || placeLon == null) { setLandclass(null); return }
const controller = new AbortController()
fetchLandclass(placeLat, placeLon, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setLandclass(data)
if (data.summary && useStore.getState().selectedPlace?.name === "Dropped pin") {
const current = useStore.getState().selectedPlace
useStore.getState().setSelectedPlace({ ...current, name: data.summary })
}
} else if (!controller.signal.aborted) setLandclass(null)
})
return () => controller.abort()
}, [placeLat, placeLon])
if (!place) return null
const address = buildAddress(place)
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
const elevation = !elevLoading ? elevResult.value : null
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
const existingStopIndex = stops.findIndex((s) => s.lat === place.lat && s.lon === place.lon)
const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon)
const handleDirections = () => {
startDirections(place)
if (geoPermission !== "granted" && stops.length === 0) toast("Set a starting point to get directions", { icon: "\u{1F4CD}" })
}
const handleAddStop = () => {
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
clearSelectedPlace()
}
const handleSave = () => {
if (!hasFeature("has_contacts")) { toast("Saved places coming soon"); return }
if (savedContact) setEditingContact(savedContact)
else setEditingContact({ label: "", lat: place.lat, lon: place.lon, osm_type: osmType || null, osm_id: osmId || null, address: address || "", name: place.type === "poi" && place.raw?.name ? place.raw.name : "" })
}
const closeCopy = useCallback(() => setCopyOpen(false), [])
const stopLetter = stopIndex != null ? String.fromCharCode(65 + stopIndex) : null
if (!expanded) {
return (
<div className="navi-place-card navi-place-card-collapsed flex items-center gap-2 p-2 rounded-lg cursor-pointer" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }} onClick={onToggleExpand}>
{draggable && <div {...dragHandleProps} className="cursor-grab" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
<span className="flex-1 text-sm truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
<ChevronDown size={14} style={{ color: "var(--text-tertiary)" }} />
{onRemove && <button onClick={(e) => { e.stopPropagation(); onRemove() }} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
</div>
)
}
return (
<div className="navi-place-card navi-place-card-expanded flex flex-col rounded-lg p-3" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }}>
<div className="flex items-start justify-between mb-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
{draggable && <div {...dragHandleProps} className="cursor-grab mt-0.5" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
{place.type && <span className="capitalize">{place.type}</span>}
{driveTime != null && <><span>{"\u00b7"}</span><span>{formatDriveTime(driveTime)} drive</span></>}
{nearbyLabel && <><span>{"\u00b7"}</span><span>Near {nearbyLabel}</span></>}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{onToggleExpand && variant === "stop" && <button onClick={onToggleExpand} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><ChevronUp size={14} /></button>}
{onClose && <button onClick={onClose} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
</div>
</div>
{address && <div className="text-xs mb-2" style={{ color: "var(--text-secondary)" }}>{address}</div>}
<div className="flex items-center text-[11px] mb-2" style={{ color: "var(--text-tertiary)" }}>
<span>{place.lat.toFixed(6)}, {place.lon.toFixed(6)}</span>
<span className="mx-2">{"\u00b7"}</span>
<span>{elevLoading ? "..." : elevFeet != null ? elevFeet.toLocaleString() + " ft" : "\u2014"}</span>
</div>
<LandclassSection data={landclass} />
<PrivateLandIndicator data={landclass} />
{placeDetails === "loading" && <EnrichmentSkeleton />}
{placeDetails && placeDetails !== "loading" && <EnrichmentSections details={placeDetails} />}
<div className="mt-3 pt-3 flex gap-2" style={{ borderTop: "1px solid var(--border)" }}>
{variant === "preview" && (
<>
<button onClick={handleDirections} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}><Navigation size={13} />Directions</button>
{existingStopIndex >= 0 ? (
<span className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--accent-muted)", color: "var(--accent)" }}>Stop {String.fromCharCode(65 + existingStopIndex)}</span>
) : (
<button onClick={handleAddStop} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }}><Plus size={13} />Add stop</button>
)}
</>
)}
{variant === "stop" && onRemove && <button onClick={onRemove} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }}><X size={13} />Remove</button>}
<button onClick={handleSave} className="p-2 rounded-lg" style={{ background: savedContact ? "var(--accent-muted)" : "var(--tan-muted)", color: savedContact ? "var(--accent)" : "var(--tan)", border: "1px solid var(--border)" }} aria-label={savedContact ? "Edit saved contact" : "Save place"}><Bookmark size={14} fill={savedContact ? "currentColor" : "none"} /></button>
<div className="relative">
<button onClick={() => setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy"><Copy size={14} /><ChevronDown size={10} /></button>
{copyOpen && <CopyPopover address={address} place={place} onClose={closeCopy} />}
</div>
</div>
</div>
)
}
export default PlaceCard

View file

@ -1,434 +0,0 @@
import { useEffect, useState, useRef, useCallback } from "react"
import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
} from "lucide-react"
import OpeningHours from "opening_hours"
import toast from "react-hot-toast"
import { useStore } from "../store"
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from "../api"
import { hasFeature } from "../config"
import { buildAddress } from "../utils/place"
const M_TO_FT = 3.28084
function formatDriveTime(seconds) {
const mins = Math.round(seconds / 60)
if (mins < 2) return "< 2 min"
if (mins < 120) return `${mins} min`
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
function parseHours(hoursStr) {
try {
const oh = new OpeningHours(hoursStr, { address: { country_code: "us", state: "Idaho" } })
const now = new Date()
const isOpen = oh.getState(now)
const nextChange = oh.getNextChange(now)
let todayStr = ""
if (isOpen) {
todayStr = "Open now"
if (nextChange) {
const closeTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
todayStr += " \u00b7 Closes " + closeTime
}
} else {
todayStr = "Closed"
if (nextChange) {
const openTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
const isTodayOpen = nextChange.getDate() === now.getDate()
todayStr += " \u00b7 Opens " + (isTodayOpen ? "at " : "tomorrow ") + openTime
}
}
const week = []
for (let d = 0; d < 7; d++) {
const date = new Date(now)
const diff = (d - now.getDay() + 7) % 7
date.setDate(now.getDate() + diff)
date.setHours(0, 0, 0, 0)
const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000))
if (intervals.length === 0) {
week.push({ day: DAY_SHORT[d], hours: "Closed", isTodayRow: d === now.getDay() })
} else {
const parts = intervals.map(([start, end]) => {
const s = start.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
const e = end.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
return s + " \u2013 " + e
})
week.push({ day: DAY_SHORT[d], hours: parts.join(", "), isTodayRow: d === now.getDay() })
}
}
return { isOpen, todayStr, week }
} catch {
return null
}
}
function formatPhone(phone) {
if (!phone) return null
const digits = phone.replace(/[^\d]/g, "")
if (digits.length === 11 && digits[0] === "1") {
return "(" + digits.slice(1, 4) + ") " + digits.slice(4, 7) + "-" + digits.slice(7)
}
if (digits.length === 10) {
return "(" + digits.slice(0, 3) + ") " + digits.slice(3, 6) + "-" + digits.slice(6)
}
return phone
}
function wheelchairLabel(val) {
if (!val) return null
const map = { yes: "Accessible", limited: "Limited access", no: "Not accessible" }
return map[val.toLowerCase()] || null
}
function wikiUrl(wp) {
if (!wp) return null
const [lang, ...rest] = wp.split(":")
const title = rest.join(":").replace(/ /g, "_")
return "https://" + lang + ".wikipedia.org/wiki/" + encodeURIComponent(title)
}
function wikiLabel(wp) {
if (!wp) return null
const [, ...rest] = wp.split(":")
return rest.join(":").replace(/_/g, " ")
}
function DetailSection({ label, icon: Icon, first, children }) {
return (
<div className="text-xs" style={{ paddingTop: first ? 0 : "0.5rem", borderTop: first ? "none" : "1px solid var(--border)" }}>
<div className="flex items-center gap-1.5 mb-1.5" style={{ color: "var(--text-tertiary)" }}>
<Icon size={12} />
<span className="uppercase text-[10px] font-medium tracking-wide">{label}</span>
</div>
{children}
</div>
)
}
function HoursDisplay({ hoursStr, first }) {
const [expanded, setExpanded] = useState(false)
const parsed = parseHours(hoursStr)
if (!parsed) return null
const { isOpen, todayStr, week } = parsed
return (
<DetailSection label="Hours" icon={Clock} first={first}>
<button onClick={() => setExpanded((v) => !v)} className="w-full flex items-center justify-between text-xs" style={{ color: "var(--text-primary)" }}>
<span style={{ color: isOpen ? "var(--success)" : "var(--text-tertiary)" }}>{todayStr}</span>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{expanded && (
<div className="mt-2 flex flex-col gap-0.5 text-[11px]">
{week.map((w) => (
<div key={w.day} className="flex justify-between" style={{ color: w.isTodayRow ? "var(--text-primary)" : "var(--text-secondary)", fontWeight: w.isTodayRow ? 500 : 400 }}>
<span>{w.day}</span>
<span>{w.hours}</span>
</div>
))}
</div>
)}
</DetailSection>
)
}
function LandclassSection({ data }) {
if (!data || !data.summary) return null
return (
<div className="mt-2 flex items-start gap-2 text-xs" style={{ color: "var(--text-secondary)" }}>
<Trees size={14} style={{ color: "var(--text-tertiary)", flexShrink: 0, marginTop: 1 }} />
<div className="flex flex-col gap-0.5">
<span>{data.summary}</span>
{data.unit_name && <span style={{ color: "var(--text-tertiary)" }}>{data.unit_name}</span>}
</div>
</div>
)
}
function PrivateLandIndicator({ data }) {
if (!data || data.gap_status !== "4") return null
return (
<div className="mt-2 px-2 py-1.5 rounded text-xs" style={{ background: "var(--warning-muted)", color: "var(--warning)", border: "1px solid var(--warning)" }}>
Private land — permission required
</div>
)
}
function EnrichmentSkeleton() {
return (
<div className="mt-3 flex flex-col gap-3 animate-pulse">
<div className="h-3 rounded w-1/3" style={{ background: "var(--bg-inset)" }} />
<div className="h-3 rounded w-2/3" style={{ background: "var(--bg-inset)" }} />
<div className="h-3 rounded w-1/2" style={{ background: "var(--bg-inset)" }} />
</div>
)
}
function EnrichmentSections({ details }) {
if (!details) return null
const { category, extratags } = details
const et = extratags || {}
const hasAbout = category
const hasHours = et.opening_hours
const hasContact = et.phone || et.website || et.email
const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
const hasLinks = et.wikipedia || et.wikidata
if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
let idx = 0
return (
<div className="mt-3 flex flex-col gap-2.5">
{hasAbout && (
<DetailSection label="About" icon={Info} first={idx++ === 0}>
<span className="category-badge">{category}</span>
</DetailSection>
)}
{hasHours && <HoursDisplay hoursStr={et.opening_hours} first={idx++ === 0} />}
{hasContact && (
<DetailSection label="Contact" icon={Phone} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.phone && <a href={"tel:" + et.phone} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Phone size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{formatPhone(et.phone)}</a>}
{et.website && <a href={et.website.startsWith("http") ? et.website : "https://" + et.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs truncate" style={{ color: "var(--accent)" }}><Globe size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}</a>}
{et.email && <a href={"mailto:" + et.email} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Mail size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.email}</a>}
</div>
</DetailSection>
)}
{hasDetails && (
<DetailSection label="Details" icon={Info} first={idx++ === 0}>
<div className="flex flex-col gap-1 text-xs" style={{ color: "var(--text-secondary)" }}>
{et.cuisine && <span>Cuisine: {et.cuisine.replace(/_/g, " ").replace(/;/g, ", ")}</span>}
{et.operator && <span>Operated by {et.operator}</span>}
{et.fee && <span>{et.fee === "no" ? "Free" : "Fee: " + et.fee}</span>}
{et.wheelchair && wheelchairLabel(et.wheelchair) && <span>{wheelchairLabel(et.wheelchair)}</span>}
{et.takeaway === "yes" && <span>Takeaway available</span>}
</div>
</DetailSection>
)}
{hasLinks && (
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.wikipedia && wikiUrl(et.wikipedia) && <a href={wikiUrl(et.wikipedia)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}><BookOpen size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{wikiLabel(et.wikipedia)}</a>}
{et.wikidata && <a href={"https://www.wikidata.org/wiki/" + et.wikidata} target="_blank" rel="noopener noreferrer" className="text-[11px]" style={{ color: "var(--text-tertiary)", textDecoration: "underline" }}>View on Wikidata</a>}
</div>
</DetailSection>
)}
</div>
)
}
function CopyPopover({ address, place, onClose }) {
const ref = useRef(null)
useEffect(() => {
function handleClick(e) { if (ref.current && !ref.current.contains(e.target)) onClose() }
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [onClose])
const copyAddress = () => {
const text = [place.name, address].filter(Boolean).join("\n")
navigator.clipboard.writeText(text).then(() => toast("Address copied"), () => toast.error("Failed to copy"))
onClose()
}
const copyCoords = () => {
const text = place.lat.toFixed(6) + ", " + place.lon.toFixed(6)
navigator.clipboard.writeText(text).then(() => toast("Coordinates copied"), () => toast.error("Failed to copy"))
onClose()
}
return (
<div ref={ref} className="absolute bottom-full mb-1 right-0 rounded-lg py-1 z-50 min-w-[140px]" style={{ background: "var(--bg-overlay)", border: "1px solid var(--border)", boxShadow: "var(--shadow-lg)" }}>
<button onClick={address ? copyAddress : undefined} disabled={!address} className="w-full text-left px-3 py-1.5 text-xs" style={{ color: address ? "var(--text-primary)" : "var(--text-tertiary)", cursor: address ? "pointer" : "not-allowed" }}>Address</button>
<button onClick={copyCoords} className="w-full text-left px-3 py-1.5 text-xs hover:opacity-80" style={{ color: "var(--text-primary)" }}>Coordinates</button>
</div>
)
}
export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) {
const { contacts, userLocation, stops, geoPermission, addStop, startDirections, clearSelectedPlace, setEditingContact } = useStore()
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null)
const [nearbyLabel, setNearbyLabel] = useState(null)
const [landclass, setLandclass] = useState(null)
const [copyOpen, setCopyOpen] = useState(false)
const placeLat = place?.lat
const placeLon = place?.lon
const osmType = place?.raw?.osm_type
const osmId = place?.raw?.osm_id
const wikidataId = place?.wikidata || place?.raw?.wikidata
useEffect(() => {
if (placeLat == null || placeLon == null) return
let cancelled = false
fetchElevation(placeLat, placeLon).then((h) => { if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h }) })
return () => { cancelled = true }
}, [placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_nominatim_details") || !osmType || !osmId) { setPlaceDetails(null); return }
const controller = new AbortController()
setPlaceDetails("loading")
fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
if (!controller.signal.aborted) {
setPlaceDetails(data || null)
if (data?.boundary) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
}
}
}
})
return () => controller.abort()
}, [osmType, osmId, placeLat, placeLon])
useEffect(() => {
if (osmType && osmId) return
if (!wikidataId) return
const controller = new AbortController()
fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setPlaceDetails((prev) => ({
...(prev === "loading" ? {} : prev || {}),
description: data.description,
population: data.population,
osm_relation_id: data.osm_relation_id,
extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags },
}))
if (data?.boundary) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
}
}
}
})
return () => controller.abort()
}, [wikidataId, osmType, osmId, placeLat, placeLon])
useEffect(() => {
if (variant !== "preview" || !userLocation || placeLat == null || placeLon == null) { setDriveTime(null); return }
setDriveTime(null)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 3000)
fetchDriveTime(userLocation.lat, userLocation.lon, placeLat, placeLon, controller.signal).then((time) => { if (!controller.signal.aborted) setDriveTime(time) })
return () => { controller.abort(); clearTimeout(timeout) }
}, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_contacts") || placeLat == null || placeLon == null) { setNearbyLabel(null); return }
const controller = new AbortController()
fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => {
if (!controller.signal.aborted && nearby.length > 0) setNearbyLabel(nearby[0].label)
else if (!controller.signal.aborted) setNearbyLabel(null)
})
return () => controller.abort()
}, [placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_landclass") || placeLat == null || placeLon == null) { setLandclass(null); return }
const controller = new AbortController()
fetchLandclass(placeLat, placeLon, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setLandclass(data)
if (data.summary && useStore.getState().selectedPlace?.name === "Dropped pin") {
const current = useStore.getState().selectedPlace
useStore.getState().setSelectedPlace({ ...current, name: data.summary })
}
} else if (!controller.signal.aborted) setLandclass(null)
})
return () => controller.abort()
}, [placeLat, placeLon])
if (!place) return null
const address = buildAddress(place)
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
const elevation = !elevLoading ? elevResult.value : null
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
const existingStopIndex = stops.findIndex((s) => s.lat === place.lat && s.lon === place.lon)
const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon)
const handleDirections = () => {
// No toast - empty origin slot is the visual prompt
startDirections(place)
}
const handleAddStop = () => {
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
clearSelectedPlace()
}
const handleSave = () => {
if (!hasFeature("has_contacts")) { toast("Saved places coming soon"); return }
if (savedContact) setEditingContact(savedContact)
else setEditingContact({ label: "", lat: place.lat, lon: place.lon, osm_type: osmType || null, osm_id: osmId || null, address: address || "", name: place.type === "poi" && place.raw?.name ? place.raw.name : "" })
}
const closeCopy = useCallback(() => setCopyOpen(false), [])
const stopLetter = stopIndex != null ? String.fromCharCode(65 + stopIndex) : null
if (!expanded) {
return (
<div className="navi-place-card navi-place-card-collapsed flex items-center gap-2 p-2 rounded-lg cursor-pointer" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }} onClick={onToggleExpand}>
{draggable && <div {...dragHandleProps} className="cursor-grab" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
<span className="flex-1 text-sm truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
<ChevronDown size={14} style={{ color: "var(--text-tertiary)" }} />
{onRemove && <button onClick={(e) => { e.stopPropagation(); onRemove() }} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
</div>
)
}
return (
<div className="navi-place-card navi-place-card-expanded flex flex-col rounded-lg p-3" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }}>
<div className="flex items-start justify-between mb-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
{draggable && <div {...dragHandleProps} className="cursor-grab mt-0.5" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
{place.type && <span className="capitalize">{place.type}</span>}
{driveTime != null && <><span>{"\u00b7"}</span><span>{formatDriveTime(driveTime)} drive</span></>}
{nearbyLabel && <><span>{"\u00b7"}</span><span>Near {nearbyLabel}</span></>}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{onToggleExpand && variant === "stop" && <button onClick={onToggleExpand} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><ChevronUp size={14} /></button>}
{onClose && <button onClick={onClose} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
</div>
</div>
{address && <div className="text-xs mb-2" style={{ color: "var(--text-secondary)" }}>{address}</div>}
<div className="flex items-center text-[11px] mb-2" style={{ color: "var(--text-tertiary)" }}>
<span>{place.lat.toFixed(6)}, {place.lon.toFixed(6)}</span>
<span className="mx-2">{"\u00b7"}</span>
<span>{elevLoading ? "..." : elevFeet != null ? elevFeet.toLocaleString() + " ft" : "\u2014"}</span>
</div>
<LandclassSection data={landclass} />
<PrivateLandIndicator data={landclass} />
{placeDetails === "loading" && <EnrichmentSkeleton />}
{placeDetails && placeDetails !== "loading" && <EnrichmentSections details={placeDetails} />}
<div className="mt-3 pt-3 flex gap-2" style={{ borderTop: "1px solid var(--border)" }}>
{variant === "preview" && (
<>
<button onClick={handleDirections} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}><Navigation size={13} />Get Directions</button>
{existingStopIndex >= 0 ? (
<span className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--accent-muted)", color: "var(--accent)" }}>Stop {String.fromCharCode(65 + existingStopIndex)}</span>
) : (
<button onClick={handleAddStop} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }}><Plus size={13} />Add stop</button>
)}
</>
)}
{variant === "stop" && onRemove && <button onClick={onRemove} className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }}><X size={13} />Remove</button>}
<button onClick={handleSave} className="p-2 rounded-lg" style={{ background: savedContact ? "var(--accent-muted)" : "var(--tan-muted)", color: savedContact ? "var(--accent)" : "var(--tan)", border: "1px solid var(--border)" }} aria-label={savedContact ? "Edit saved contact" : "Save place"}><Bookmark size={14} fill={savedContact ? "currentColor" : "none"} /></button>
<div className="relative">
<button onClick={() => setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy"><Copy size={14} /><ChevronDown size={10} /></button>
{copyOpen && <CopyPopover address={address} place={place} onClose={closeCopy} />}
</div>
</div>
</div>
)
}
export default PlaceCard

View file

@ -551,7 +551,7 @@ export default function PlaceDetail() {
// Fetch nearby contacts for proximity annotation // Fetch nearby contacts for proximity annotation
useEffect(() => { useEffect(() => {
if (!hasFeature('has_contacts') || placeLat == null || placeLon == null) { if (!hasFeature('has_contacts') || !auth.authenticated || placeLat == null || placeLon == null) {
setNearbyLabel(null) setNearbyLabel(null)
return return
} }

View file

@ -1,798 +0,0 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees,
} from 'lucide-react'
import OpeningHours from 'opening_hours'
import toast from 'react-hot-toast'
import { useStore } from '../store'
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from '../api'
import { hasFeature } from '../config'
import { buildAddress } from '../utils/place'
/** Meters to feet */
const M_TO_FT = 3.28084
/** Format drive time (seconds) to human-readable string */
function formatDriveTime(seconds) {
const mins = Math.round(seconds / 60)
if (mins < 2) return '< 2 min drive'
if (mins < 120) return `${mins} min drive`
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h}h ${m}m drive` : `${h}h drive`
}
// ── Opening hours helpers ──────────────────────────────────────────────
const DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function parseHours(hoursStr) {
try {
const oh = new OpeningHours(hoursStr, { address: { country_code: 'us', state: 'Idaho' } })
const now = new Date()
const isOpen = oh.getState(now)
const nextChange = oh.getNextChange(now)
let todayStr = ''
if (isOpen) {
todayStr = 'Open now'
if (nextChange) {
const closeTime = nextChange.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
todayStr += ` \u00b7 Closes at ${closeTime}`
}
} else {
todayStr = 'Closed'
if (nextChange) {
const openTime = nextChange.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
const isToday = nextChange.getDate() === now.getDate()
todayStr += ` \u00b7 Opens ${isToday ? 'at' : 'tomorrow at'} ${openTime}`
}
}
const week = []
for (let d = 0; d < 7; d++) {
const date = new Date(now)
const diff = (d - now.getDay() + 7) % 7
date.setDate(now.getDate() + diff)
date.setHours(0, 0, 0, 0)
const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000))
if (intervals.length === 0) {
week.push({ day: DAY_SHORT[d], hours: 'Closed', isToday: d === now.getDay() })
} else {
const parts = intervals.map(([start, end]) => {
const s = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
const e = end.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
return `${s} \u2013 ${e}`
})
week.push({ day: DAY_SHORT[d], hours: parts.join(', '), isToday: d === now.getDay() })
}
}
return { isOpen, todayStr, week }
} catch {
return null
}
}
// ── Formatting helpers ─────────────────────────────────────────────────
function formatPhone(phone) {
if (!phone) return null
const digits = phone.replace(/[^\d]/g, '')
if (digits.length === 11 && digits[0] === '1') {
return `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`
}
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`
}
return phone
}
function wheelchairLabel(val) {
if (!val) return null
const map = { yes: 'Accessible', limited: 'Limited access', no: 'Not accessible' }
return map[val.toLowerCase()] || null
}
function wikiUrl(wp) {
if (!wp) return null
const match = wp.match(/^([a-z-]+):(.+)$/)
if (!match) return null
return `https://${match[1]}.wikipedia.org/wiki/${encodeURIComponent(match[2].replace(/ /g, '_'))}`
}
function wikiLabel(wp) {
if (!wp) return null
const match = wp.match(/^[a-z-]+:(.+)$/)
return match ? match[1].replace(/_/g, ' ') : wp
}
// ── Section wrapper ────────────────────────────────────────────────────
function DetailSection({ label, icon: Icon, first, children }) {
return (
<div
className="place-detail-section"
style={first ? {} : { borderTop: '1px solid var(--border-subtle)', paddingTop: '10px' }}
>
<div className="place-detail-section-header">
{Icon && <Icon size={12} style={{ opacity: 0.6 }} />}
<span>{label}</span>
</div>
{children}
</div>
)
}
// ── Hours display ──────────────────────────────────────────────────────
function HoursDisplay({ hoursStr, first }) {
const [expanded, setExpanded] = useState(false)
const parsed = parseHours(hoursStr)
if (!parsed) {
return (
<DetailSection label="Hours" icon={Clock} first={first}>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>{hoursStr}</p>
</DetailSection>
)
}
return (
<DetailSection label="Hours" icon={Clock} first={first}>
<button
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between text-xs"
style={{ color: 'var(--text-primary)' }}
>
<span>
<span
className="inline-block w-1.5 h-1.5 rounded-full mr-1.5"
style={{ background: parsed.isOpen ? 'var(--accent)' : 'var(--tan)' }}
/>
{parsed.todayStr}
</span>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{expanded && (
<div className="mt-2 flex flex-col gap-0.5">
{parsed.week.map((d) => (
<div
key={d.day}
className="flex justify-between text-xs"
style={{
color: d.isToday ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: d.isToday ? 600 : 400,
}}
>
<span>{d.day}</span>
<span>{d.hours}</span>
</div>
))}
</div>
)}
</DetailSection>
)
}
// ── Copy popover ───────────────────────────────────────────────────────
function CopyPopover({ address, selectedPlace, onClose }) {
const ref = useRef(null)
useEffect(() => {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) onClose()
}
function handleKey(e) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleKey)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleKey)
}
}, [onClose])
const copyAddress = () => {
const text = [selectedPlace.name, address].filter(Boolean).join('\n')
navigator.clipboard.writeText(text).then(
() => toast('Address copied'),
() => toast.error('Failed to copy')
)
onClose()
}
const copyCoords = () => {
const text = `${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}`
navigator.clipboard.writeText(text).then(
() => toast('Coordinates copied'),
() => toast.error('Failed to copy')
)
onClose()
}
return (
<div
ref={ref}
className="absolute bottom-full mb-1 right-0 rounded-lg py-1 z-50 min-w-[140px]"
style={{
background: 'var(--bg-overlay)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
}}
>
<button
onClick={address ? copyAddress : undefined}
disabled={!address}
className="w-full text-left px-3 py-1.5 text-xs"
style={{
color: address ? 'var(--text-primary)' : 'var(--text-tertiary)',
cursor: address ? 'pointer' : 'not-allowed',
}}
title={!address ? 'No address available' : undefined}
>
Address
</button>
<button
onClick={copyCoords}
className="w-full text-left px-3 py-1.5 text-xs hover:opacity-80"
style={{ color: 'var(--text-primary)' }}
>
Coordinates
</button>
</div>
)
}
// ── Enrichment sections ────────────────────────────────────────────────
function EnrichmentSections({ details }) {
if (!details) return null
const { category, extratags } = details
const et = extratags || {}
const hasAbout = category
const hasHours = et.opening_hours
const hasContact = et.phone || et.website || et.email
const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
const hasLinks = et.wikipedia || et.wikidata
if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
let idx = 0
return (
<div className="mt-3 flex flex-col gap-2.5">
{hasAbout && (
<DetailSection label="About" icon={Info} first={idx++ === 0}>
<span className="category-badge">{category}</span>
</DetailSection>
)}
{hasHours && <HoursDisplay hoursStr={et.opening_hours} first={idx++ === 0} />}
{hasContact && (
<DetailSection label="Contact" icon={Phone} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.phone && (
<a href={`tel:${et.phone}`} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-primary)' }}>
<Phone size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{formatPhone(et.phone)}
</a>
)}
{et.website && (
<a
href={et.website.startsWith('http') ? et.website : `https://${et.website}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs truncate"
style={{ color: 'var(--accent)' }}
>
<Globe size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{et.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
)}
{et.email && (
<a href={`mailto:${et.email}`} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-primary)' }}>
<Mail size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{et.email}
</a>
)}
</div>
</DetailSection>
)}
{hasDetails && (
<DetailSection label="Details" icon={Info} first={idx++ === 0}>
<div className="flex flex-col gap-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
{et.cuisine && <span>Cuisine: {et.cuisine.replace(/_/g, ' ').replace(/;/g, ', ')}</span>}
{et.operator && <span>Operated by {et.operator}</span>}
{et.fee && <span>{et.fee === 'no' ? 'Free' : `Fee: ${et.fee}`}</span>}
{et.wheelchair && wheelchairLabel(et.wheelchair) && <span>{wheelchairLabel(et.wheelchair)}</span>}
{et.takeaway === 'yes' && <span>Takeaway available</span>}
</div>
</DetailSection>
)}
{hasLinks && (
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.wikipedia && wikiUrl(et.wikipedia) && (
<a
href={wikiUrl(et.wikipedia)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs"
style={{ color: 'var(--accent)' }}
>
<BookOpen size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{wikiLabel(et.wikipedia)}
</a>
)}
{et.wikidata && (
<a
href={`https://www.wikidata.org/wiki/${et.wikidata}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs font-mono"
style={{ color: 'var(--text-tertiary)' }}
>
Wikidata: {et.wikidata}
</a>
)}
</div>
</DetailSection>
)}
</div>
)
}
// ── Skeleton loader ────────────────────────────────────────────────────
// ── Land classification display ──────────────────────────────────────────────────────────────────────
function LandclassSection({ data }) {
if (!data || data.is_public !== true || !data.classifications?.length) return null
return (
<DetailSection label="Public Land" icon={Trees}>
<div className="flex flex-col gap-2">
{data.classifications.map((c, i) => (
<div key={i} className="flex flex-col gap-0.5">
<span className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
{c.unit_name}
</span>
{(c.owner_type || c.manager_name || c.designation_type) && (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{[c.owner_type, c.manager_name, c.designation_type].filter(Boolean).join(' \u203a ')}
</span>
)}
{c.public_access && c.public_access !== 'Unknown' && (
<span className="category-badge" style={{ fontSize: '10px', width: 'fit-content' }}>
{c.public_access}
</span>
)}
</div>
))}
</div>
</DetailSection>
)
}
function PrivateLandIndicator({ data }) {
if (!data || data.is_private !== true) return null
return (
<p className="mt-1 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
Private land
</p>
)
}
function EnrichmentSkeleton() {
return (
<div className="mt-3 flex flex-col gap-2.5 animate-pulse">
<div className="h-3 rounded w-16" style={{ background: 'var(--border-subtle)' }} />
<div className="h-3 rounded w-32" style={{ background: 'var(--border-subtle)' }} />
<div className="h-3 rounded w-24" style={{ background: 'var(--border-subtle)' }} />
</div>
)
}
// ── Main component ─────────────────────────────────────────────────────
export default function PlaceDetail() {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const startDirections = useStore((s) => s.startDirections)
const addStop = useStore((s) => s.addStop)
const stops = useStore((s) => s.stops)
const geoPermission = useStore((s) => s.geoPermission)
const userLocation = useStore((s) => s.userLocation)
const contacts = useStore((s) => s.contacts)
const setEditingContact = useStore((s) => s.setEditingContact)
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [isMobile, setIsMobile] = useState(false)
const [copyOpen, setCopyOpen] = useState(false)
const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null)
const [nearbyLabel, setNearbyLabel] = useState(null)
const [landclass, setLandclass] = useState(null)
const closeCopy = useCallback(() => setCopyOpen(false), [])
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Close copy popover when place changes
useEffect(() => { setCopyOpen(false) }, [selectedPlace])
// Escape key closes panel
useEffect(() => {
if (!selectedPlace) return
function handleKey(e) {
if (e.key === 'Escape') clearSelectedPlace()
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [selectedPlace, clearSelectedPlace])
// Fetch elevation when place changes
const placeLat = selectedPlace?.lat
const placeLon = selectedPlace?.lon
useEffect(() => {
if (placeLat == null || placeLon == null) return
let cancelled = false
fetchElevation(placeLat, placeLon).then((h) => {
if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h })
})
return () => { cancelled = true }
}, [placeLat, placeLon])
// Fetch place details when place changes (if feature enabled)
const osmType = selectedPlace?.raw?.osm_type
const osmId = selectedPlace?.raw?.osm_id
useEffect(() => {
if (!hasFeature('has_nominatim_details') || !osmType || !osmId) {
setPlaceDetails(null)
return
}
const controller = new AbortController()
setPlaceDetails('loading')
fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
if (!controller.signal.aborted) {
setPlaceDetails(data || null)
}
})
return () => controller.abort()
}, [osmType, osmId])
// Fetch wikidata enrichment when place has wikidata but no OSM details
const wikidataId = selectedPlace?.wikidata || selectedPlace?.raw?.wikidata
useEffect(() => {
// Skip if OSM details are available (they provide richer data)
if (osmType && osmId) return
// Skip if no wikidata ID
if (!wikidataId) return
const controller = new AbortController()
fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
// Merge wikidata info into placeDetails (description, population, etc.)
setPlaceDetails((prev) => ({
...(prev === 'loading' ? {} : prev || {}),
description: data.description,
population: data.population,
osm_relation_id: data.osm_relation_id,
extratags: {
...(prev && prev !== 'loading' ? prev.extratags : {}),
...data.extratags,
},
}))
}
})
return () => controller.abort()
}, [wikidataId, osmType, osmId])
// Fetch drive time when place or user location changes
useEffect(() => {
if (!userLocation || placeLat == null || placeLon == null) {
setDriveTime(null)
return
}
setDriveTime(null)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 3000)
fetchDriveTime(
userLocation.lat, userLocation.lon,
placeLat, placeLon,
controller.signal
).then((time) => {
if (!controller.signal.aborted) setDriveTime(time)
})
return () => {
controller.abort()
clearTimeout(timeout)
}
}, [userLocation?.lat, userLocation?.lon, placeLat, placeLon])
// Fetch nearby contacts for proximity annotation
useEffect(() => {
if (!hasFeature('has_contacts') || placeLat == null || placeLon == null) {
setNearbyLabel(null)
return
}
const controller = new AbortController()
fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => {
if (!controller.signal.aborted && nearby.length > 0) {
setNearbyLabel(nearby[0].label)
} else if (!controller.signal.aborted) {
setNearbyLabel(null)
}
})
return () => controller.abort()
}, [placeLat, placeLon])
// Fetch land classification when place changes (if feature enabled)
useEffect(() => {
if (!hasFeature('has_landclass') || placeLat == null || placeLon == null) {
setLandclass(null)
return
}
const controller = new AbortController()
fetchLandclass(placeLat, placeLon, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setLandclass(data)
// Upgrade "Dropped pin" name to land summary if reverse geocode didn't resolve
if (data.summary && useStore.getState().selectedPlace?.name === 'Dropped pin') {
const current = useStore.getState().selectedPlace
useStore.getState().setSelectedPlace({ ...current, name: data.summary })
}
} else if (!controller.signal.aborted) {
setLandclass(null)
}
})
return () => controller.abort()
}, [placeLat, placeLon])
// Derive elevation/loading from comparing result to current place
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
const elevation = !elevLoading ? elevResult.value : null
if (!selectedPlace) return null
const address = buildAddress(selectedPlace)
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
// Check if place is already in stops
const existingStopIndex = stops.findIndex(
(s) => Math.abs(s.lat - selectedPlace.lat) < 0.00001 && Math.abs(s.lon - selectedPlace.lon) < 0.00001
)
// Check if place is already saved as a contact
const savedContact = hasFeature('has_contacts')
? contacts.find((c) => {
if (c.osm_type && c.osm_id && osmType && osmId) {
return c.osm_type === osmType && c.osm_id === osmId
}
if (c.lat != null && c.lon != null) {
return Math.abs(c.lat - selectedPlace.lat) < 0.0001 && Math.abs(c.lon - selectedPlace.lon) < 0.0001
}
return false
})
: null
const handleDirections = () => {
startDirections(selectedPlace)
if (geoPermission !== 'granted' && stops.length === 0) {
toast('Set a starting point to get directions', { icon: '\u{1F4CD}' })
}
}
const handleAddStop = () => {
addStop({
lat: selectedPlace.lat,
lon: selectedPlace.lon,
name: selectedPlace.name,
source: selectedPlace.source,
matchCode: selectedPlace.matchCode,
})
clearSelectedPlace()
}
const handleSave = () => {
if (!hasFeature('has_contacts')) {
toast('Saved places coming soon')
return
}
if (savedContact) {
// Edit existing contact
setEditingContact(savedContact)
} else {
// New contact pre-populated from place
setEditingContact({
label: '',
lat: selectedPlace.lat,
lon: selectedPlace.lon,
osm_type: osmType || null,
osm_id: osmId || null,
address: address || '',
name: selectedPlace.type === 'poi' && selectedPlace.raw?.name ? selectedPlace.raw.name : '',
})
}
}
const panelContent = (
<>
{/* Close button */}
<button
onClick={clearSelectedPlace}
className="absolute top-3 right-3 p-1 rounded"
style={{ color: 'var(--text-tertiary)' }}
aria-label="Close detail panel"
>
<X size={18} />
</button>
{/* Place name */}
<div className="pr-8">
<h2 className="text-md font-semibold" style={{ color: 'var(--text-primary)' }}>
{selectedPlace.type === 'poi' && selectedPlace.raw?.name
? selectedPlace.raw.name
: selectedPlace.name}
</h2>
{(() => {
const cat = placeDetails && placeDetails !== 'loading' ? placeDetails.category : null
const parts = []
if (cat) parts.push(cat)
if (nearbyLabel) parts.push(`near ${nearbyLabel}`)
if (driveTime != null) parts.push(formatDriveTime(driveTime))
if (parts.length === 0) return null
return (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{parts.join(' \u00b7 ')}
</span>
)
})()}
</div>
{/* Address */}
{address && (
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
{address}
</p>
)}
{/* Coordinates + elevation */}
<div className="mt-3 font-mono text-xs" style={{ color: 'var(--text-secondary)' }}>
<span>{selectedPlace.lat.toFixed(6)}, {selectedPlace.lon.toFixed(6)}</span>
<span className="mx-2">&middot;</span>
<span>
{elevLoading ? '...' : elevFeet != null ? `${elevFeet.toLocaleString()} ft` : '\u2014'}
</span>
</div>
{/* OSM enrichment sections */}
{/* Land classification (PAD-US) */}
<LandclassSection data={landclass} />
<PrivateLandIndicator data={landclass} />
{/* OSM enrichment sections */}
{placeDetails === 'loading' && <EnrichmentSkeleton />}
{placeDetails && placeDetails !== 'loading' && <EnrichmentSections details={placeDetails} />}
{/* Action buttons */}
<div className="mt-auto pt-4 flex gap-2">
<button
onClick={handleDirections}
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
>
<Navigation size={13} />
Directions
</button>
{existingStopIndex >= 0 ? (
<span
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
>
Added as stop {String.fromCharCode(65 + existingStopIndex)}
</span>
) : (
<button
onClick={handleAddStop}
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
>
<Plus size={13} />
Add stop
</button>
)}
<button
onClick={handleSave}
className="p-2 rounded-lg"
style={{
background: savedContact ? 'var(--accent-muted)' : 'var(--tan-muted)',
color: savedContact ? 'var(--accent)' : 'var(--tan)',
border: '1px solid var(--border)',
}}
aria-label={savedContact ? 'Edit saved contact' : 'Save place'}
>
<Bookmark size={14} fill={savedContact ? 'currentColor' : 'none'} />
</button>
{/* Copy dropdown */}
<div className="relative">
<button
onClick={() => setCopyOpen((v) => !v)}
className="p-2 rounded-lg flex items-center gap-0.5"
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
aria-label="Copy"
>
<Copy size={14} />
<ChevronDown size={10} />
</button>
{copyOpen && (
<CopyPopover
address={address}
selectedPlace={selectedPlace}
onClose={closeCopy}
/>
)}
</div>
</div>
</>
)
// Mobile: bottom overlay
if (isMobile) {
return (
<div
className="navi-place-detail navi-place-detail-active fixed bottom-0 left-0 right-0 z-20 p-4 rounded-t-2xl flex flex-col"
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
maxHeight: '60vh',
overflowY: 'auto',
}}
>
{panelContent}
</div>
)
}
// Desktop: side panel
return (
<div
className="navi-place-detail navi-place-detail-active absolute top-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
left: '20rem',
width: '360px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{panelContent}
</div>
)
}

View file

@ -287,7 +287,6 @@ export default function RadialMenu({
font-size: 9px; font-size: 9px;
fill: var(--text-secondary); fill: var(--text-secondary);
pointer-events: none; pointer-events: none;
user-select: none;
transition: fill 100ms ease; transition: fill 100ms ease;
} }

View file

@ -1,351 +0,0 @@
import { useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Lock } from 'lucide-react'
/**
* RadialMenu - ATAK-style radial context menu
* Themed to match Navi light/dark palette using CSS custom properties.
*
* Props:
* - open: boolean
* - x, y: screen coordinates of trigger point
* - lat, lon: geographic coordinates
* - wedges: array of { id, label, icon: LucideIcon, onSelect, requiresAuth? }
* - centerLabel: string (coords by default, replaced by reverse-geocode async)
* - onDismiss: callback when menu should close
*/
export default function RadialMenu({
open,
x,
y,
lat,
lon,
wedges = [],
centerLabel,
onDismiss,
}) {
const containerRef = useRef(null)
const activeWedgeRef = useRef(null)
// Geometry constants
const outerRadius = 80
const innerRadius = 40
const wedgeCount = wedges.length || 6
const wedgeAngle = 360 / wedgeCount
// Handle escape key
useEffect(() => {
if (!open) return
const handleKey = (e) => {
if (e.key === 'Escape') {
onDismiss?.()
}
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [open, onDismiss])
// Calculate which wedge the pointer is over
const getWedgeAtPoint = useCallback((clientX, clientY) => {
const dx = clientX - x
const dy = clientY - y
const dist = Math.sqrt(dx * dx + dy * dy)
// Inside inner radius = center (no wedge)
if (dist < innerRadius) return null
// Outside outer radius = no wedge
if (dist > outerRadius + 20) return null
// Calculate angle (0 = top, clockwise)
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
if (angle < 0) angle += 360
// Find which wedge
const wedgeIndex = Math.floor(angle / wedgeAngle)
return wedges[wedgeIndex] || null
}, [x, y, wedges, wedgeAngle])
// Handle mouse/touch move for highlighting
const handlePointerMove = useCallback((e) => {
const clientX = e.touches ? e.touches[0].clientX : e.clientX
const clientY = e.touches ? e.touches[0].clientY : e.clientY
activeWedgeRef.current = getWedgeAtPoint(clientX, clientY)
// Force re-render for highlight
containerRef.current?.querySelectorAll('.radial-wedge').forEach((el, i) => {
if (wedges[i] && wedges[i].id === activeWedgeRef.current?.id) {
el.classList.add('active')
} else {
el.classList.remove('active')
}
})
}, [getWedgeAtPoint, wedges])
// Handle release
const handlePointerUp = useCallback((e) => {
const clientX = e.changedTouches ? e.changedTouches[0].clientX : e.clientX
const clientY = e.changedTouches ? e.changedTouches[0].clientY : e.clientY
const wedge = getWedgeAtPoint(clientX, clientY)
if (wedge) {
wedge.onSelect?.({ lat, lon })
}
onDismiss?.()
}, [getWedgeAtPoint, lat, lon, onDismiss])
// Handle backdrop click (dismiss menu)
const handleBackdropClick = useCallback((e) => {
e.stopPropagation()
onDismiss?.()
}, [onDismiss])
// Prevent menu container clicks from reaching backdrop
const handleContainerClick = useCallback((e) => {
e.stopPropagation()
}, [])
// Generate wedge paths
const generateWedgePath = (index) => {
const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180)
const endAngle = ((index + 1) * wedgeAngle - 90) * (Math.PI / 180)
const x1 = innerRadius * Math.cos(startAngle)
const y1 = innerRadius * Math.sin(startAngle)
const x2 = outerRadius * Math.cos(startAngle)
const y2 = outerRadius * Math.sin(startAngle)
const x3 = outerRadius * Math.cos(endAngle)
const y3 = outerRadius * Math.sin(endAngle)
const x4 = innerRadius * Math.cos(endAngle)
const y4 = innerRadius * Math.sin(endAngle)
return `M ${x1} ${y1} L ${x2} ${y2} A ${outerRadius} ${outerRadius} 0 0 1 ${x3} ${y3} L ${x4} ${y4} A ${innerRadius} ${innerRadius} 0 0 0 ${x1} ${y1} Z`
}
// Calculate icon position for each wedge
const getIconPosition = (index) => {
const midAngle = ((index + 0.5) * wedgeAngle - 90) * (Math.PI / 180)
const r = (innerRadius + outerRadius) / 2
return {
x: r * Math.cos(midAngle),
y: r * Math.sin(midAngle),
}
}
if (!open) return null
// Clamp position to viewport
const padding = outerRadius + 20
const clampedX = Math.max(padding, Math.min(window.innerWidth - padding, x))
const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y))
const content = (
<>
{/* Full-screen backdrop for dismiss — matches modal overlay opacity */}
<div
className="radial-backdrop"
onClick={handleBackdropClick}
onContextMenu={handleBackdropClick}
/>
{/* Radial menu container */}
<div
ref={containerRef}
className="radial-menu-container"
onClick={handleContainerClick}
style={{
position: 'fixed',
left: clampedX,
top: clampedY,
zIndex: 9999,
transform: 'translate(-50%, -50%)',
animation: 'radialFadeIn 100ms ease-out',
filter: 'drop-shadow(var(--shadow-lg))',
}}
onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp}
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp}
>
<svg
width={outerRadius * 2 + 40}
height={outerRadius * 2 + 40}
viewBox={`${-outerRadius - 20} ${-outerRadius - 20} ${outerRadius * 2 + 40} ${outerRadius * 2 + 40}`}
style={{ overflow: 'visible' }}
>
{/* Wedges */}
{wedges.map((wedge, i) => {
const iconPos = getIconPosition(i)
const Icon = wedge.icon
return (
<g key={wedge.id} className="radial-wedge" data-wedge-id={wedge.id}>
<path
d={generateWedgePath(i)}
className="wedge-path"
/>
<g transform={`translate(${iconPos.x}, ${iconPos.y})`}>
{Icon && (
<foreignObject
x={-9}
y={-12}
width={18}
height={18}
style={{ overflow: 'visible' }}
>
<Icon
size={18}
className="wedge-icon"
strokeWidth={1.5}
/>
</foreignObject>
)}
{wedge.requiresAuth && (
<foreignObject
x={4}
y={-14}
width={10}
height={10}
style={{ overflow: 'visible' }}
>
<Lock
size={10}
className="wedge-lock"
strokeWidth={1.5}
/>
</foreignObject>
)}
<text
y={10}
textAnchor="middle"
className="wedge-label"
>
{wedge.label}
</text>
</g>
</g>
)
})}
{/* Center disc */}
<circle
cx={0}
cy={0}
r={innerRadius - 2}
className="center-disc"
/>
<text
y={-4}
textAnchor="middle"
className="center-coords"
>
{lat?.toFixed(4)}
</text>
<text
y={8}
textAnchor="middle"
className="center-coords"
>
{lon?.toFixed(4)}
</text>
{centerLabel && (
<text
y={20}
textAnchor="middle"
className="center-label"
>
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel}
</text>
)}
</svg>
<style>{`
/* Backdrop — matches modal overlay */
.radial-backdrop {
position: fixed;
inset: 0;
z-index: 9998;
background: rgba(0, 0, 0, 0.4);
cursor: default;
}
/* Wedge paths — themed surface */
.wedge-path {
fill: var(--bg-overlay);
fill-opacity: 0.92;
stroke: var(--border);
stroke-width: 1;
transition: fill 100ms ease, fill-opacity 100ms ease;
}
.radial-wedge:hover .wedge-path {
fill: var(--accent-muted);
fill-opacity: 1;
}
.radial-wedge.active .wedge-path {
fill: var(--accent-muted);
fill-opacity: 1;
}
/* Wedge icons — secondary text color */
.wedge-icon {
color: var(--text-secondary);
transition: color 100ms ease;
}
.radial-wedge:hover .wedge-icon,
.radial-wedge.active .wedge-icon {
color: var(--text-primary);
}
/* Lock icon — tertiary/muted */
.wedge-lock {
color: var(--text-tertiary);
}
/* Wedge labels — secondary text */
.wedge-label {
font-family: var(--font-sans);
font-size: 9px;
fill: var(--text-secondary);
pointer-events: none;
user-select: none;
transition: fill 100ms ease;
}
.radial-wedge:hover .wedge-label,
.radial-wedge.active .wedge-label {
fill: var(--text-primary);
}
/* Center disc — raised surface */
.center-disc {
fill: var(--bg-raised);
stroke: var(--border);
stroke-width: 1;
}
/* Center coordinates — monospace primary */
.center-coords {
font-family: var(--font-mono);
font-size: 10px;
fill: var(--text-primary);
}
/* Center label — secondary italic */
.center-label {
font-family: var(--font-sans);
font-size: 9px;
font-style: italic;
fill: var(--text-secondary);
}
@keyframes radialFadeIn {
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
`}</style>
</div>
</>
)
return createPortal(content, document.body)
}

View file

@ -1,320 +0,0 @@
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X, User } from 'lucide-react'
import toast from 'react-hot-toast'
import { useStore } from '../store'
import { buildAddress } from '../utils/place'
import { searchGeocode } from '../api'
import { hasFeature } from '../config'
/** Get category icon based on result type/source */
function CategoryIcon({ result }) {
const type = result.type || ''
const source = result.source || ''
const size = 14
if (result._isContact) return <User size={size} />
if (source === 'nickname') return <Star size={size} />
if (type === 'coordinates') return <Crosshair size={size} />
if (type === 'locality' || type === 'city') return <Building2 size={size} />
// POI subcategories from osm_value if available
const osmVal = result.raw?.osm_value || ''
if (osmVal.includes('cafe') || osmVal.includes('coffee')) return <Coffee size={size} />
if (osmVal.includes('fuel') || osmVal.includes('gas')) return <Fuel size={size} />
if (osmVal.includes('shop') || osmVal.includes('supermarket')) return <ShoppingBag size={size} />
if (osmVal.includes('hotel') || osmVal.includes('motel')) return <Hotel size={size} />
return <MapPin size={size} />
}
const SearchBar = forwardRef(function SearchBar(_, ref) {
const inputRef = useRef(null)
const [activeIndex, setActiveIndex] = useState(-1)
const debounceRef = useRef(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
}))
const query = useStore((s) => s.query)
const results = useStore((s) => s.results)
const searchLoading = useStore((s) => s.searchLoading)
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
const stops = useStore((s) => s.stops)
const pendingDestination = useStore((s) => s.pendingDestination)
const contacts = useStore((s) => s.contacts)
const setQuery = useStore((s) => s.setQuery)
const setResults = useStore((s) => s.setResults)
const setSearchLoading = useStore((s) => s.setSearchLoading)
const setAbortController = useStore((s) => s.setAbortController)
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
const addStop = useStore((s) => s.addStop)
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
const setEditingContact = useStore((s) => s.setEditingContact)
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
const mapCenter = useStore((s) => s.mapCenter)
useEffect(() => {
inputRef.current?.focus()
}, [])
const doSearch = useCallback(
async (q) => {
const prev = useStore.getState().abortController
if (prev) prev.abort()
if (!q.trim()) {
setResults([])
setAutocompleteOpen(false)
setSearchLoading(false)
return
}
// Prepend matching contacts
let contactResults = []
if (hasFeature('has_contacts') && contacts.length > 0) {
const lower = q.trim().toLowerCase()
contactResults = contacts
.filter((c) =>
(c.label || '').toLowerCase().startsWith(lower) ||
(c.name || '').toLowerCase().startsWith(lower) ||
(c.call_sign || '').toLowerCase().startsWith(lower)
)
.slice(0, 3)
.map((c) => ({
lat: c.lat,
lon: c.lon,
name: c.label,
address: c.address || c.name || '',
type: 'contact',
source: 'contacts',
match_code: null,
raw: { osm_type: c.osm_type, osm_id: c.osm_id, contact: c },
_isContact: true,
}))
}
const ctrl = new AbortController()
setAbortController(ctrl)
setSearchLoading(true)
try {
const data = await searchGeocode(q.trim(), 6, ctrl.signal, mapCenter)
const combined = [...contactResults, ...(data.results || [])]
setResults(combined)
setAutocompleteOpen(combined.length > 0)
setActiveIndex(-1)
} catch (e) {
if (e.name !== 'AbortError') {
// Still show contacts even if geocode fails
if (contactResults.length > 0) {
setResults(contactResults)
setAutocompleteOpen(true)
} else {
setResults([])
setAutocompleteOpen(false)
}
}
} finally {
setSearchLoading(false)
}
},
[setResults, setAutocompleteOpen, setSearchLoading, setAbortController, contacts]
)
const handleChange = (e) => {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => doSearch(val), 150)
}
const handleClear = () => {
setQuery('')
setResults([])
setAutocompleteOpen(false)
inputRef.current?.focus()
}
const selectResult = (result) => {
const { pendingDestination: pending } = useStore.getState()
// Pure contact (no geo) → open edit modal
if (result._isContact && result.lat == null) {
setEditingContact(result.raw.contact)
setQuery('')
setResults([])
setAutocompleteOpen(false)
setActiveIndex(-1)
return
}
if (pending) {
addStop({ lat: result.lat, lon: result.lon, name: result.name, source: result.source, matchCode: result.match_code })
addStop({ lat: pending.lat, lon: pending.lon, name: pending.name, source: pending.source, matchCode: pending.matchCode })
clearPendingDestination()
toast(`Routing from ${result.name} to ${pending.name}`, { icon: '\u{1F9ED}' })
} else {
setSelectedPlace({
lat: result.lat,
lon: result.lon,
name: result.name,
address: result.address || null,
type: result.type,
source: result.source,
matchCode: result.match_code,
raw: result.raw || {},
})
}
setQuery('')
setResults([])
setAutocompleteOpen(false)
setActiveIndex(-1)
inputRef.current?.focus()
}
const handleKeyDown = (e) => {
if (!autocompleteOpen || results.length === 0) {
if (e.key === 'Escape') setAutocompleteOpen(false)
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex((prev) => Math.min(prev + 1, results.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex((prev) => Math.max(prev - 1, -1))
break
case 'Enter':
e.preventDefault()
if (activeIndex >= 0 && activeIndex < results.length) {
selectResult(results[activeIndex])
}
break
case 'Escape':
e.preventDefault()
setAutocompleteOpen(false)
setActiveIndex(-1)
break
}
}
const atCap = stops.length >= 10
return (
<div className="relative">
<div className="relative">
<input
ref={inputRef}
type="text"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
placeholder={atCap ? 'Max 10 stops reached' : pendingDestination ? 'Starting point...' : 'Search for a place...'}
disabled={atCap}
className="navi-input w-full pr-8"
aria-label="Search places"
aria-expanded={autocompleteOpen}
aria-autocomplete="list"
role="combobox"
/>
{/* Clear / Loading indicator */}
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
{searchLoading ? (
<div
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
/>
) : query ? (
<button
onClick={handleClear}
className="p-0.5"
style={{ color: 'var(--text-tertiary)' }}
aria-label="Clear search"
>
<X size={14} />
</button>
) : null}
</div>
</div>
{/* Autocomplete dropdown */}
{autocompleteOpen && results.length > 0 && (
<ul
className="absolute z-50 mt-1 w-full rounded-lg overflow-hidden max-h-72 overflow-y-auto"
style={{
background: 'var(--bg-overlay)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
}}
role="listbox"
>
{results.map((r, i) => {
const isPoi = r.type === 'poi' && r.raw?.name
const isContact = r._isContact
const primary = isContact ? r.name : isPoi ? r.raw.name : r.name
const secondary = isContact ? (r.address || '') : isPoi ? buildAddress(r) : null
return (
<li
key={`${r.lat}-${r.lon}-${i}`}
role="option"
aria-selected={i === activeIndex}
className="px-3 py-2 cursor-pointer text-sm"
style={{
background: i === activeIndex
? 'var(--accent-muted)'
: isContact
? 'var(--accent-muted)'
: 'transparent',
borderBottom: i < results.length - 1 ? '1px solid var(--border-subtle)' : 'none',
opacity: isContact && i !== activeIndex ? 0.85 : 1,
}}
onClick={() => selectResult(r)}
onMouseEnter={() => setActiveIndex(i)}
>
<div className="flex items-center gap-2">
<span className="shrink-0" style={{ color: isContact ? 'var(--accent)' : 'var(--text-tertiary)' }}>
<CategoryIcon result={r} />
</span>
<span className="truncate flex-1" style={{ color: 'var(--text-primary)' }}>
{primary}
</span>
<span className="flex items-center gap-1.5 shrink-0">
{isContact && (
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
>
saved
</span>
)}
{r.match_code?.housenumber === 'matched' && (
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
>
exact
</span>
)}
</span>
</div>
{secondary && (
<div className="text-[11px] mt-0.5 ml-6 truncate" style={{ color: 'var(--text-tertiary)' }}>
{secondary}
</div>
)}
</li>
)
})}
</ul>
)}
</div>
)
})
export default SearchBar

View file

@ -1,319 +0,0 @@
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X, User } from 'lucide-react'
import toast from 'react-hot-toast'
import { useStore } from '../store'
import { buildAddress } from '../utils/place'
import { searchGeocode } from '../api'
import { hasFeature } from '../config'
/** Get category icon based on result type/source */
function CategoryIcon({ result }) {
const type = result.type || ''
const source = result.source || ''
const size = 14
if (result._isContact) return <User size={size} />
if (source === 'nickname') return <Star size={size} />
if (type === 'coordinates') return <Crosshair size={size} />
if (type === 'locality' || type === 'city') return <Building2 size={size} />
// POI subcategories from osm_value if available
const osmVal = result.raw?.osm_value || ''
if (osmVal.includes('cafe') || osmVal.includes('coffee')) return <Coffee size={size} />
if (osmVal.includes('fuel') || osmVal.includes('gas')) return <Fuel size={size} />
if (osmVal.includes('shop') || osmVal.includes('supermarket')) return <ShoppingBag size={size} />
if (osmVal.includes('hotel') || osmVal.includes('motel')) return <Hotel size={size} />
return <MapPin size={size} />
}
const SearchBar = forwardRef(function SearchBar(_, ref) {
const inputRef = useRef(null)
const [activeIndex, setActiveIndex] = useState(-1)
const debounceRef = useRef(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
}))
const query = useStore((s) => s.query)
const results = useStore((s) => s.results)
const searchLoading = useStore((s) => s.searchLoading)
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
const stops = useStore((s) => s.stops)
const pendingDestination = useStore((s) => s.pendingDestination)
const contacts = useStore((s) => s.contacts)
const setQuery = useStore((s) => s.setQuery)
const setResults = useStore((s) => s.setResults)
const setSearchLoading = useStore((s) => s.setSearchLoading)
const setAbortController = useStore((s) => s.setAbortController)
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
const addStop = useStore((s) => s.addStop)
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
const setEditingContact = useStore((s) => s.setEditingContact)
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
useEffect(() => {
inputRef.current?.focus()
}, [])
const doSearch = useCallback(
async (q) => {
const prev = useStore.getState().abortController
if (prev) prev.abort()
if (!q.trim()) {
setResults([])
setAutocompleteOpen(false)
setSearchLoading(false)
return
}
// Prepend matching contacts
let contactResults = []
if (hasFeature('has_contacts') && contacts.length > 0) {
const lower = q.trim().toLowerCase()
contactResults = contacts
.filter((c) =>
(c.label || '').toLowerCase().startsWith(lower) ||
(c.name || '').toLowerCase().startsWith(lower) ||
(c.call_sign || '').toLowerCase().startsWith(lower)
)
.slice(0, 3)
.map((c) => ({
lat: c.lat,
lon: c.lon,
name: c.label,
address: c.address || c.name || '',
type: 'contact',
source: 'contacts',
match_code: null,
raw: { osm_type: c.osm_type, osm_id: c.osm_id, contact: c },
_isContact: true,
}))
}
const ctrl = new AbortController()
setAbortController(ctrl)
setSearchLoading(true)
try {
const data = await searchGeocode(q.trim(), 6, ctrl.signal)
const combined = [...contactResults, ...(data.results || [])]
setResults(combined)
setAutocompleteOpen(combined.length > 0)
setActiveIndex(-1)
} catch (e) {
if (e.name !== 'AbortError') {
// Still show contacts even if geocode fails
if (contactResults.length > 0) {
setResults(contactResults)
setAutocompleteOpen(true)
} else {
setResults([])
setAutocompleteOpen(false)
}
}
} finally {
setSearchLoading(false)
}
},
[setResults, setAutocompleteOpen, setSearchLoading, setAbortController, contacts]
)
const handleChange = (e) => {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => doSearch(val), 150)
}
const handleClear = () => {
setQuery('')
setResults([])
setAutocompleteOpen(false)
inputRef.current?.focus()
}
const selectResult = (result) => {
const { pendingDestination: pending } = useStore.getState()
// Pure contact (no geo) → open edit modal
if (result._isContact && result.lat == null) {
setEditingContact(result.raw.contact)
setQuery('')
setResults([])
setAutocompleteOpen(false)
setActiveIndex(-1)
return
}
if (pending) {
addStop({ lat: result.lat, lon: result.lon, name: result.name, source: result.source, matchCode: result.match_code })
addStop({ lat: pending.lat, lon: pending.lon, name: pending.name, source: pending.source, matchCode: pending.matchCode })
clearPendingDestination()
toast(`Routing from ${result.name} to ${pending.name}`, { icon: '\u{1F9ED}' })
} else {
setSelectedPlace({
lat: result.lat,
lon: result.lon,
name: result.name,
address: result.address || null,
type: result.type,
source: result.source,
matchCode: result.match_code,
raw: result.raw || {},
})
}
setQuery('')
setResults([])
setAutocompleteOpen(false)
setActiveIndex(-1)
inputRef.current?.focus()
}
const handleKeyDown = (e) => {
if (!autocompleteOpen || results.length === 0) {
if (e.key === 'Escape') setAutocompleteOpen(false)
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex((prev) => Math.min(prev + 1, results.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex((prev) => Math.max(prev - 1, -1))
break
case 'Enter':
e.preventDefault()
if (activeIndex >= 0 && activeIndex < results.length) {
selectResult(results[activeIndex])
}
break
case 'Escape':
e.preventDefault()
setAutocompleteOpen(false)
setActiveIndex(-1)
break
}
}
const atCap = stops.length >= 10
return (
<div className="relative">
<div className="relative">
<input
ref={inputRef}
type="text"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
placeholder={atCap ? 'Max 10 stops reached' : pendingDestination ? 'Starting point...' : 'Search for a place...'}
disabled={atCap}
className="navi-input w-full pr-8"
aria-label="Search places"
aria-expanded={autocompleteOpen}
aria-autocomplete="list"
role="combobox"
/>
{/* Clear / Loading indicator */}
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
{searchLoading ? (
<div
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
/>
) : query ? (
<button
onClick={handleClear}
className="p-0.5"
style={{ color: 'var(--text-tertiary)' }}
aria-label="Clear search"
>
<X size={14} />
</button>
) : null}
</div>
</div>
{/* Autocomplete dropdown */}
{autocompleteOpen && results.length > 0 && (
<ul
className="absolute z-50 mt-1 w-full rounded-lg overflow-hidden max-h-72 overflow-y-auto"
style={{
background: 'var(--bg-overlay)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
}}
role="listbox"
>
{results.map((r, i) => {
const isPoi = r.type === 'poi' && r.raw?.name
const isContact = r._isContact
const primary = isContact ? r.name : isPoi ? r.raw.name : r.name
const secondary = isContact ? (r.address || '') : isPoi ? buildAddress(r) : null
return (
<li
key={`${r.lat}-${r.lon}-${i}`}
role="option"
aria-selected={i === activeIndex}
className="px-3 py-2 cursor-pointer text-sm"
style={{
background: i === activeIndex
? 'var(--accent-muted)'
: isContact
? 'var(--accent-muted)'
: 'transparent',
borderBottom: i < results.length - 1 ? '1px solid var(--border-subtle)' : 'none',
opacity: isContact && i !== activeIndex ? 0.85 : 1,
}}
onClick={() => selectResult(r)}
onMouseEnter={() => setActiveIndex(i)}
>
<div className="flex items-center gap-2">
<span className="shrink-0" style={{ color: isContact ? 'var(--accent)' : 'var(--text-tertiary)' }}>
<CategoryIcon result={r} />
</span>
<span className="truncate flex-1" style={{ color: 'var(--text-primary)' }}>
{primary}
</span>
<span className="flex items-center gap-1.5 shrink-0">
{isContact && (
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
>
saved
</span>
)}
{r.match_code?.housenumber === 'matched' && (
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
>
exact
</span>
)}
</span>
</div>
{secondary && (
<div className="text-[11px] mt-0.5 ml-6 truncate" style={{ color: 'var(--text-tertiary)' }}>
{secondary}
</div>
)}
</li>
)
})}
</ul>
)}
</div>
)
})
export default SearchBar

View file

@ -1,117 +0,0 @@
import { useState } from 'react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '../store'
import { PlaceCard } from './PlaceCard'
import GpsOriginItem from './GpsOriginItem'
// Wrapper to make PlaceCard sortable
function SortableStopCard({ stop, index, indexOffset }) {
const removeStop = useStore((s) => s.removeStop)
const [expanded, setExpanded] = useState(false)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: stop.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
// Convert stop to place format for PlaceCard
const place = {
lat: stop.lat,
lon: stop.lon,
name: stop.name,
source: stop.source,
matchCode: stop.matchCode,
type: stop.type || null,
raw: stop.raw || null,
wikidata: stop.wikidata || null,
}
return (
<div ref={setNodeRef} style={style}>
<PlaceCard
place={place}
variant="stop"
expanded={expanded}
onToggleExpand={() => setExpanded(!expanded)}
onRemove={() => removeStop(stop.id)}
stopIndex={index + indexOffset}
draggable={true}
dragHandleProps={{ ...attributes, ...listeners }}
/>
</div>
)
}
export default function StopList() {
const stops = useStore((s) => s.stops)
const reorderStops = useStore((s) => s.reorderStops)
const geoPermission = useStore((s) => s.geoPermission)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const pendingDestination = useStore((s) => s.pendingDestination)
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const indexOffset = hasGpsOrigin ? 1 : 0
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragEnd(event) {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = stops.findIndex((s) => s.id === active.id)
const newIndex = stops.findIndex((s) => s.id === over.id)
reorderStops(arrayMove(stops, oldIndex, newIndex))
}
if (stops.length === 0 && !hasGpsOrigin) {
return (
<div className="text-xs px-2 py-3 text-center" style={{ color: 'var(--text-tertiary)' }}>
{pendingDestination
? 'Search for a starting point above'
: geoPermission === 'denied'
? 'Add a starting point and destination above'
: 'Search and add stops to build your route'}
</div>
)
}
return (
<div className="flex flex-col gap-2">
{hasGpsOrigin && <GpsOriginItem />}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
{stops.map((stop, i) => (
<SortableStopCard
key={stop.id}
stop={stop}
index={i}
indexOffset={indexOffset}
/>
))}
</SortableContext>
</DndContext>
</div>
)
}

View file

@ -1,86 +0,0 @@
/**
* Deployment config loader.
*
* Fetches /api/config on startup and caches the result.
* Falls back to hardcoded defaults matching the home profile if the
* API is unavailable (backend restart, network issue).
*/
const FALLBACK_CONFIG = {
profile: 'home',
region_name: 'North America',
tileset: {
url: '/tiles/na.pmtiles',
bounds: [-168, 14, -52, 72],
max_zoom: 15,
attribution: 'Protomaps © OSM',
},
services: {
geocode: '/api/geocode',
reverse: '/api/reverse',
address_book: '/api/address_book',
valhalla: '/valhalla',
},
features: {
has_nominatim_details: false,
has_kiwix_wiki: false,
has_hillshade: false,
has_3d_terrain: false,
has_traffic_overlay: false,
has_landclass: false,
has_public_lands_layer: false,
has_address_book_write: false,
has_contacts: false,
},
defaults: {
center: [42.5736, -114.6066],
zoom: 10,
},
}
let _config = null
let _configPromise = null
/**
* Fetch config from backend. Returns cached config on subsequent calls.
* Falls back to FALLBACK_CONFIG if API fails.
*/
export function loadConfig() {
if (_configPromise) return _configPromise
_configPromise = fetch('/api/config', { signal: AbortSignal.timeout(3000) })
.then((resp) => {
if (!resp.ok) throw new Error(`Config API returned ${resp.status}`)
return resp.json()
})
.then((data) => {
_config = data
console.log('[navi] Config loaded:', data.profile, `(${data.region_name})`)
console.log('[navi] Feature flags:', data.features)
return data
})
.catch((err) => {
console.warn('[navi] Config API unavailable, using fallback:', err.message)
_config = FALLBACK_CONFIG
return FALLBACK_CONFIG
})
return _configPromise
}
/**
* Get the current config synchronously. Returns null if not yet loaded.
*/
export function getConfig() {
return _config
}
/**
* Check a feature flag from the loaded config.
* @param {string} flag - Feature flag name (e.g. 'has_hillshade')
* @returns {boolean}
*/
export function hasFeature(flag) {
if (!_config) return false
return Boolean(_config.features?.[flag])
}

View file

@ -1,537 +0,0 @@
@import "tailwindcss";
/* ═══════════════════════════════════════════════════════
NAVI DESIGN TOKENS
Warm grays, sage greens, khaki tans, deep blacks.
No blue in UI chrome.
═══════════════════════════════════════════════════════ */
:root {
/* ── Typography ── */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* ── Type scale ── */
--text-xs: 0.6875rem; /* 11px */
--text-sm: 0.8125rem; /* 13px */
--text-base: 0.875rem; /* 14px */
--text-md: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
}
/* ═══ DARK MODE (default) ═══ */
[data-theme="dark"] {
--bg-base: #1c1917; /* warm off-black (was #0f1210) */
--bg-raised: #252220; /* raised surface (was #181d1a) */
--bg-overlay: #2e2a27; /* overlay/dropdown (was #1e2522) */
--bg-input: #201d1a; /* input fields (was #141a16) */
--text-primary: #dde3dc;
--text-secondary: #8f9a8e;
--text-tertiary: #5e6b5d;
--text-inverse: #1c1917;
--border: #3a3530; /* warm brown-gray (was #2a3329) */
--border-subtle: #2a2624; /* (was #1f261e) */
--accent: #7a9a6b; /* sage green — interactive states */
--accent-hover: #8fad7f;
--accent-muted: #3d4d36;
--tan: #b8a88a; /* khaki — secondary highlights */
--tan-muted: #4a4235;
--pin-origin: #6b8f5e; /* sage */
--pin-destination: #a67c52; /* rust/tan */
--pin-intermediate: #6b7268; /* warm gray */
--pin-stroke: #1c1917;
--status-success: #6b8f5e;
--status-warning: #b89a4a;
--status-danger: #a65c52;
--route-line: #7a9a6b;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
}
/* ═══ LIGHT MODE ═══ */
[data-theme="light"] {
--bg-base: #ddd2b9; /* warm khaki-tan (was #ece8e1) */
--bg-raised: #e8dec8; /* raised surface (was #f5f2ec) */
--bg-overlay: #e3d9c1; /* overlay/dropdown (was #f0ece5) */
--bg-input: #e8dec8; /* input fields (was #f5f2ec) */
--text-primary: #1a1d1a;
--text-secondary: #4f5a49; /* darkened for WCAG AA on new base (was #5c6558) */
--text-tertiary: #7a8674; /* darkened proportionally (was #8a9486) */
--text-inverse: #f5f2ed;
--border: #c4b89e; /* warmer border (was #d4cfc5) */
--border-subtle: #d5cab2; /* warmer subtle border (was #e8e3db) */
--accent: #4a7040;
--accent-hover: #3d5e35;
--accent-muted: #dce8d6;
--tan: #8a7556;
--tan-muted: #f0e8d8;
--pin-origin: #4a7040;
--pin-destination: #8a5c35;
--pin-intermediate: #6b6960;
--pin-stroke: #1a1d1a;
--status-success: #4a7040;
--status-warning: #8a7040;
--status-danger: #8a4040;
--route-line: #4a7040;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12);
}
/* ═══ BASE STYLES ═══ */
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
}
body {
background: var(--bg-base);
color: var(--text-primary);
}
/* Mono class utility */
.font-mono {
font-family: var(--font-mono);
}
/* ═══ FOCUS RING — accent, never blue ═══ */
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ═══ TRANSITIONS — respect reduced motion ═══ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ═══ MAPLIBRE POPUP ═══ */
.maplibregl-popup-content {
background: var(--bg-raised) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
padding: 8px 12px !important;
box-shadow: var(--shadow-lg) !important;
color: var(--text-primary) !important;
}
.maplibregl-popup-tip {
border-top-color: var(--bg-raised) !important;
border-bottom-color: var(--bg-raised) !important;
}
.maplibregl-popup-close-button {
color: var(--text-secondary) !important;
font-size: 16px !important;
padding: 2px 6px !important;
}
.maplibregl-popup-close-button:hover {
color: var(--text-primary) !important;
background: transparent !important;
}
/* ═══ SCROLLBAR ═══ */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* ═══ GPS CHEVRON MARKER ═══ */
.navi-chevron {
width: 16px;
height: 16px;
transition: transform 0.3s ease;
}
.navi-gps-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
border: 2px solid var(--bg-raised);
box-shadow: 0 0 0 2px var(--accent);
}
/* ═══ STOP PIN MARKERS (map) ═══ */
.navi-pin {
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
color: #fff;
border: 2px solid var(--pin-stroke);
cursor: pointer;
box-shadow: var(--shadow);
}
.navi-pin--origin { background: var(--pin-origin); }
.navi-pin--destination { background: var(--pin-destination); }
.navi-pin--intermediate { background: var(--pin-intermediate); }
/* ═══ FORM ELEMENTS ═══ */
.navi-input {
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
font-family: var(--font-sans);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text-primary);
transition: border-color 0.1s;
}
.navi-input::placeholder {
color: var(--text-tertiary);
}
.navi-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-muted);
}
.navi-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.navi-btn-secondary {
padding: 0.375rem 0.75rem;
font-size: var(--text-xs);
font-family: var(--font-sans);
font-weight: 500;
background: var(--tan-muted);
color: var(--tan);
border: 1px solid var(--border);
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.1s;
}
.navi-btn-secondary:hover:not(:disabled) {
background: var(--accent-muted);
color: var(--accent);
}
.navi-btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ═══ PREVIEW PIN (selected but not committed) ═══ */
.navi-pin-preview {
width: 28px;
height: 28px;
border-radius: 50%;
border: 3px solid var(--accent);
background: transparent;
box-shadow: 0 0 0 3px var(--accent-muted), var(--shadow);
pointer-events: none;
}
/* ═══ PLACE DETAIL PANEL ═══ */
.navi-place-detail {
transition: transform 150ms ease, opacity 150ms ease;
}
.navi-place-detail-enter {
transform: translateX(-10px);
opacity: 0;
}
.navi-place-detail-active {
transform: translateX(0);
opacity: 1;
}
/* ═══ LAYER CONTROL ═══ */
.layer-control {
position: absolute;
bottom: 32px;
right: 10px;
z-index: 10;
}
.layer-control-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
box-shadow: var(--shadow);
transition: color 0.1s, border-color 0.1s;
}
.layer-control-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
}
.layer-control-popover {
position: absolute;
bottom: 44px;
right: 0;
min-width: 160px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 0;
box-shadow: var(--shadow-lg);
}
.layer-control-header {
padding: 4px 12px 6px;
font-size: var(--text-xs);
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.layer-control-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
cursor: pointer;
transition: background 0.1s;
}
.layer-control-item:hover {
background: var(--bg-overlay);
}
.layer-control-label {
font-size: var(--text-sm);
color: var(--text-primary);
}
.layer-control-toggle {
appearance: none;
width: 32px;
height: 18px;
background: var(--border);
border-radius: 9px;
position: relative;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
margin-left: 12px;
}
.layer-control-toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: var(--text-primary);
border-radius: 50%;
transition: transform 0.15s;
}
.layer-control-toggle:checked {
background: var(--accent);
}
.layer-control-toggle:checked::after {
transform: translateX(14px);
}
/* ═══ PLACE DETAIL ENRICHMENT ═══ */
.place-detail-section {
margin-top: 2px;
}
.place-detail-section-header {
display: flex;
align-items: center;
gap: 4px;
padding-bottom: 6px;
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.category-badge {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: var(--accent);
background: var(--accent-muted);
border-radius: 10px;
}
/* ═══ LOCATE BUTTON ═══ */
.locate-btn {
position: absolute;
bottom: 80px;
right: 10px;
z-index: 10;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
box-shadow: var(--shadow);
transition: color 0.1s, border-color 0.1s;
}
.locate-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
}
/* ═══ STOP REMOVE BUTTON (touch-friendly) ═══ */
.stop-remove-btn {
opacity: 0;
transition: opacity 0.15s;
}
.group:hover .stop-remove-btn {
opacity: 1;
}
/* ═══ MOBILE OVERRIDES ═══ */
@media (max-width: 767px) {
body {
overflow-x: hidden;
}
.layer-control {
bottom: auto;
top: 120px;
right: 10px;
}
.locate-btn {
bottom: auto;
top: 166px;
right: 10px;
}
.stop-remove-btn {
opacity: 0.6;
}
}
/* ═══ CONTACT MODAL ═══ */
.contact-modal-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.contact-modal {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 12px;
width: 90%;
max-width: 420px;
max-height: 85vh;
overflow-y: auto;
padding: 20px;
box-shadow: var(--shadow-lg);
}
/* ═══ PANEL TAB BAR ═══ */
.navi-tab-bar {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border-subtle);
}
.navi-tab {
padding: 6px 12px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.1s, border-color 0.1s;
}
.navi-tab:hover {
color: var(--text-secondary);
}
.navi-tab-active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ═══ CONTACT LIST ITEMS ═══ */
.contact-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
cursor: pointer;
transition: background 0.1s;
}
.contact-item:hover {
background: var(--bg-overlay);
}

View file

@ -1,141 +0,0 @@
import { create } from 'zustand'
export const useStore = create((set, get) => ({
// ── Search state ──
query: '',
results: [],
searchLoading: false,
abortController: null,
setQuery: (query) => set({ query }),
setResults: (results) => set({ results }),
setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }),
// ── Stop list ──
stops: [],
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
addStop: (stop) => {
const { stops } = get()
if (stops.length >= 10) return false
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
return true
},
removeStop: (id) => {
set({ stops: get().stops.filter((s) => s.id !== id) })
},
reorderStops: (newStops) => set({ stops: newStops }),
clearStops: () => set({ stops: [] }),
setStops: (stops) => set({ stops }),
// ── Geolocation ──
userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
// ── Route ──
route: null, // Valhalla response (trip object)
routeLoading: false,
routeError: null,
setRoute: (route) => set({ route, routeError: null }),
setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
gpsOrigin: true, // whether GPS should be used as origin when available
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
setSelectedPlace: (place) => set({ selectedPlace: place }),
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
setClickMarker: (marker) => set({ clickMarker: marker }),
clearClickMarker: () => set({ clickMarker: null }),
setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
startDirections: (place) => {
const { geoPermission, stops, addStop, clearStops } = get()
if (geoPermission === 'granted') {
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ gpsOrigin: true, selectedPlace: null })
} else if (stops.length > 0) {
const origin = stops[0]
clearStops()
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ selectedPlace: null })
} else {
// GPS denied, no stops: add destination, show empty origin slot
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ pendingDestination: place, selectedPlace: null })
}
},
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })
if (override) {
localStorage.setItem('navi-theme-override', override)
} else {
localStorage.removeItem('navi-theme-override')
}
},
// ── Contacts ──
contacts: [],
contactsLoaded: false,
activeTab: 'routes', // 'routes' | 'contacts'
editingContact: null, // null=closed, {}=new, {id:N}=edit
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }),
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))
// ── Panel state selector ──
// Returns string state, prioritizing preview to allow it alongside any route state
export const usePanelState = () => {
return useStore((s) => {
const hasPreview = !!s.selectedPlace
const hasRoute = !!s.route
const hasStops = s.stops.length >= 1
if (hasPreview && hasRoute) return "PREVIEW_CALCULATED"
if (hasPreview && hasStops) return "PREVIEW_ROUTING"
if (hasPreview) return "PREVIEW"
if (hasRoute) return "ROUTE_CALCULATED"
if (hasStops) return "ROUTING"
return "IDLE"
})
}

View file

@ -1,139 +0,0 @@
import { create } from 'zustand'
export const useStore = create((set, get) => ({
// ── Search state ──
query: '',
results: [],
searchLoading: false,
abortController: null,
setQuery: (query) => set({ query }),
setResults: (results) => set({ results }),
setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }),
// ── Stop list ──
stops: [],
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
addStop: (stop) => {
const { stops } = get()
if (stops.length >= 10) return false
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
return true
},
removeStop: (id) => {
set({ stops: get().stops.filter((s) => s.id !== id) })
},
reorderStops: (newStops) => set({ stops: newStops }),
clearStops: () => set({ stops: [] }),
setStops: (stops) => set({ stops }),
// ── Geolocation ──
userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
// ── Route ──
route: null, // Valhalla response (trip object)
routeLoading: false,
routeError: null,
setRoute: (route) => set({ route, routeError: null }),
setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
gpsOrigin: true, // whether GPS should be used as origin when available
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
setSelectedPlace: (place) => set({ selectedPlace: place }),
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
setClickMarker: (marker) => set({ clickMarker: marker }),
clearClickMarker: () => set({ clickMarker: null }),
setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
startDirections: (place) => {
const { geoPermission, stops, addStop, clearStops } = get()
if (geoPermission === 'granted') {
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ gpsOrigin: true, selectedPlace: null })
} else if (stops.length > 0) {
const origin = stops[0]
clearStops()
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ selectedPlace: null })
} else {
// GPS denied, no stops: add destination, show empty origin slot
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ pendingDestination: place, selectedPlace: null })
}
},
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })
if (override) {
localStorage.setItem('navi-theme-override', override)
} else {
// GPS denied, no stops: add destination, show empty origin slot
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
localStorage.removeItem('navi-theme-override')
}
},
// ── Contacts ──
contacts: [],
contactsLoaded: false,
activeTab: 'routes', // 'routes' | 'contacts'
editingContact: null, // null=closed, {}=new, {id:N}=edit
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }),
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))
// ── Panel state selector ──
// IDLE | PREVIEW | ROUTING | PREVIEW_ROUTING | ROUTE_CALCULATED
export const usePanelState = () => {
return useStore((s) => {
if (s.route) return "ROUTE_CALCULATED"
if (s.selectedPlace && s.stops.length >= 1) return "PREVIEW_ROUTING"
if (s.selectedPlace) return "PREVIEW"
if (s.stops.length >= 1) return "ROUTING"
return "IDLE"
})
}

View file

@ -1,118 +0,0 @@
import { create } from 'zustand'
export const useStore = create((set, get) => ({
// ── Search state ──
query: '',
results: [],
searchLoading: false,
abortController: null,
setQuery: (query) => set({ query }),
setResults: (results) => set({ results }),
setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }),
// ── Stop list ──
stops: [],
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
addStop: (stop) => {
const { stops } = get()
if (stops.length >= 10) return false
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
return true
},
removeStop: (id) => {
set({ stops: get().stops.filter((s) => s.id !== id) })
},
reorderStops: (newStops) => set({ stops: newStops }),
clearStops: () => set({ stops: [] }),
setStops: (stops) => set({ stops }),
// ── Geolocation ──
userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
// ── Route ──
route: null, // Valhalla response (trip object)
routeLoading: false,
routeError: null,
setRoute: (route) => set({ route, routeError: null }),
setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
gpsOrigin: true, // whether GPS should be used as origin when available
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
setSelectedPlace: (place) => set({ selectedPlace: place }),
clearSelectedPlace: () => set({ selectedPlace: null }),
setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
startDirections: (place) => {
const { geoPermission, stops, addStop, clearStops } = get()
if (geoPermission === 'granted') {
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ gpsOrigin: true, selectedPlace: null })
} else if (stops.length > 0) {
const origin = stops[0]
clearStops()
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ selectedPlace: null })
} else {
set({ pendingDestination: place, selectedPlace: null })
}
},
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })
if (override) {
localStorage.setItem('navi-theme-override', override)
} else {
localStorage.removeItem('navi-theme-override')
}
},
// ── Contacts ──
contacts: [],
contactsLoaded: false,
activeTab: 'routes', // 'routes' | 'contacts'
editingContact: null, // null=closed, {}=new, {id:N}=edit
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }),
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))

View file

@ -1,133 +0,0 @@
import { create } from 'zustand'
export const useStore = create((set, get) => ({
// ── Search state ──
query: '',
results: [],
searchLoading: false,
abortController: null,
setQuery: (query) => set({ query }),
setResults: (results) => set({ results }),
setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }),
// ── Stop list ──
stops: [],
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
addStop: (stop) => {
const { stops } = get()
if (stops.length >= 10) return false
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
return true
},
removeStop: (id) => {
set({ stops: get().stops.filter((s) => s.id !== id) })
},
reorderStops: (newStops) => set({ stops: newStops }),
clearStops: () => set({ stops: [] }),
setStops: (stops) => set({ stops }),
// ── Geolocation ──
userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
// ── Route ──
route: null, // Valhalla response (trip object)
routeLoading: false,
routeError: null,
setRoute: (route) => set({ route, routeError: null }),
setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
gpsOrigin: true, // whether GPS should be used as origin when available
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
setSelectedPlace: (place) => set({ selectedPlace: place }),
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
setClickMarker: (marker) => set({ clickMarker: marker }),
clearClickMarker: () => set({ clickMarker: null }),
setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
startDirections: (place) => {
const { geoPermission, stops, addStop, clearStops } = get()
if (geoPermission === 'granted') {
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ gpsOrigin: true, selectedPlace: null })
} else if (stops.length > 0) {
const origin = stops[0]
clearStops()
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ selectedPlace: null })
} else {
set({ pendingDestination: place, selectedPlace: null })
}
},
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })
if (override) {
localStorage.setItem('navi-theme-override', override)
} else {
localStorage.removeItem('navi-theme-override')
}
},
// ── Contacts ──
contacts: [],
contactsLoaded: false,
activeTab: 'routes', // 'routes' | 'contacts'
editingContact: null, // null=closed, {}=new, {id:N}=edit
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }),
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))
// ── Panel state selector ──
// IDLE | PREVIEW | ROUTING | PREVIEW_ROUTING | ROUTE_CALCULATED
export const usePanelState = () => {
return useStore((s) => {
if (s.route) return "ROUTE_CALCULATED"
if (s.selectedPlace && s.stops.length >= 1) return "PREVIEW_ROUTING"
if (s.selectedPlace) return "PREVIEW"
if (s.stops.length >= 1) return "ROUTING"
return "IDLE"
})
}

View file

@ -1,139 +0,0 @@
import { create } from 'zustand'
export const useStore = create((set, get) => ({
// ── Search state ──
query: '',
results: [],
searchLoading: false,
abortController: null,
setQuery: (query) => set({ query }),
setResults: (results) => set({ results }),
setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }),
// ── Stop list ──
stops: [],
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
addStop: (stop) => {
const { stops } = get()
if (stops.length >= 10) return false
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
return true
},
removeStop: (id) => {
set({ stops: get().stops.filter((s) => s.id !== id) })
},
reorderStops: (newStops) => set({ stops: newStops }),
clearStops: () => set({ stops: [] }),
setStops: (stops) => set({ stops }),
// ── Geolocation ──
userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
// ── Route ──
route: null, // Valhalla response (trip object)
routeLoading: false,
routeError: null,
setRoute: (route) => set({ route, routeError: null }),
setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
gpsOrigin: true, // whether GPS should be used as origin when available
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
setSelectedPlace: (place) => set({ selectedPlace: place }),
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
setClickMarker: (marker) => set({ clickMarker: marker }),
clearClickMarker: () => set({ clickMarker: null }),
setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
startDirections: (place) => {
const { geoPermission, stops, addStop, clearStops } = get()
if (geoPermission === 'granted') {
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ gpsOrigin: true, selectedPlace: null })
} else if (stops.length > 0) {
const origin = stops[0]
clearStops()
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ selectedPlace: null })
} else {
// GPS denied, no stops: set pendingDestination only; origin-picker will add both
set({ pendingDestination: place, selectedPlace: null })
}
},
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })
if (override) {
localStorage.setItem('navi-theme-override', override)
} else {
localStorage.removeItem('navi-theme-override')
}
},
// ── Contacts ──
contacts: [],
contactsLoaded: false,
activeTab: 'routes', // 'routes' | 'contacts'
editingContact: null, // null=closed, {}=new, {id:N}=edit
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }),
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))
// ── Panel state selector ──
// Returns string state, prioritizing preview to allow it alongside any route state
export const usePanelState = () => {
return useStore((s) => {
const hasPreview = !!s.selectedPlace
const hasRoute = !!s.route
const hasStops = s.stops.length >= 1
if (hasPreview && hasRoute) return "PREVIEW_CALCULATED"
if (hasPreview && hasStops) return "PREVIEW_ROUTING"
if (hasPreview) return "PREVIEW"
if (hasRoute) return "ROUTE_CALCULATED"
if (hasStops) return "ROUTING"
return "IDLE"
})
}