mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
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:
parent
0d4a807a05
commit
a40f68fa26
39 changed files with 728 additions and 21085 deletions
52
src/api.js
52
src/api.js
|
|
@ -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
|
||||||
|
|
@ -286,27 +286,27 @@ export async function fetchLandclass(lat, lon, signal) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Auth API ──
|
// ── Auth API ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check authentication state via whoami endpoint.
|
* Check authentication state via whoami endpoint.
|
||||||
* Uses redirect: manual to detect auth without triggering navigation.
|
* Uses redirect: manual to detect auth without triggering navigation.
|
||||||
* @returns {Promise<{authenticated: boolean, username: string|null}>}
|
* @returns {Promise<{authenticated: boolean, username: string|null}>}
|
||||||
*/
|
*/
|
||||||
export async function fetchAuthState() {
|
export async function fetchAuthState() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/auth/whoami', { redirect: 'manual' })
|
const resp = await fetch('/api/auth/whoami', { redirect: 'manual' })
|
||||||
// Redirect response means unauthenticated (Authentik SSO flow)
|
// Redirect response means unauthenticated (Authentik SSO flow)
|
||||||
if (resp.type === 'opaqueredirect' || resp.status === 302) {
|
if (resp.type === 'opaqueredirect' || resp.status === 302) {
|
||||||
return { authenticated: false, username: null }
|
return { authenticated: false, username: null }
|
||||||
}
|
}
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
return { authenticated: false, username: null }
|
return { authenticated: false, username: null }
|
||||||
}
|
}
|
||||||
return resp.json()
|
return resp.json()
|
||||||
} catch {
|
} catch {
|
||||||
return { authenticated: false, username: null }
|
return { authenticated: false, username: null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -1,315 +1,324 @@
|
||||||
import { useRef, useCallback, useEffect, useState } from 'react'
|
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||||
import { Sun, Moon, LogIn, LogOut } from 'lucide-react'
|
import { Sun, Moon, LogIn, LogOut } from 'lucide-react'
|
||||||
import { useStore, usePanelState } from '../store'
|
import { useStore, usePanelState } from '../store'
|
||||||
import { hasFeature } from '../config'
|
import { hasFeature } from '../config'
|
||||||
import SearchBar from './SearchBar'
|
import SearchBar from './SearchBar'
|
||||||
import StopList from './StopList'
|
import StopList from './StopList'
|
||||||
import ModeSelector from './ModeSelector'
|
import ModeSelector from './ModeSelector'
|
||||||
import ManeuverList from './ManeuverList'
|
import ManeuverList from './ManeuverList'
|
||||||
import ContactList from './ContactList'
|
import ContactList from './ContactList'
|
||||||
import { PlaceCard } from './PlaceCard'
|
import { PlaceCard } from './PlaceCard'
|
||||||
import { requestOptimizedRoute } from '../api'
|
import { requestOptimizedRoute } from '../api'
|
||||||
|
|
||||||
export default function Panel({ onManeuverClick }) {
|
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 stops = useStore((s) => s.stops)
|
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
|
||||||
const mode = useStore((s) => s.mode)
|
const stops = useStore((s) => s.stops)
|
||||||
const route = useStore((s) => s.route)
|
const mode = useStore((s) => s.mode)
|
||||||
const routeLoading = useStore((s) => s.routeLoading)
|
const route = useStore((s) => s.route)
|
||||||
const routeError = useStore((s) => s.routeError)
|
const routeLoading = useStore((s) => s.routeLoading)
|
||||||
const setStops = useStore((s) => s.setStops)
|
const routeError = useStore((s) => s.routeError)
|
||||||
const setRoute = useStore((s) => s.setRoute)
|
const setStops = useStore((s) => s.setStops)
|
||||||
const setRouteError = useStore((s) => s.setRouteError)
|
const setRoute = useStore((s) => s.setRoute)
|
||||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
const setRouteError = useStore((s) => s.setRouteError)
|
||||||
const sheetState = useStore((s) => s.sheetState)
|
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||||
const setSheetState = useStore((s) => s.setSheetState)
|
const sheetState = useStore((s) => s.sheetState)
|
||||||
const theme = useStore((s) => s.theme)
|
const setSheetState = useStore((s) => s.setSheetState)
|
||||||
const themeOverride = useStore((s) => s.themeOverride)
|
const theme = useStore((s) => s.theme)
|
||||||
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
const themeOverride = useStore((s) => s.themeOverride)
|
||||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
||||||
const geoPermission = useStore((s) => s.geoPermission)
|
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||||
const activeTab = useStore((s) => s.activeTab)
|
const geoPermission = useStore((s) => s.geoPermission)
|
||||||
const auth = useStore((s) => s.auth)
|
const activeTab = useStore((s) => s.activeTab)
|
||||||
const setActiveTab = useStore((s) => s.setActiveTab)
|
const auth = useStore((s) => s.auth)
|
||||||
|
const setActiveTab = useStore((s) => s.setActiveTab)
|
||||||
const panelState = usePanelState()
|
|
||||||
|
const panelState = usePanelState()
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
|
||||||
const [optimizing, setOptimizing] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const sheetRef = useRef(null)
|
const [optimizing, setOptimizing] = useState(false)
|
||||||
const dragStartY = useRef(0)
|
const sheetRef = useRef(null)
|
||||||
const dragStartState = useRef('half')
|
const dragStartY = useRef(0)
|
||||||
|
const dragStartState = useRef('half')
|
||||||
// Show contacts tab only if feature enabled AND user is authenticated
|
|
||||||
const showContacts = hasFeature('has_contacts') && auth.authenticated
|
// Show contacts tab only if feature enabled AND user is authenticated
|
||||||
|
const showContacts = hasFeature('has_contacts') && auth.authenticated
|
||||||
// Responsive detection
|
|
||||||
useEffect(() => {
|
// Responsive detection
|
||||||
const check = () => setIsMobile(window.innerWidth < 768)
|
useEffect(() => {
|
||||||
check()
|
const check = () => setIsMobile(window.innerWidth < 768)
|
||||||
window.addEventListener('resize', check)
|
check()
|
||||||
return () => window.removeEventListener('resize', check)
|
window.addEventListener('resize', check)
|
||||||
}, [])
|
return () => window.removeEventListener('resize', check)
|
||||||
|
}, [])
|
||||||
// Theme toggle
|
|
||||||
const toggleTheme = () => {
|
// Theme toggle
|
||||||
const next = theme === 'dark' ? 'light' : 'dark'
|
const toggleTheme = () => {
|
||||||
setThemeOverride(next)
|
const next = theme === 'dark' ? 'light' : 'dark'
|
||||||
}
|
setThemeOverride(next)
|
||||||
|
}
|
||||||
// Auth handlers
|
|
||||||
const handleLogin = () => { window.location.href = '/api/auth/whoami' }
|
// Auth handlers
|
||||||
const handleLogout = () => { window.location.href = '/outpost.goauthentik.io/sign_out' }
|
const handleLogin = () => { window.location.href = '/api/auth/whoami' }
|
||||||
|
const handleLogout = () => { window.location.href = '/outpost.goauthentik.io/sign_out' }
|
||||||
// Optimize stops
|
|
||||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
// Optimize stops
|
||||||
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||||
|
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
||||||
const handleOptimize = useCallback(async () => {
|
|
||||||
if (effectiveCount < 3 || optimizing) return
|
const handleOptimize = useCallback(async () => {
|
||||||
setOptimizing(true)
|
if (effectiveCount < 3 || optimizing) return
|
||||||
try {
|
setOptimizing(true)
|
||||||
const { userLocation } = useStore.getState()
|
try {
|
||||||
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
const { userLocation } = useStore.getState()
|
||||||
if (hasGpsOrigin && userLocation) {
|
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||||
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
if (hasGpsOrigin && userLocation) {
|
||||||
}
|
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
||||||
const data = await requestOptimizedRoute(locations, mode)
|
}
|
||||||
if (data.trip) {
|
const data = await requestOptimizedRoute(locations, mode)
|
||||||
const wpOrder = hasGpsOrigin && userLocation
|
if (data.trip) {
|
||||||
? (data.trip.locations || []).slice(1)
|
const wpOrder = hasGpsOrigin && userLocation
|
||||||
: data.trip.locations
|
? (data.trip.locations || []).slice(1)
|
||||||
if (wpOrder && wpOrder.length === stops.length) {
|
: data.trip.locations
|
||||||
const reordered = wpOrder.map((wp) => {
|
if (wpOrder && wpOrder.length === stops.length) {
|
||||||
let closest = stops[0]
|
const reordered = wpOrder.map((wp) => {
|
||||||
let minDist = Infinity
|
let closest = stops[0]
|
||||||
for (const s of stops) {
|
let minDist = Infinity
|
||||||
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
|
for (const s of stops) {
|
||||||
if (d < minDist) {
|
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
|
||||||
minDist = d
|
if (d < minDist) {
|
||||||
closest = s
|
minDist = d
|
||||||
}
|
closest = s
|
||||||
}
|
}
|
||||||
return closest
|
}
|
||||||
})
|
return closest
|
||||||
const seen = new Set()
|
})
|
||||||
const unique = reordered.filter((s) => {
|
const seen = new Set()
|
||||||
if (seen.has(s.id)) return false
|
const unique = reordered.filter((s) => {
|
||||||
seen.add(s.id)
|
if (seen.has(s.id)) return false
|
||||||
return true
|
seen.add(s.id)
|
||||||
})
|
return true
|
||||||
if (unique.length === stops.length) {
|
})
|
||||||
setStops(unique)
|
if (unique.length === stops.length) {
|
||||||
}
|
setStops(unique)
|
||||||
}
|
}
|
||||||
setRoute(data.trip)
|
}
|
||||||
}
|
setRoute(data.trip)
|
||||||
} catch (e) {
|
}
|
||||||
setRouteError(e.message)
|
} catch (e) {
|
||||||
} finally {
|
setRouteError(e.message)
|
||||||
setOptimizing(false)
|
} finally {
|
||||||
}
|
setOptimizing(false)
|
||||||
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
}
|
||||||
|
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
||||||
// Mobile sheet drag handling
|
|
||||||
const handleTouchStart = useCallback((e) => {
|
// Mobile sheet drag handling
|
||||||
dragStartY.current = e.touches[0].clientY
|
const handleTouchStart = useCallback((e) => {
|
||||||
dragStartState.current = sheetState
|
dragStartY.current = e.touches[0].clientY
|
||||||
}, [sheetState])
|
dragStartState.current = sheetState
|
||||||
|
}, [sheetState])
|
||||||
const handleTouchEnd = useCallback((e) => {
|
|
||||||
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
const handleTouchEnd = useCallback((e) => {
|
||||||
if (Math.abs(deltaY) < 30) return
|
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
||||||
if (deltaY < 0) {
|
if (Math.abs(deltaY) < 30) return
|
||||||
if (dragStartState.current === 'collapsed') setSheetState('half')
|
if (deltaY < 0) {
|
||||||
else if (dragStartState.current === 'half') setSheetState('full')
|
if (dragStartState.current === 'collapsed') setSheetState('half')
|
||||||
} else {
|
else if (dragStartState.current === 'half') setSheetState('full')
|
||||||
if (dragStartState.current === 'full') setSheetState('half')
|
} else {
|
||||||
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
if (dragStartState.current === 'full') setSheetState('half')
|
||||||
}
|
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
||||||
}, [setSheetState])
|
}
|
||||||
|
}, [setSheetState])
|
||||||
const showOptimize = effectiveCount >= 3
|
|
||||||
|
const showOptimize = effectiveCount >= 3
|
||||||
// Determine what to show based on panel state
|
|
||||||
const showPreviewCard = panelState.startsWith('PREVIEW')
|
// Determine what to show based on panel state
|
||||||
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination
|
const showPreviewCard = panelState.startsWith('PREVIEW')
|
||||||
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
|
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination
|
||||||
const showEmptyState = panelState === 'IDLE' && !pendingDestination
|
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
|
||||||
|
const showEmptyState = panelState === 'IDLE' && !pendingDestination
|
||||||
// Routes tab content - now state-driven
|
|
||||||
const routesContent = (
|
// Routes tab content - now state-driven
|
||||||
<>
|
const routesContent = (
|
||||||
<SearchBar />
|
<>
|
||||||
|
<SearchBar />
|
||||||
{/* Preview card when place is selected */}
|
|
||||||
{showPreviewCard && selectedPlace && (
|
{/* Preview card when place is selected */}
|
||||||
<div className="mt-3">
|
{showPreviewCard && selectedPlace && (
|
||||||
<PlaceCard
|
<div className="mt-3">
|
||||||
place={selectedPlace}
|
<PlaceCard
|
||||||
variant="preview"
|
place={selectedPlace}
|
||||||
expanded={true}
|
variant="preview"
|
||||||
onClose={clearSelectedPlace}
|
expanded={true}
|
||||||
/>
|
onClose={clearSelectedPlace}
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
{/* Route section with stops */}
|
|
||||||
{showRouteSection && (
|
{/* Route section with stops */}
|
||||||
<>
|
{showRouteSection && (
|
||||||
<div className="mt-3">
|
<>
|
||||||
<StopList />
|
<div className="mt-3">
|
||||||
</div>
|
<StopList />
|
||||||
|
</div>
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
|
||||||
<ModeSelector />
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
{showOptimize && (
|
<ModeSelector />
|
||||||
<button
|
{showOptimize && (
|
||||||
onClick={handleOptimize}
|
<button
|
||||||
disabled={optimizing || routeLoading}
|
onClick={handleOptimize}
|
||||||
className="navi-btn-secondary w-full"
|
disabled={optimizing || routeLoading}
|
||||||
>
|
className="navi-btn-secondary w-full"
|
||||||
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
>
|
||||||
</button>
|
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
||||||
)}
|
</button>
|
||||||
</div>
|
)}
|
||||||
</>
|
{pendingDestination && stops.length === 0 && (
|
||||||
)}
|
<button
|
||||||
|
onClick={clearPendingDestination}
|
||||||
{/* Maneuvers when route is calculated */}
|
className="navi-btn-secondary w-full"
|
||||||
{showManeuvers && (route || routeLoading || routeError) && (
|
>
|
||||||
<div className="mt-3">
|
Cancel
|
||||||
<ManeuverList onManeuverClick={onManeuverClick} />
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
</>
|
||||||
{/* Empty state */}
|
)}
|
||||||
{showEmptyState && (
|
|
||||||
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
{/* Maneuvers when route is calculated */}
|
||||||
<p>Search or tap the map to explore</p>
|
{showManeuvers && (route || routeLoading || routeError) && (
|
||||||
</div>
|
<div className="mt-3">
|
||||||
)}
|
<ManeuverList onManeuverClick={onManeuverClick} />
|
||||||
</>
|
</div>
|
||||||
)
|
)}
|
||||||
|
|
||||||
const content = (
|
{/* Empty state */}
|
||||||
<>
|
{showEmptyState && (
|
||||||
{showContacts && (
|
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<div className="navi-tab-bar mb-3">
|
<p>Search or tap the map to explore</p>
|
||||||
<button
|
</div>
|
||||||
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
|
)}
|
||||||
onClick={() => setActiveTab('routes')}
|
</>
|
||||||
>
|
)
|
||||||
Routes
|
|
||||||
</button>
|
const content = (
|
||||||
<button
|
<>
|
||||||
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
|
{showContacts && (
|
||||||
onClick={() => setActiveTab('contacts')}
|
<div className="navi-tab-bar mb-3">
|
||||||
>
|
<button
|
||||||
Contacts
|
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
|
||||||
</button>
|
onClick={() => setActiveTab('routes')}
|
||||||
</div>
|
>
|
||||||
)}
|
Routes
|
||||||
|
</button>
|
||||||
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
|
<button
|
||||||
</>
|
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
|
||||||
)
|
onClick={() => setActiveTab('contacts')}
|
||||||
|
>
|
||||||
const header = (
|
Contacts
|
||||||
<div className="flex items-center justify-between mb-3">
|
</button>
|
||||||
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
)}
|
||||||
{auth.loaded && (
|
|
||||||
auth.authenticated ? (
|
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
|
||||||
<button
|
</>
|
||||||
onClick={handleLogout}
|
)
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
|
|
||||||
style={{ color: 'var(--text-tertiary)' }}
|
const header = (
|
||||||
title={`Logged in as ${auth.username}. Click to log out.`}
|
<div className="flex items-center justify-between mb-3">
|
||||||
>
|
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
||||||
<span className="hidden sm:inline">{auth.username}</span>
|
<div className="flex items-center gap-1">
|
||||||
<LogOut size={14} />
|
{auth.loaded && (
|
||||||
</button>
|
auth.authenticated ? (
|
||||||
) : (
|
<button
|
||||||
<button
|
onClick={handleLogout}
|
||||||
onClick={handleLogin}
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
style={{ color: 'var(--accent)' }}
|
title={`Logged in as ${auth.username}. Click to log out.`}
|
||||||
title="Log in"
|
>
|
||||||
>
|
<span className="hidden sm:inline">{auth.username}</span>
|
||||||
<LogIn size={14} />
|
<LogOut size={14} />
|
||||||
<span>Log in</span>
|
</button>
|
||||||
</button>
|
) : (
|
||||||
)
|
<button
|
||||||
)}
|
onClick={handleLogin}
|
||||||
<button
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
|
||||||
onClick={toggleTheme}
|
style={{ color: 'var(--accent)' }}
|
||||||
className="p-1.5 rounded"
|
title="Log in"
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
>
|
||||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
<LogIn size={14} />
|
||||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
<span>Log in</span>
|
||||||
>
|
</button>
|
||||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
)
|
||||||
</button>
|
)}
|
||||||
</div>
|
<button
|
||||||
</div>
|
onClick={toggleTheme}
|
||||||
)
|
className="p-1.5 rounded"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
// Desktop: side panel (now 360px to accommodate PlaceCard)
|
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
if (!isMobile) {
|
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
return (
|
>
|
||||||
<div
|
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
</button>
|
||||||
style={{
|
</div>
|
||||||
width: '400px',
|
</div>
|
||||||
background: 'var(--bg-raised)',
|
)
|
||||||
borderRight: '1px solid var(--border)',
|
|
||||||
}}
|
// Desktop: side panel (now 360px to accommodate PlaceCard)
|
||||||
>
|
if (!isMobile) {
|
||||||
{header}
|
return (
|
||||||
{content}
|
<div
|
||||||
</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)',
|
||||||
// Mobile: bottom sheet
|
borderRight: '1px solid var(--border)',
|
||||||
const sheetHeights = {
|
}}
|
||||||
collapsed: 'h-12',
|
>
|
||||||
half: 'h-[45vh]',
|
{header}
|
||||||
full: 'h-[85vh]',
|
{content}
|
||||||
}
|
</div>
|
||||||
|
)
|
||||||
return (
|
}
|
||||||
<div
|
|
||||||
ref={sheetRef}
|
// Mobile: bottom sheet
|
||||||
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
|
const sheetHeights = {
|
||||||
style={{
|
collapsed: 'h-12',
|
||||||
background: 'var(--bg-raised)',
|
half: 'h-[45vh]',
|
||||||
borderTop: '1px solid var(--border)',
|
full: 'h-[85vh]',
|
||||||
}}
|
}
|
||||||
>
|
|
||||||
{/* Drag handle */}
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex justify-center py-2 cursor-grab"
|
ref={sheetRef}
|
||||||
onTouchStart={handleTouchStart}
|
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
|
||||||
onTouchEnd={handleTouchEnd}
|
style={{
|
||||||
onClick={() => {
|
background: 'var(--bg-raised)',
|
||||||
if (sheetState === 'collapsed') setSheetState('half')
|
borderTop: '1px solid var(--border)',
|
||||||
else if (sheetState === 'half') setSheetState('full')
|
}}
|
||||||
else setSheetState('half')
|
>
|
||||||
}}
|
{/* Drag handle */}
|
||||||
>
|
<div
|
||||||
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
|
className="flex justify-center py-2 cursor-grab"
|
||||||
</div>
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
{sheetState !== 'collapsed' && (
|
onClick={() => {
|
||||||
<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))' }}>
|
if (sheetState === 'collapsed') setSheetState('half')
|
||||||
{header}
|
else if (sheetState === 'half') setSheetState('full')
|
||||||
{content}
|
else setSheetState('half')
|
||||||
</div>
|
}}
|
||||||
)}
|
>
|
||||||
</div>
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">·</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,310 +1,309 @@
|
||||||
import { useEffect, useRef, useCallback } from 'react'
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RadialMenu - ATAK-style radial context menu
|
* RadialMenu - ATAK-style radial context menu
|
||||||
* Themed to match Navi light/dark palette using CSS custom properties.
|
* Themed to match Navi light/dark palette using CSS custom properties.
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* - open: boolean
|
* - open: boolean
|
||||||
* - x, y: screen coordinates of trigger point
|
* - x, y: screen coordinates of trigger point
|
||||||
* - lat, lon: geographic coordinates
|
* - lat, lon: geographic coordinates
|
||||||
* - wedges: array of { id, label, icon: LucideIcon, onSelect, requiresAuth? }
|
* - wedges: array of { id, label, icon: LucideIcon, onSelect, requiresAuth? }
|
||||||
* - centerLabel: string (coords by default, replaced by reverse-geocode async)
|
* - centerLabel: string (coords by default, replaced by reverse-geocode async)
|
||||||
* - onDismiss: callback when menu should close
|
* - onDismiss: callback when menu should close
|
||||||
*/
|
*/
|
||||||
export default function RadialMenu({
|
export default function RadialMenu({
|
||||||
open,
|
open,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
wedges = [],
|
wedges = [],
|
||||||
centerLabel,
|
centerLabel,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
const activeWedgeRef = useRef(null)
|
const activeWedgeRef = useRef(null)
|
||||||
|
|
||||||
// Geometry constants
|
// Geometry constants
|
||||||
const outerRadius = 80
|
const outerRadius = 80
|
||||||
const innerRadius = 40
|
const innerRadius = 40
|
||||||
const wedgeCount = wedges.length || 6
|
const wedgeCount = wedges.length || 6
|
||||||
const wedgeAngle = 360 / wedgeCount
|
const wedgeAngle = 360 / wedgeCount
|
||||||
|
|
||||||
// Handle escape key
|
// Handle escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const handleKey = (e) => {
|
const handleKey = (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
onDismiss?.()
|
onDismiss?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKey)
|
window.addEventListener('keydown', handleKey)
|
||||||
return () => window.removeEventListener('keydown', handleKey)
|
return () => window.removeEventListener('keydown', handleKey)
|
||||||
}, [open, onDismiss])
|
}, [open, onDismiss])
|
||||||
|
|
||||||
// Calculate which wedge the pointer is over
|
// Calculate which wedge the pointer is over
|
||||||
const getWedgeAtPoint = useCallback((clientX, clientY) => {
|
const getWedgeAtPoint = useCallback((clientX, clientY) => {
|
||||||
const dx = clientX - x
|
const dx = clientX - x
|
||||||
const dy = clientY - y
|
const dy = clientY - y
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
// Inside inner radius = center (no wedge)
|
// Inside inner radius = center (no wedge)
|
||||||
if (dist < innerRadius) return null
|
if (dist < innerRadius) return null
|
||||||
// Outside outer radius = no wedge
|
// Outside outer radius = no wedge
|
||||||
if (dist > outerRadius + 20) return null
|
if (dist > outerRadius + 20) return null
|
||||||
|
|
||||||
// Calculate angle (0 = top, clockwise)
|
// Calculate angle (0 = top, clockwise)
|
||||||
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
|
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
|
||||||
if (angle < 0) angle += 360
|
if (angle < 0) angle += 360
|
||||||
|
|
||||||
// Find which wedge
|
// Find which wedge
|
||||||
const wedgeIndex = Math.floor(angle / wedgeAngle)
|
const wedgeIndex = Math.floor(angle / wedgeAngle)
|
||||||
return wedges[wedgeIndex] || null
|
return wedges[wedgeIndex] || null
|
||||||
}, [x, y, wedges, wedgeAngle])
|
}, [x, y, wedges, wedgeAngle])
|
||||||
|
|
||||||
// Handle mouse/touch move for highlighting
|
// Handle mouse/touch move for highlighting
|
||||||
const handlePointerMove = useCallback((e) => {
|
const handlePointerMove = useCallback((e) => {
|
||||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX
|
||||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY
|
||||||
activeWedgeRef.current = getWedgeAtPoint(clientX, clientY)
|
activeWedgeRef.current = getWedgeAtPoint(clientX, clientY)
|
||||||
// Force re-render for highlight
|
// Force re-render for highlight
|
||||||
containerRef.current?.querySelectorAll('.radial-wedge').forEach((el, i) => {
|
containerRef.current?.querySelectorAll('.radial-wedge').forEach((el, i) => {
|
||||||
if (wedges[i] && wedges[i].id === activeWedgeRef.current?.id) {
|
if (wedges[i] && wedges[i].id === activeWedgeRef.current?.id) {
|
||||||
el.classList.add('active')
|
el.classList.add('active')
|
||||||
} else {
|
} else {
|
||||||
el.classList.remove('active')
|
el.classList.remove('active')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [getWedgeAtPoint, wedges])
|
}, [getWedgeAtPoint, wedges])
|
||||||
|
|
||||||
// Handle release
|
// Handle release
|
||||||
const handlePointerUp = useCallback((e) => {
|
const handlePointerUp = useCallback((e) => {
|
||||||
const clientX = e.changedTouches ? e.changedTouches[0].clientX : e.clientX
|
const clientX = e.changedTouches ? e.changedTouches[0].clientX : e.clientX
|
||||||
const clientY = e.changedTouches ? e.changedTouches[0].clientY : e.clientY
|
const clientY = e.changedTouches ? e.changedTouches[0].clientY : e.clientY
|
||||||
const wedge = getWedgeAtPoint(clientX, clientY)
|
const wedge = getWedgeAtPoint(clientX, clientY)
|
||||||
|
|
||||||
if (wedge) {
|
if (wedge) {
|
||||||
wedge.onSelect?.({ lat, lon })
|
wedge.onSelect?.({ lat, lon })
|
||||||
}
|
}
|
||||||
onDismiss?.()
|
onDismiss?.()
|
||||||
}, [getWedgeAtPoint, lat, lon, onDismiss])
|
}, [getWedgeAtPoint, lat, lon, onDismiss])
|
||||||
|
|
||||||
// Handle backdrop click (dismiss menu)
|
// Handle backdrop click (dismiss menu)
|
||||||
const handleBackdropClick = useCallback((e) => {
|
const handleBackdropClick = useCallback((e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onDismiss?.()
|
onDismiss?.()
|
||||||
}, [onDismiss])
|
}, [onDismiss])
|
||||||
|
|
||||||
// Prevent menu container clicks from reaching backdrop
|
// Prevent menu container clicks from reaching backdrop
|
||||||
const handleContainerClick = useCallback((e) => {
|
const handleContainerClick = useCallback((e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Generate wedge paths
|
// Generate wedge paths
|
||||||
const generateWedgePath = (index) => {
|
const generateWedgePath = (index) => {
|
||||||
const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180)
|
const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180)
|
||||||
const endAngle = ((index + 1) * wedgeAngle - 90) * (Math.PI / 180)
|
const endAngle = ((index + 1) * wedgeAngle - 90) * (Math.PI / 180)
|
||||||
|
|
||||||
const x1 = innerRadius * Math.cos(startAngle)
|
const x1 = innerRadius * Math.cos(startAngle)
|
||||||
const y1 = innerRadius * Math.sin(startAngle)
|
const y1 = innerRadius * Math.sin(startAngle)
|
||||||
const x2 = outerRadius * Math.cos(startAngle)
|
const x2 = outerRadius * Math.cos(startAngle)
|
||||||
const y2 = outerRadius * Math.sin(startAngle)
|
const y2 = outerRadius * Math.sin(startAngle)
|
||||||
const x3 = outerRadius * Math.cos(endAngle)
|
const x3 = outerRadius * Math.cos(endAngle)
|
||||||
const y3 = outerRadius * Math.sin(endAngle)
|
const y3 = outerRadius * Math.sin(endAngle)
|
||||||
const x4 = innerRadius * Math.cos(endAngle)
|
const x4 = innerRadius * Math.cos(endAngle)
|
||||||
const y4 = innerRadius * Math.sin(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`
|
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
|
// Calculate icon position for each wedge
|
||||||
const getIconPosition = (index) => {
|
const getIconPosition = (index) => {
|
||||||
const midAngle = ((index + 0.5) * wedgeAngle - 90) * (Math.PI / 180)
|
const midAngle = ((index + 0.5) * wedgeAngle - 90) * (Math.PI / 180)
|
||||||
const r = (innerRadius + outerRadius) / 2
|
const r = (innerRadius + outerRadius) / 2
|
||||||
return {
|
return {
|
||||||
x: r * Math.cos(midAngle),
|
x: r * Math.cos(midAngle),
|
||||||
y: r * Math.sin(midAngle),
|
y: r * Math.sin(midAngle),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
// Clamp position to viewport
|
// Clamp position to viewport
|
||||||
const padding = outerRadius + 20
|
const padding = outerRadius + 20
|
||||||
const clampedX = Math.max(padding, Math.min(window.innerWidth - padding, x))
|
const clampedX = Math.max(padding, Math.min(window.innerWidth - padding, x))
|
||||||
const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y))
|
const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y))
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{/* Full-screen backdrop for dismiss — matches modal overlay opacity */}
|
{/* Full-screen backdrop for dismiss — matches modal overlay opacity */}
|
||||||
<div
|
<div
|
||||||
className="radial-backdrop"
|
className="radial-backdrop"
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
onContextMenu={handleBackdropClick}
|
onContextMenu={handleBackdropClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Radial menu container */}
|
{/* Radial menu container */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="radial-menu-container"
|
className="radial-menu-container"
|
||||||
onClick={handleContainerClick}
|
onClick={handleContainerClick}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: clampedX,
|
left: clampedX,
|
||||||
top: clampedY,
|
top: clampedY,
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
animation: 'radialFadeIn 100ms ease-out',
|
animation: 'radialFadeIn 100ms ease-out',
|
||||||
filter: 'drop-shadow(var(--shadow-lg))',
|
filter: 'drop-shadow(var(--shadow-lg))',
|
||||||
}}
|
}}
|
||||||
onMouseMove={handlePointerMove}
|
onMouseMove={handlePointerMove}
|
||||||
onMouseUp={handlePointerUp}
|
onMouseUp={handlePointerUp}
|
||||||
onTouchMove={handlePointerMove}
|
onTouchMove={handlePointerMove}
|
||||||
onTouchEnd={handlePointerUp}
|
onTouchEnd={handlePointerUp}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width={outerRadius * 2 + 40}
|
width={outerRadius * 2 + 40}
|
||||||
height={outerRadius * 2 + 40}
|
height={outerRadius * 2 + 40}
|
||||||
viewBox={`${-outerRadius - 20} ${-outerRadius - 20} ${outerRadius * 2 + 40} ${outerRadius * 2 + 40}`}
|
viewBox={`${-outerRadius - 20} ${-outerRadius - 20} ${outerRadius * 2 + 40} ${outerRadius * 2 + 40}`}
|
||||||
style={{ overflow: 'visible' }}
|
style={{ overflow: 'visible' }}
|
||||||
>
|
>
|
||||||
{/* Wedges */}
|
{/* Wedges */}
|
||||||
{wedges.map((wedge, i) => {
|
{wedges.map((wedge, i) => {
|
||||||
const iconPos = getIconPosition(i)
|
const iconPos = getIconPosition(i)
|
||||||
const Icon = wedge.icon
|
const Icon = wedge.icon
|
||||||
const wedgeClasses = `radial-wedge${wedge.requiresAuth ? ' auth-required' : ''}`
|
const wedgeClasses = `radial-wedge${wedge.requiresAuth ? ' auth-required' : ''}`
|
||||||
return (
|
return (
|
||||||
<g key={wedge.id} className={wedgeClasses} data-wedge-id={wedge.id}>
|
<g key={wedge.id} className={wedgeClasses} data-wedge-id={wedge.id}>
|
||||||
<path
|
<path
|
||||||
d={generateWedgePath(i)}
|
d={generateWedgePath(i)}
|
||||||
className="wedge-path"
|
className="wedge-path"
|
||||||
/>
|
/>
|
||||||
<g transform={`translate(${iconPos.x}, ${iconPos.y})`}>
|
<g transform={`translate(${iconPos.x}, ${iconPos.y})`}>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<foreignObject
|
<foreignObject
|
||||||
x={-9}
|
x={-9}
|
||||||
y={-12}
|
y={-12}
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
style={{ overflow: 'visible' }}
|
style={{ overflow: 'visible' }}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={18}
|
size={18}
|
||||||
className="wedge-icon"
|
className="wedge-icon"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
</foreignObject>
|
</foreignObject>
|
||||||
)}
|
)}
|
||||||
<text
|
<text
|
||||||
y={10}
|
y={10}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="wedge-label"
|
className="wedge-label"
|
||||||
>
|
>
|
||||||
{wedge.label}
|
{wedge.label}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Center disc */}
|
{/* Center disc */}
|
||||||
<circle
|
<circle
|
||||||
cx={0}
|
cx={0}
|
||||||
cy={0}
|
cy={0}
|
||||||
r={innerRadius - 2}
|
r={innerRadius - 2}
|
||||||
className="center-disc"
|
className="center-disc"
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
y={-4}
|
y={-4}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="center-coords"
|
className="center-coords"
|
||||||
>
|
>
|
||||||
{lat?.toFixed(4)}
|
{lat?.toFixed(4)}
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
y={8}
|
y={8}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="center-coords"
|
className="center-coords"
|
||||||
>
|
>
|
||||||
{lon?.toFixed(4)}
|
{lon?.toFixed(4)}
|
||||||
</text>
|
</text>
|
||||||
{centerLabel && (
|
{centerLabel && (
|
||||||
<text
|
<text
|
||||||
y={20}
|
y={20}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="center-label"
|
className="center-label"
|
||||||
>
|
>
|
||||||
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel}
|
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
/* Backdrop — matches modal overlay */
|
/* Backdrop — matches modal overlay */
|
||||||
.radial-backdrop {
|
.radial-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wedge paths — themed surface */
|
/* Wedge paths — themed surface */
|
||||||
.wedge-path {
|
.wedge-path {
|
||||||
fill: var(--bg-overlay);
|
fill: var(--bg-overlay);
|
||||||
fill-opacity: 0.92;
|
fill-opacity: 0.92;
|
||||||
stroke: var(--border);
|
stroke: var(--border);
|
||||||
stroke-width: 1;
|
stroke-width: 1;
|
||||||
transition: fill 100ms ease, fill-opacity 100ms ease;
|
transition: fill 100ms ease, fill-opacity 100ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radial-wedge:hover .wedge-path {
|
.radial-wedge:hover .wedge-path {
|
||||||
fill: var(--accent-muted);
|
fill: var(--accent-muted);
|
||||||
fill-opacity: 1;
|
fill-opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radial-wedge.active .wedge-path {
|
.radial-wedge.active .wedge-path {
|
||||||
fill: var(--accent-muted);
|
fill: var(--accent-muted);
|
||||||
fill-opacity: 1;
|
fill-opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wedge icons — secondary text color */
|
/* Wedge icons — secondary text color */
|
||||||
.wedge-icon {
|
.wedge-icon {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
transition: color 100ms ease;
|
transition: color 100ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radial-wedge:hover .wedge-icon,
|
.radial-wedge:hover .wedge-icon,
|
||||||
.radial-wedge.active .wedge-icon {
|
.radial-wedge.active .wedge-icon {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wedge labels — secondary text */
|
/* Wedge labels — secondary text */
|
||||||
.wedge-label {
|
.wedge-label {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
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;
|
}
|
||||||
}
|
|
||||||
|
.radial-wedge:hover .wedge-label,
|
||||||
.radial-wedge:hover .wedge-label,
|
.radial-wedge.active .wedge-label {
|
||||||
.radial-wedge.active .wedge-label {
|
fill: var(--text-primary);
|
||||||
fill: var(--text-primary);
|
}
|
||||||
}
|
|
||||||
|
/* Auth-required wedges — grayed out */
|
||||||
/* Auth-required wedges — grayed out */
|
.radial-wedge.auth-required .wedge-icon {
|
||||||
.radial-wedge.auth-required .wedge-icon {
|
color: var(--text-tertiary);
|
||||||
color: var(--text-tertiary);
|
}
|
||||||
}
|
|
||||||
|
.radial-wedge.auth-required .wedge-label {
|
||||||
.radial-wedge.auth-required .wedge-label {
|
fill: var(--text-tertiary);
|
||||||
fill: var(--text-tertiary);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Auth-required wedges — suppress hover highlight (still clickable) */
|
/* Auth-required wedges — suppress hover highlight (still clickable) */
|
||||||
.radial-wedge.auth-required:hover .wedge-path,
|
.radial-wedge.auth-required:hover .wedge-path,
|
||||||
.radial-wedge.auth-required.active .wedge-path {
|
.radial-wedge.auth-required.active .wedge-path {
|
||||||
|
|
@ -312,47 +311,47 @@ export default function RadialMenu({
|
||||||
fill-opacity: 0.92;
|
fill-opacity: 0.92;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auth-required hover — content stays muted */
|
/* Auth-required hover — content stays muted */
|
||||||
.radial-wedge.auth-required:hover .wedge-icon,
|
.radial-wedge.auth-required:hover .wedge-icon,
|
||||||
.radial-wedge.auth-required.active .wedge-icon {
|
.radial-wedge.auth-required.active .wedge-icon {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radial-wedge.auth-required:hover .wedge-label,
|
.radial-wedge.auth-required:hover .wedge-label,
|
||||||
.radial-wedge.auth-required.active .wedge-label {
|
.radial-wedge.auth-required.active .wedge-label {
|
||||||
fill: var(--text-tertiary);
|
fill: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Center disc — raised surface */
|
/* Center disc — raised surface */
|
||||||
.center-disc {
|
.center-disc {
|
||||||
fill: var(--bg-raised);
|
fill: var(--bg-raised);
|
||||||
stroke: var(--border);
|
stroke: var(--border);
|
||||||
stroke-width: 1;
|
stroke-width: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Center coordinates — monospace primary */
|
/* Center coordinates — monospace primary */
|
||||||
.center-coords {
|
.center-coords {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
fill: var(--text-primary);
|
fill: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Center label — secondary italic */
|
/* Center label — secondary italic */
|
||||||
.center-label {
|
.center-label {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
fill: var(--text-secondary);
|
fill: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes radialFadeIn {
|
@keyframes radialFadeIn {
|
||||||
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
|
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
|
||||||
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
return createPortal(content, document.body)
|
return createPortal(content, document.body)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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])
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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 }),
|
|
||||||
}))
|
|
||||||
|
|
@ -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"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue