diff --git a/docs/OFFROUTE-ARCHITECTURE.md b/docs/OFFROUTE-ARCHITECTURE.md deleted file mode 100644 index c29945b..0000000 --- a/docs/OFFROUTE-ARCHITECTURE.md +++ /dev/null @@ -1,427 +0,0 @@ -# OFFROUTE — Off-Network Effort-Based Routing Architecture - -**Status:** Draft -**Author:** Matt / Claude -**Date:** 2026-05-07 -**Canonical location:** `matt/refactored-recon` alongside PROJECT-BIBLE.md, NAV-INTEGRATION-v4.md - ---- - -## 1. Vision - -From any arbitrary point in the backcountry — no trails, no roads, no signal — route via effort cost and safety to the nearest trail, to a BLM/forest road, to a paved road, to home. Four segments, one continuous path, one GeoJSON response. - -The system serves two interfaces: -- **Navi frontend** (`navi.echo6.co`) — visual route overlay on the map -- **Aurora via Meshtastic** — text-based step-by-step directions for a lost person with no map display - -This capability does not exist in any open-source consumer product. CalTopo, OnX, Gaia GPS, AllTrails — all route on-network only. The military has Primordial Ground Guidance (closed-source ATAK plugin). We are building the open, self-hosted equivalent. - ---- - -## 2. The Routing Chain - -``` -[Lost person] - │ - ▼ - ┌──────────────────────────────────────────┐ - │ Segment 1: WILDERNESS → TRAIL │ - │ Engine: Raster cost-surface pathfinder │ - │ Cost: slope effort + vegetation + │ - │ water barriers + land ownership │ - │ Output: lat/lon waypoint sequence │ - └──────────────────────────────────────────┘ - │ snap to nearest trail entry point - ▼ - ┌──────────────────────────────────────────┐ - │ Segment 2: TRAIL → BLM/FOREST ROAD │ - │ Engine: Valhalla (pedestrian/MTB) │ - │ Cost: elevation-aware hike/bike profile │ - └──────────────────────────────────────────┘ - │ transition to road network - ▼ - ┌──────────────────────────────────────────┐ - │ Segment 3: BLM ROAD → PAVED ROAD │ - │ Engine: Valhalla (auto/motorcycle) │ - │ Cost: standard + surface preference │ - └──────────────────────────────────────────┘ - │ - ▼ - ┌──────────────────────────────────────────┐ - │ Segment 4: PAVED ROAD → HOME │ - │ Engine: Valhalla (auto) │ - │ Cost: standard routing │ - └──────────────────────────────────────────┘ -``` - -Segments 2–4 already work today via Valhalla. **Segment 1 is the engineering gap.** - ---- - -## 3. Endpoint Design - -### `POST /api/offroute` - -**Request:** -```json -{ - "start": { "lat": 43.512, "lon": -114.823 }, - "destination": { "lat": 42.736, "lon": -114.514 }, - "mode": "foot", - "max_search_km": 15 -} -``` - -**Modes:** `foot` | `mtb` | `atv` - -**Response:** -```json -{ - "segments": [ - { - "type": "wilderness", - "geometry": { "type": "LineString", "coordinates": [...] }, - "distance_m": 4200, - "elevation_gain_m": 310, - "elevation_loss_m": 85, - "estimated_time_min": 72, - "surface": "cross-country", - "instructions": [ - { "bearing": 245, "distance_m": 320, "terrain": "sagebrush slope", "grade_pct": 8 }, - { "bearing": 260, "distance_m": 510, "terrain": "drainage crossing", "grade_pct": -12 } - ] - }, - { - "type": "trail", - "geometry": { "type": "LineString", "coordinates": [...] }, - "trail_name": "Pioneer Cabin Trail", - "distance_m": 6100, - "estimated_time_min": 85 - }, - { - "type": "road_unpaved", - "geometry": { "type": "LineString", "coordinates": [...] }, - "road_name": "FR-227", - "distance_m": 12400, - "estimated_time_min": 22 - }, - { - "type": "road_paved", - "geometry": { "type": "LineString", "coordinates": [...] }, - "distance_m": 34000, - "estimated_time_min": 28 - } - ], - "total_distance_m": 56700, - "total_time_min": 207, - "confidence": 0.82 -} -``` - -**Aurora tool integration:** Add `offroute` to `nav_tools.py` alongside existing `route()` and `reverse_geocode()`. The semantic query router gets a new embedding for "I'm lost, help me get home" / "navigate to nearest road" type queries. - ---- - -## 4. Pathfinder Architecture (Segment 1) - -### 4.1 No Pre-Rendered Slope Rasters - -The pathfinder does NOT need pre-computed slope layers, GDAL processing, or reprojection. It reads elevation directly: - -1. Routing request arrives with a start point and search radius -2. Determine which PMTiles z12 tiles cover the search area -3. Fetch + decode Terrarium tiles from `planet-dem.pmtiles` → numpy elevation arrays -4. Cache decoded arrays keyed by (z, x, y) — LRU, in-memory -5. A* / Dijkstra runs on the elevation grid, computing grade between neighbors on the fly -6. Cost function = `grade → effort model → multiply by land-cover friction → check barriers` - -### 4.2 Elevation Data Source - -**Primary:** `planet-dem.pmtiles` (658GB on pi-nas, served via nginx at `/tiles/planet-dem.pmtiles`) -- Mapterhorn, Copernicus GLO-30 source, Terrarium encoding (lossless WebP) -- z12 with 512px tiles = ~13–16m pixels at Idaho latitude -- 30m effective resolution (upsampled from source) -- Decode: `elevation = (R * 256 + G + B/256) - 32768` (metres, EGM2008) -- Precision: ~3.9mm quantization — far below source noise (~4m RMSE) - -**Upgrade path:** USGS 3DEP 1/3 arc-second (10m bare-earth DTM, CONUS). Same architecture, denser grid. Free download. Address when/if 30m proves insufficient for safety. - -**Regional GeoTIFFs** (203GB on NAS at `/mnt/nas/nav/contour-rebuild/dem/`): Keep as insurance until this pipeline is validated, then delete. - -### 4.3 Cost Function - -For each candidate move from cell A to cell B: - -```python -def travel_cost(elev_a, elev_b, distance_m, friction_ab): - grade = (elev_b - elev_a) / distance_m - - # Safety gate — impassable above threshold - slope_deg = math.degrees(math.atan(abs(grade))) - if slope_deg > MAX_SLOPE[mode]: # foot=40°, mtb=25°, atv=30° - return INF - - # Effort model (speed in km/h) - if mode == "foot": - # Tobler off-path hiking function - speed = 0.6 * 6.0 * math.exp(-3.5 * abs(grade + 0.05)) - elif mode == "mtb": - # Herzog wheeled-transport polynomial (crit_slope=8%) - speed = herzog_wheeled(grade, crit_slope=0.08, base_speed=12) - elif mode == "atv": - # Herzog with higher base speed and slope tolerance - speed = herzog_wheeled(grade, crit_slope=0.15, base_speed=25) - - # Time cost (seconds to traverse this cell) - time_s = (distance_m / 1000.0) / speed * 3600.0 - - # Multiply by land-cover friction - time_s *= friction_ab - - return time_s -``` - -**Tobler off-path:** `W = 0.6 × 6 × exp(-3.5 × |S + 0.05|)` km/h -Peak speed 3.6 km/h at ~-2.86° (slight downhill). The 0.6 multiplier is the off-trail penalty. - -**Herzog wheeled-transport:** sixth-degree polynomial fitted to wheeled vehicle energy expenditure. Has a `crit_slope` parameter where switchbacks become more efficient than direct climb. Best published proxy for MTB/ATV in open-source literature. - -**Reference implementations:** R `leastcostpath` package contains 30+ validated cost functions including Tobler, Tobler off-path, Irmischer-Clarke (male/female/off-path, fitted to USMA cadets), Naismith-Langmuir, Herzog, Minetti, Campbell 2019 percentiles. Port as needed. - -### 4.4 Friction Layers (Cost Surface Inputs) - -All pre-computed offline, tiled, cached. Updated infrequently. - -| Layer | Source | Resolution | Purpose | Update Frequency | -|---|---|---|---|---| -| Elevation | planet-dem.pmtiles | ~30m (z12) | Slope/grade calculation | Static | -| Land cover | NLCD | 30m | Vegetation traversal friction | ~Annual | -| Waterways | OSM | Rasterized from vectors | Barrier (∞ cost) except at bridges/fords | Weekly from planet PBF | -| Water bodies | OSM `natural=water` | Rasterized polygons | Barrier (∞) | Weekly | -| Cliffs | OSM `natural=cliff` | Rasterized lines | Barrier (∞) | Weekly | -| Land ownership | PAD-US | Polygon raster | Access restrictions per mode | ~Quarterly | -| Trails/roads | OSM + USFS | Rasterized lines | Low-cost corridors (negative friction) | Weekly | - -**NLCD friction mapping (foot mode example):** - -| NLCD Class | Description | Friction Multiplier | -|---|---|---| -| 11 | Open Water | ∞ | -| 21 | Developed, Open Space | 1.0 | -| 22 | Developed, Low Intensity | 1.2 | -| 31 | Barren Land | 1.1 | -| 41 | Deciduous Forest | 1.8 | -| 42 | Evergreen Forest | 2.0 | -| 43 | Mixed Forest | 1.9 | -| 52 | Shrub/Scrub | 1.5 | -| 71 | Grassland/Herbaceous | 1.2 | -| 90 | Woody Wetlands | 3.5 | -| 95 | Emergent Herbaceous Wetlands | 4.0 | - -Mode-specific adjustments: MTB and ATV get higher penalties on forest/wetland. ATV gets ∞ on wilderness-designated areas (PAD-US `Des_Tp = WA`). - -**Trail burn-in:** Rasterize OSM trails/tracks as cells with reduced friction (trail cell = 0.5× base, track = 0.3×, road = 0.1×). The pathfinder naturally gravitates toward and follows these corridors without special logic. - -### 4.5 Engine Choice - -**Recommended: scikit-image `MCP_Geometric` for initial build.** - -- Cython Dijkstra, 1–5 seconds on 2–4M cell grids -- `find_costs(start)` computes cumulative cost surface once; `traceback(target)` for any target is O(path length) — reuse for "nearest trail," "nearest road," and destination all in one pass -- `MCP_Flexible` subclass allows overriding `_travel_cost()` for anisotropic costs (uphill ≠ downhill) -- Pure Python integration with Flask backend -- Memory OK up to ~20–40M cells on 24GB - -**Performance path: Rust `pathfinding` crate as a microservice.** - -- A*, Dijkstra, HPA* (hierarchical) all available -- Custom successor function encodes anisotropic cost -- Sub-second on 4M cells -- `hierarchical_pathfinding` crate enables multi-resolution: coarse pass → refine in corridor -- Wrap in Axum HTTP server, call from Flask - -**Decision:** Start with scikit-image Python. If latency is a problem, rewrite the inner loop in Rust. The cost function, data pipeline, and API don't change. - -### 4.6 Multi-Resolution Strategy - -For routes where the wilderness segment exceeds ~10km, full-resolution pathfinding on the entire search area gets expensive. Use the Primordial Ground Guidance approach: - -1. **Coarse pass:** Downsample cost grid 4× (120m cells). Solve A*. Sub-second. -2. **Corridor extraction:** Buffer the coarse path by 200m. -3. **Fine pass:** Re-solve at native 30m resolution only within the corridor. Sub-second. -4. **Total:** <2 seconds for a 15km wilderness segment. - -### 4.7 Network Hand-Off - -The raster pathfinder needs to know where the trail/road network starts so it can stop: - -1. **Pre-compute trail entry points:** Extract from OSM all endpoints and intersections of `highway=path|track|footway|bridleway|unclassified|tertiary|secondary|primary`. Store as a PostGIS point table (or SQLite spatial index in `navi.db`). -2. **Rasterize entry points** onto the cost grid as target cells. -3. **Run `MCP.find_costs(start)`** — the Dijkstra wave expands until it reaches any entry-point cell. Use `goal_reached()` override in `MCP_Flexible` for early termination. -4. **Snap** the reached entry point to its nearest Valhalla graph node. -5. **Call Valhalla** from that node to destination with appropriate costing profile. -6. **Concatenate** raster path + Valhalla path into one GeoJSON with per-segment metadata. - ---- - -## 5. Data Acquisition Checklist - -| Dataset | Status | Size | Action | -|---|---|---|---| -| DEM (planet-dem.pmtiles) | ✅ Have it | 658GB | Serving via nginx from pi-nas | -| NLCD Land Cover (CONUS) | ❌ Not acquired | ~5GB | Download from USGS MRLC | -| NLCD Tree Canopy (CONUS) | ❌ Not acquired | ~2GB | Optional — continuous friction surface | -| OSM Planet PBF | ❌ Not acquired for this use | ~70GB | Extract waterways, cliffs, trails via osmium | -| PAD-US | ✅ Have source | 1.6GB in /mnt/nav/padus/ | Rasterize by access class | -| USFS Trail/Road layers | ✅ Have PMTiles | 848MB + 496MB | Need raw vectors for rasterization | -| Trail entry points index | ❌ Not built | ~50MB | Extract from OSM + USFS | - -**First acquisition:** NLCD. It's the single most impactful layer after the DEM — without land cover, the pathfinder can't distinguish open meadow from dense forest. - ---- - -## 6. Safety Considerations - -This system may guide people through dangerous terrain. Design constraints: - -- **Hard slope cutoffs are non-negotiable.** No route segment should ever cross terrain above the mode's max slope threshold, regardless of how much faster the direct path would be. -- **Confidence scoring:** Every response includes a `confidence` field (0.0–1.0) based on: DEM resolution vs route steepness, distance from nearest verified trail data, land cover data freshness, number of barrier crossings. -- **Fallback behaviors:** If no safe route exists within `max_search_km`, return an error with the direction and distance to the nearest trail (as a bearing, not a route). Never hallucinate a route through impassable terrain. -- **Per-step user confirmation (Aurora/Meshtastic):** In text mode, Aurora should confirm each major terrain transition ("You will cross a drainage heading southwest — confirm you can see safe footing"). A lost person should never blindly follow instructions into terrain they can't visually verify. -- **DSM vs DTM caveat:** Copernicus GLO-30 is a Digital Surface Model (includes treetops, buildings). A flat meadow next to tall pines will show false slope at the treeline. The system should note this in Aurora's instructions for forested areas. -- **30m resolution risk:** A 15m-wide cliff band can be smoothed into a single "steep but passable" cell. The safety gate catches obvious cliffs but may miss narrow features. Documented limitation; mitigated by upgrading to 10m USGS 3DEP in the future. - ---- - -## 7. Implementation Phases - -### Phase O1: Foundation -- Acquire NLCD CONUS land cover -- Build PMTiles elevation decoder + tile cache module -- Implement Tobler off-path cost function -- Prototype: scikit-image MCP on a small Idaho bbox (e.g., 20km × 20km around Sun Valley) -- Validate: does the path avoid canyons, prefer gentle slopes, follow drainages? - -### Phase O2: Friction Integration -- Rasterize NLCD into friction grid -- Rasterize OSM waterways/cliffs as barriers -- Rasterize PAD-US access restrictions -- Burn OSM trails/roads as low-cost corridors -- Combined cost surface for foot mode - -### Phase O3: Network Hand-Off -- Build trail entry point index from OSM + USFS -- Implement MCP → Valhalla stitching -- `/api/offroute` endpoint (foot mode only) -- GeoJSON response with per-segment metadata - -### Phase O4: Multi-Mode + Aurora -- Add MTB cost function (Herzog wheeled-transport) -- Add ATV cost function -- Mode-specific barrier rules (wilderness restrictions for MTB/ATV) -- Aurora tool integration — `offroute` in nav_tools.py -- Meshtastic text-based instruction generation (bearings, terrain descriptions) - -### Phase O5: Performance + Polish -- Multi-resolution pathfinding (coarse → corridor → fine) -- Rust pathfinder microservice (if Python latency is insufficient) -- Confidence scoring -- Navi frontend route visualization with segment coloring -- Elevation profile display per segment - -### Phase O6: Pi 5 Field Kit -- Offline PMTiles elevation access -- Pre-baked cost tiles for Idaho/CONUS-West -- Bbox-filter packager for all spatial datasets -- Full offline operation via Meshtastic ↔ Aurora ↔ offroute chain - ---- - -## 8. Infrastructure - -**Runtime services (VM 1130):** -- `/api/offroute` — Flask endpoint in RECON dashboard -- Tile cache — LRU in-memory decoded elevation arrays -- Valhalla Docker :8002 — on-network routing (already running) - -**Data (VM 1130 /mnt/nav/):** -- Pre-baked friction rasters (NLCD, barriers, trails) — tiled GeoTIFF or COG -- Trail entry point index — SQLite spatial in navi.db - -**Data (pi-nas /mnt/nas/nav/):** -- planet-dem.pmtiles — 658GB, served via nginx -- Regional GeoTIFF DEMs — 203GB, insurance until pipeline validated - -**Compute (cortex or matt-desktop):** -- One-time cost surface generation jobs (NLCD rasterization, OSM extraction, barrier tiling) - ---- - -## 9. Key Decisions Made - -| Decision | Rationale | -|---|---| -| No pre-rendered slope rasters | Pathfinder computes grade on the fly from cached elevation arrays. Simpler, no GDAL dependency at runtime. | -| planet-dem.pmtiles as single elevation source | Same data already drives contours + hillshade. 30m sufficient for first build. Global coverage. | -| scikit-image MCP for initial engine | Cython Dijkstra, proven on 2–4M cell grids, Python-native, anisotropic via MCP_Flexible. Rust upgrade path if needed. | -| Tobler off-path as primary foot cost model | Best-validated off-trail hiking function. Inherently anisotropic. 0.6× off-trail multiplier built in. | -| Trail burn-in (not separate hand-off logic) | Rasterizing trails as low-cost cells lets the pathfinder naturally follow them without mode-switching logic. | -| Pre-baked friction rasters (offline) | NLCD, barriers, and land ownership change slowly. Build once, cache, update periodically. | -| Multi-resolution for long routes | Coarse pass → corridor → fine pass. Standard technique from military route planning (Primordial Ground Guidance). | -| Confidence scoring on every response | Safety-critical system. User must know when to trust vs. verify the route. | - ---- - -## 10. Open Questions - -- [ ] What is the right `max_slope` cutoff per mode? Needs field testing / literature review. -- [ ] Should the pathfinder use A* (faster, needs admissible heuristic) or Dijkstra (guaranteed optimal, slower)? MCP uses Dijkstra; pyastar2d uses A*. -- [ ] How to generate natural-language terrain descriptions for Aurora from raster data? (e.g., "sagebrush slope" vs. "forested drainage") -- [ ] Should we pre-compute the full cost surface for Idaho/CONUS-West, or generate it on demand per request? -- [ ] How to handle seasonal/weather variations? (Snow, spring runoff, wildfire closures) -- [ ] Valhalla pedestrian elevation costing (PR #3234) — test and validate before relying on it for segments 2–4. -- [ ] USFS MVUM (Motor Vehicle Use Maps) — authoritative ATV/4WD legal access layer. Acquire and integrate for ATV mode. - ---- - -## References - -- Tobler, W. (1993). Three Presentations on Geographical Analysis and Modeling. NCGIA TR 93-1. -- Irmischer, I.J. & Clarke, K.C. (2018). Measuring and modeling the speed of human navigation. *Cartography and GIS*, 45(2), 177-186. -- Herzog, I. (2020). Spatial Analysis Based on Cost Functions. In *Archaeological Spatial Analysis*. -- Lewis, J. (2023). `leastcostpath` R package. CRAN. -- GRASS GIS. `r.walk` manual. grass.osgeo.org. -- Hoover, B. et al. (2019). CostMAP: An open-source software package for developing cost surfaces. LANL. -- Mapterhorn project. mapterhorn.com. BSD-3. - - ---- - -## 11. On-Network Traffic Intelligence - -Two features that affect Valhalla segments (2–4) of the offroute chain, not the wilderness pathfinder (segment 1): - -### Traffic-Aware Routing - -- Valhalla supports time-dependent costing via traffic speed tiles -- TomTom traffic tiles already integrated in Navi at `/api/traffic/*` (currently visual overlay only) -- **Integration path:** configure Valhalla `traffic_tile_dir` to consume TomTom speed data so route calculations account for live congestion -- **Effect on offroute:** segments 2–4 (trail-to-road, road-to-road, road-to-home) would route around congested corridors -- Does NOT affect segment 1 (wilderness pathfinder) - -### Idaho 511 Incident Feed - -- Idaho 511 API provides real-time construction zones, accidents, and road closures -- Two integration points: - 1. **Visual layer** — display incidents on Navi map as icons/overlays - 2. **Routing barriers** — feed active closures to Valhalla as `avoid_locations` or edge exclusions so routes avoid closed roads -- **Implementation:** polling daemon (5–10 min interval), stores active incidents in `navi.db`, expires automatically when cleared -- Affects both standalone Valhalla routing and offroute segments 2–4 -- **Stretch goal:** ingest other state 511 feeds for cross-state trips - -### Sequencing - -- Both features are post-offroute-core (after Phase O3) -- Can be built in parallel — traffic routing is Valhalla config, 511 is a new ingestion daemon + map layer -- Neither blocks wilderness pathfinder development diff --git a/docs/navi-feature-ideas.md b/docs/navi-feature-ideas.md deleted file mode 100644 index 13ab389..0000000 --- a/docs/navi-feature-ideas.md +++ /dev/null @@ -1,92 +0,0 @@ -# Navi Feature Ideas - -Planned features and enhancements for the Navi navigation platform. - ---- - -## Traffic & Incident Intelligence - -### Traffic-Aware Routing - -**Status:** Planned (post-Phase O3) - -Integrate TomTom traffic data into Valhalla routing calculations: - -- TomTom traffic tiles already available at `/api/traffic/*` (visual overlay) -- Configure Valhalla `traffic_tile_dir` to consume speed data -- Routes will account for live congestion on segments 2–4 of offroute chain -- Does not affect wilderness pathfinder (segment 1) - -### Idaho 511 Incident Feed - -**Status:** Planned (post-Phase O3) - -Real-time road closure and incident integration: - -- Poll Idaho 511 API every 5–10 minutes -- Store active incidents in `navi.db` with auto-expiration -- Display incidents as map overlay (icons/markers) -- Feed closures to Valhalla as `avoid_locations` for routing -- Stretch: support other state 511 feeds for cross-state trips - ---- - -## Tracking & Situational Awareness - -### ADS-B Aircraft Tracking - -**Status:** Planned - -Display live aircraft positions from ADS-B receivers: - -- Integrate with local ADS-B receiver (dump1090/readsb) -- Show aircraft positions, altitude, callsign on map -- Useful for backcountry SAR coordination and general aviation awareness - -### AIS Vessel Tracking - -**Status:** Planned - -Display marine vessel positions: - -- Integrate with AIS receiver or feed -- Show vessel positions, heading, name on map -- Applicable for coastal/maritime navigation scenarios - ---- - -## TAK Integration - -### TAK Server + EUD Integration - -**Status:** Planned - -Connect Navi to the TAK ecosystem (ATAK, iTAK, WinTAK): - -- TAK Server integration for shared situational awareness -- Push Navi routes to TAK clients as CoT (Cursor on Target) -- Pull team member positions from TAK into Navi -- Enable SAR/field team coordination through unified COP - ---- - -## Mobile & Offline - -### Native iOS App - -**Status:** Planned - -Native iOS application for offline-first navigation: - -- Full offline map tile access -- Offline routing with pre-cached Valhalla tiles -- Integration with Apple Watch for turn-by-turn -- Meshtastic/LoRa mesh network support for off-grid comms - ---- - -## Notes - -- Features above Phase O3 depend on core offroute functionality being complete -- Traffic and 511 features can be built in parallel -- TAK integration useful for field coordination but not blocking core nav diff --git a/package-lock.json b/package-lock.json index 3bd1ca0..682949c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "navi", "version": "0.0.0", "dependencies": { - "@acalcutt/maplibre-contour-pmtiles": "^0.1.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -38,12 +37,6 @@ "vite": "^8.0.9" } }, - "node_modules/@acalcutt/maplibre-contour-pmtiles": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@acalcutt/maplibre-contour-pmtiles/-/maplibre-contour-pmtiles-0.1.2.tgz", - "integrity": "sha512-dCyJFLLM4NomLoJ22McRp7yETFmzUuA6iEMVJS6+mFyHoNk7Sv6RI4Hn0DhGKeyjcJgan3YnfSnzsqRinnXSug==", - "license": "BSD-3-Clause" - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", diff --git a/package.json b/package.json index ae0057b..9faab00 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "preview": "vite preview" }, "dependencies": { - "@acalcutt/maplibre-contour-pmtiles": "^0.1.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/src/App.jsx b/src/App.jsx index 3bdea6e..0d02c8f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useCallback } from 'react' import { useStore } from './store' import { useTheme } from './hooks/useTheme' -import { fetchAuthState } from './api' +import { requestRoute, fetchAuthState } from './api' +import { decodePolyline } from './utils/decode' import MapView from './components/MapView' import Panel from './components/Panel' @@ -11,10 +12,20 @@ import LocateButton from './components/LocateButton' export default function App() { const mapViewRef = useRef(null) + const routeDebounceRef = useRef(null) // Initialize theme system useTheme() + const stops = useStore((s) => s.stops) + const mode = useStore((s) => s.mode) + const route = useStore((s) => s.route) + const gpsOrigin = useStore((s) => s.gpsOrigin) + const geoPermission = useStore((s) => s.geoPermission) + const setRoute = useStore((s) => s.setRoute) + const setRouteLoading = useStore((s) => s.setRouteLoading) + const setRouteError = useStore((s) => s.setRouteError) + const clearRoute = useStore((s) => s.clearRoute) const setAuth = useStore((s) => s.setAuth) // Initialize auth state on app load (single fetch, no polling) @@ -22,18 +33,70 @@ export default function App() { fetchAuthState().then(setAuth) }, [setAuth]) - // Handle clear route from panel - const handleClearRoute = useCallback(() => { - mapViewRef.current?.clearRoute?.() - }, []) + // Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms) + useEffect(() => { + if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) + + routeDebounceRef.current = setTimeout(async () => { + const { userLocation } = useStore.getState() + + let effective = stops.map((s) => ({ lat: s.lat, lon: s.lon })) + if (gpsOrigin && geoPermission === 'granted' && userLocation) { + effective = [{ lat: userLocation.lat, lon: userLocation.lon }, ...effective] + } + + if (effective.length < 2) { + clearRoute() + return + } + + setRouteLoading(true) + + try { + const data = await requestRoute(effective, mode) + if (data.trip) { + setRoute(data.trip) + } else { + setRouteError('No route returned') + } + } catch (e) { + setRouteError(e.message || 'Route request failed') + } finally { + setRouteLoading(false) + } + }, 500) + + return () => { + if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) + } + }, [stops, mode, gpsOrigin, geoPermission, clearRoute, setRoute, setRouteLoading, setRouteError]) + + // Handle maneuver click + const handleManeuverClick = useCallback( + (maneuver) => { + if (!route || !route.legs) return + + const legIdx = maneuver._legIndex || 0 + const leg = route.legs[legIdx] + if (!leg || !leg.shape) return + + const coords = decodePolyline(leg.shape, 6) + const idx = maneuver.begin_shape_index + if (idx >= 0 && idx < coords.length) { + const [lng, lat] = coords[idx] + mapViewRef.current?.flyTo(lat, lng, 15) + } + }, + [route] + ) return (
- - + + - + {/* Bottom-right map controls */}
diff --git a/src/api.js b/src/api.js index bed21ec..fe8fd02 100644 --- a/src/api.js +++ b/src/api.js @@ -321,70 +321,3 @@ export async function fetchAuthState() { return { authenticated: false, username: null } } } - -// ── Offroute API ── - -const OFFROUTE_URL = "/api/offroute" -const MVUM_URL = "/api/mvum" - -/** - * Request an offroute route from the pathfinder API. - * @param {object} start - { lat, lon } - * @param {object} end - { lat, lon } - * @param {string} mode - foot | mtb | atv | vehicle - * @param {string} boundaryMode - strict | pragmatic | emergency - * @returns {Promise} Offroute response with GeoJSON route - */ -export async function requestOffroute(start, end, mode = "foot", boundaryMode = "strict") { - const body = { - start: [start.lat, start.lon], - end: [end.lat, end.lon], - mode, - boundary_mode: boundaryMode, - } - console.log('[TRACE-API] requestOffroute body:', JSON.stringify(body)) - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 120000) // 2 min timeout for complex routes - - try { - const resp = await fetch(OFFROUTE_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.message || 'Could not find a route. Try a different start point or mode.') - } - - return resp.json() - } finally { - clearTimeout(timeout) - } -} - -/** - * Fetch MVUM (Motor Vehicle Use Map) info for a location. - * @param {number} lat - * @param {number} lon - * @param {number} radius - Search radius in meters - * @returns {Promise} MVUM feature info or null - */ -export async function fetchMvumInfo(lat, lon, radius = 500) { - try { - const params = new URLSearchParams({ - lat: String(lat), - lon: String(lon), - radius: String(radius), - }) - const resp = await fetch(`${MVUM_URL}?${params}`, { signal: AbortSignal.timeout(5000) }) - if (!resp.ok) return null - const data = await resp.json() - return data.feature || null - } catch { - return null - } -} diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx deleted file mode 100644 index a01f1c9..0000000 --- a/src/components/DirectionsPanel.jsx +++ /dev/null @@ -1,417 +0,0 @@ -import { useEffect, useMemo } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical } from "lucide-react" -import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core" -import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable" -import { CSS } from "@dnd-kit/utilities" -import { useStore } from "../store" -import LocationInput from "./LocationInput" -import ManeuverList from "./ManeuverList" - -const TRAVEL_MODES = [ - { id: "auto", label: "Drive", Icon: Car }, - { id: "foot", label: "Foot", Icon: Footprints }, - { id: "mtb", label: "MTB", Icon: Bike }, - { id: "atv", label: "ATV", Icon: Car }, - { id: "vehicle", label: "4x4", Icon: Car }, -] - -const BOUNDARY_MODES = [ - { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, - { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, - { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, -] - -// Sortable row component -function SortableRow({ id, children }) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - zIndex: isDragging ? 1000 : 1, - } - - return ( -
- {/* Drag handle */} - - {children} -
- ) -} - -export default function DirectionsPanel({ onClose }) { - const routeStart = useStore((s) => s.routeStart) - const routeEnd = useStore((s) => s.routeEnd) - const routeMode = useStore((s) => s.routeMode) - const boundaryMode = useStore((s) => s.boundaryMode) - const routeResult = useStore((s) => s.routeResult) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - const stops = useStore((s) => s.stops) - const userLocation = useStore((s) => s.userLocation) - const geoPermission = useStore((s) => s.geoPermission) - - const setRouteStart = useStore((s) => s.setRouteStart) - const setRouteEnd = useStore((s) => s.setRouteEnd) - const setRouteMode = useStore((s) => s.setRouteMode) - const setBoundaryMode = useStore((s) => s.setBoundaryMode) - const computeRoute = useStore((s) => s.computeRoute) - const clearRoute = useStore((s) => s.clearRoute) - const setDirectionsMode = useStore((s) => s.setDirectionsMode) - const addIntermediateStop = useStore((s) => s.addIntermediateStop) - const updateStop = useStore((s) => s.updateStop) - const removeStop = useStore((s) => s.removeStop) - const setStops = useStore((s) => s.setStops) - - // DnD sensors - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ) - - // Build unified list for drag-and-drop: origin + stops + destination - // Each item has: { id, type, data } - const unifiedList = useMemo(() => { - const items = [] - if (routeStart) { - items.push({ id: "origin", type: "origin", data: routeStart }) - } - stops.forEach((stop) => { - items.push({ id: stop.id, type: "stop", data: stop }) - }) - if (routeEnd) { - items.push({ id: "destination", type: "destination", data: routeEnd }) - } - return items - }, [routeStart, stops, routeEnd]) - - const itemIds = useMemo(() => unifiedList.map((item) => item.id), [unifiedList]) - - // Auto-fill origin with GPS if available and origin is empty - useEffect(() => { - if (!routeStart && geoPermission === "granted" && userLocation) { - setRouteStart({ - lat: userLocation.lat, - lon: userLocation.lon, - name: "Your location", - source: "gps", - }) - } - }, [routeStart, geoPermission, userLocation, setRouteStart]) - - // Auto-compute route when both endpoints are set - useEffect(() => { - if (routeStart && routeEnd) { - computeRoute() - } - }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) - - const handleClose = () => { - clearRoute() - setDirectionsMode(false) - onClose?.() - } - - const handleAddStop = () => { - addIntermediateStop() - } - - // Handle drag end - reorder the unified list - const handleDragEnd = (event) => { - const { active, over } = event - if (!over || active.id === over.id) return - - const oldIndex = unifiedList.findIndex((item) => item.id === active.id) - const newIndex = unifiedList.findIndex((item) => item.id === over.id) - - if (oldIndex === -1 || newIndex === -1) return - - // Reorder the unified list - const reordered = arrayMove(unifiedList, oldIndex, newIndex) - - // Extract new origin, stops, and destination from reordered list - // First item becomes origin, last becomes destination, middle are stops - if (reordered.length === 0) return - - const newOriginItem = reordered[0] - const newDestItem = reordered.length > 1 ? reordered[reordered.length - 1] : null - const newStopItems = reordered.length > 2 ? reordered.slice(1, -1) : [] - - // Convert items to proper format - const newOrigin = newOriginItem.data ? { - lat: newOriginItem.data.lat, - lon: newOriginItem.data.lon, - name: newOriginItem.data.name, - source: newOriginItem.data.source, - } : null - - const newDest = newDestItem?.data ? { - lat: newDestItem.data.lat, - lon: newDestItem.data.lon, - name: newDestItem.data.name, - source: newDestItem.data.source, - } : null - - const newStops = newStopItems.map((item) => ({ - id: item.id === "origin" || item.id === "destination" ? crypto.randomUUID() : item.id, - lat: item.data?.lat ?? null, - lon: item.data?.lon ?? null, - name: item.data?.name ?? "", - })) - - // Update state - setRouteStart(newOrigin) - setRouteEnd(newDest) - setStops(newStops) - - // Trigger route recalculation - setTimeout(() => computeRoute(), 0) - } - - // Check if route has wilderness segments - const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 - - return ( -
- {/* Header */} -
- - Directions - - -
- - {/* Drag-and-drop location list */} - - -
- {unifiedList.map((item, idx) => ( - -
- {item.type === "origin" && ( - - )} - {item.type === "destination" && ( - - )} - {item.type === "stop" && ( - { - if (place) { - updateStop(item.id, place) - } - }} - placeholder={`Stop ${idx}`} - icon="stop" - fieldId={`stop-${item.id}`} - autoFocus={item.data.lat == null} - /> - )} -
- {/* Remove button for intermediate stops only */} - {item.type === "stop" && ( - - )} - {/* Spacer for origin/destination to align with stops that have remove button */} - {item.type !== "stop" && ( -
- )} - - ))} - - {/* Add stop button - only show when route exists */} - {routeStart && routeEnd && stops.length < 8 && ( - - )} -
- - - - {/* Travel mode selector */} -
- {TRAVEL_MODES.map((m) => { - const active = routeMode === m.id - return ( - - ) - })} -
- - {/* Boundary mode selector (only for non-auto modes) */} - {routeMode !== "auto" && ( -
- {BOUNDARY_MODES.map((m) => { - const active = boundaryMode === m.id - return ( - - ) - })} -
- )} - - {/* Loading indicator */} - {routeLoading && ( -
-
- - Finding route... - -
- )} - - {/* Error message - friendly text, no "offroute" */} - {routeError && ( -
- {routeError.includes("No route") || routeError.includes("not found") - ? "No route found. Try a different start point or mode." - : routeError.includes("entry point") - ? "No roads found nearby — try Foot mode for trails." - : routeError} -
- )} - - {/* Route legend - only shown when route has wilderness segment */} - {routeResult && hasWilderness && !routeLoading && ( -
-
- - - - Wilderness (on foot) -
-
- - - - Road/Trail -
-
- )} - - {/* Route summary and maneuvers */} - {routeResult && !routeLoading && ( -
- -
- )} - - {/* Hint when waiting for input */} - {!routeStart && !routeEnd && !routeLoading && ( -
-

- Enter addresses, paste coordinates, or click the map -

-
- )} -
- ) -} diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx deleted file mode 100644 index eb0a204..0000000 --- a/src/components/LocationInput.jsx +++ /dev/null @@ -1,321 +0,0 @@ -import { useRef, useEffect, useCallback, useState } from "react" -import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2, Target } from "lucide-react" -import toast from "react-hot-toast" -import { useStore } from "../store" -import { searchGeocode } from "../api" -import { buildAddress } from "../utils/place" -import { hasFeature } from "../config" - -/** Parse coordinate input like "42.35, -114.30" */ -function parseCoordinates(input) { - if (!input) return null - const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ - const match = input.trim().match(pattern) - if (!match) return null - const lat = parseFloat(match[1]) - const lon = parseFloat(match[2]) - if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null - return { lat, lon } -} - -function CategoryIcon({ result, size = 14 }) { - const type = result.type || "" - const source = result.source || "" - if (result._isContact) return - if (source === "nickname") return - if (type === "coordinates") return - if (type === "locality" || type === "city") return - const osmVal = result.raw?.osm_value || "" - if (osmVal.includes("cafe") || osmVal.includes("coffee")) return - if (osmVal.includes("fuel") || osmVal.includes("gas")) return - if (osmVal.includes("shop") || osmVal.includes("supermarket")) return - if (osmVal.includes("hotel") || osmVal.includes("motel")) return - return -} - -export default function LocationInput({ - value, // { lat, lon, name } or null - onChange, // (place) => void - placeholder, - icon, // "origin" | "destination" | "stop" - fieldId, // unique id for this field (for map click targeting) - onFocus, // () => void - autoFocus, -}) { - const inputRef = useRef(null) - const [query, setQuery] = useState(value?.name || "") - const [results, setResults] = useState([]) - const [loading, setLoading] = useState(false) - const [open, setOpen] = useState(false) - const [activeIndex, setActiveIndex] = useState(-1) - const debounceRef = useRef(null) - const abortRef = useRef(null) - - const contacts = useStore((s) => s.contacts) - const activeDirectionsField = useStore((s) => s.activeDirectionsField) - const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField) - const pickingRouteField = useStore((s) => s.pickingRouteField) - const setPickingRouteField = useStore((s) => s.setPickingRouteField) - - // Sync display value when external value changes - useEffect(() => { - if (value?.name && value.name !== query) { - setQuery(value.name) - } else if (!value && query && !open) { - // Value cleared externally - setQuery("") - } - }, [value?.name, value?.lat, value?.lon]) - - const doSearch = useCallback(async (q) => { - if (abortRef.current) abortRef.current.abort() - - if (!q.trim()) { - setResults([]) - setOpen(false) - setLoading(false) - return - } - - // Check coordinates first - const coords = parseCoordinates(q) - if (coords) { - const coordResult = { - lat: coords.lat, - lon: coords.lon, - name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), - address: "Coordinates", - type: "coordinates", - source: "coordinates", - match_code: null, - raw: {}, - } - setResults([coordResult]) - setOpen(true) - setLoading(false) - return - } - - // Contact matches - 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: { contact: c }, - _isContact: true, - })) - } - - const ctrl = new AbortController() - abortRef.current = ctrl - setLoading(true) - - try { - const data = await searchGeocode(q.trim(), 5, ctrl.signal) - const combined = [...contactResults, ...(data.results || [])] - setResults(combined) - setOpen(combined.length > 0) - setActiveIndex(-1) - } catch (e) { - if (e.name !== "AbortError") { - if (contactResults.length > 0) { - setResults(contactResults) - setOpen(true) - } else { - setResults([]) - setOpen(false) - } - } - } finally { - setLoading(false) - } - }, [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([]) - setOpen(false) - onChange(null) - inputRef.current?.focus() - } - - const selectResult = (result) => { - onChange({ - lat: result.lat, - lon: result.lon, - name: result.name, - source: result.source, - matchCode: result.match_code, - }) - setQuery(result.name) - setResults([]) - setOpen(false) - setActiveIndex(-1) - } - - const handleKeyDown = (e) => { - if (!open || results.length === 0) { - if (e.key === "Escape") setOpen(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() - setOpen(false) - setActiveIndex(-1) - break - } - } - - const handleFocus = () => { - setActiveDirectionsField(fieldId) // For styling only, not map clicks - if (results.length > 0) setOpen(true) - onFocus?.() - } - - const handlePickFromMap = () => { - setPickingRouteField(fieldId) - toast("Click map to set location", { icon: "🎯", duration: 3000 }) - inputRef.current?.blur() // Unfocus input so user focuses on map - } - - const isPicking = pickingRouteField === fieldId - - const handleBlur = () => { - // Delay to allow click on dropdown - setTimeout(() => setOpen(false), 150) - } - - const isActive = activeDirectionsField === fieldId - - const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)" - - return ( -
-
- {icon === "origin" ? ( - - ) : ( - - )} - - {/* Pick from map button */} - - {loading ? ( -
- ) : query ? ( - - ) : null} -
- - {open && results.length > 0 && ( -
    - {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 ( -
  • selectResult(r)} - onMouseEnter={() => setActiveIndex(i)} - > -
    - - - - - {primary} - -
    - {secondary && ( -
    - {secondary} -
    - )} -
  • - ) - })} -
- )} -
- ) -} diff --git a/src/components/ManeuverList.jsx b/src/components/ManeuverList.jsx index 44d1ffc..d869b66 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -1,343 +1,140 @@ -import { - MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, - MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, - GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle, - Compass, ArrowUp, ArrowUpRight, ArrowRight, ArrowDownRight, ArrowDown, - ArrowDownLeft, ArrowLeft, ArrowUpLeft, MapPin -} from 'lucide-react' -import { useStore } from '../store' - -/** - * Format distance with commas for feet, one decimal for miles. - * Under 1 mile: "2,640 ft" - * 1+ miles: "1.3 mi" - */ -function formatDistance(distanceM, distanceKm) { - let meters = null - if (distanceM !== undefined && distanceM !== null) { - meters = distanceM - } else if (distanceKm !== undefined && distanceKm !== null) { - meters = distanceKm * 1000 - } - - if (meters === null) return '' - - const miles = meters / 1609.34 - if (miles < 1) { - const feet = Math.round(meters * 3.28084) - return feet.toLocaleString() + ' ft' - } - return miles.toFixed(1) + ' mi' -} - -function formatTimeMin(minutes) { - if (minutes < 60) return Math.round(minutes) + ' min' - const h = Math.floor(minutes / 60) - const m = Math.round(minutes % 60) - return m > 0 ? h + 'h ' + m + 'm' : h + 'h' -} - -// Compass arrow icon based on cardinal direction with rotation -function CompassIcon({ cardinal, bearing, size = 16 }) { - // Use bearing to rotate arrow, or fall back to cardinal-based icon - if (bearing !== undefined && bearing !== null) { - return ( - - ) - } - - const props = { size, strokeWidth: 2 } - const arrowMap = { - 'N': ArrowUp, - 'NNE': ArrowUpRight, - 'NE': ArrowUpRight, - 'ENE': ArrowRight, - 'E': ArrowRight, - 'ESE': ArrowRight, - 'SE': ArrowDownRight, - 'SSE': ArrowDownRight, - 'S': ArrowDown, - 'SSW': ArrowDownLeft, - 'SW': ArrowDownLeft, - 'WSW': ArrowLeft, - 'W': ArrowLeft, - 'WNW': ArrowLeft, - 'NW': ArrowUpLeft, - 'NNW': ArrowUpLeft, - } - const Icon = arrowMap[cardinal] || Compass - return -} - -// Wilderness maneuver icon -function WildernessIcon({ type, cardinal, bearing, size = 16 }) { - if (type === 'arrival') { - return - } - return -} - -// Network maneuver icon (Valhalla types) -function ManeuverIcon({ type }) { - const size = 16 - const props = { size, strokeWidth: 1.5 } - switch (type) { - case 0: return - case 1: return - case 2: return - case 3: return - case 4: case 5: return - case 6: return - case 7: return - case 8: return - case 9: return - case 10: case 11: case 12: return - case 15: case 16: return - case 24: return - case 25: return - case 26: return - default: return - } -} - -/** - * Add transport mode prefix to network maneuver instruction. - * "Drive east on..." for auto, "Walk south on..." for foot, "Ride north on..." for mtb - */ -function formatNetworkInstruction(instruction, mode) { - if (!instruction) return '' - - // Get verb based on mode - const modeVerbs = { - 'auto': 'Drive', - 'foot': 'Walk', - 'pedestrian': 'Walk', - 'mtb': 'Ride', - 'bicycle': 'Ride', - 'atv': 'Drive', - 'vehicle': 'Drive', - } - const verb = modeVerbs[mode] || 'Go' - - // Check if instruction starts with a direction verb we should replace - const startsWithVerbs = [ - 'Turn left', 'Turn right', 'Bear left', 'Bear right', - 'Keep left', 'Keep right', 'Continue', 'Head', 'Go', - 'Proceed', 'Make a', 'Take a', 'Start', 'Merge', 'Exit' - ] - - for (const v of startsWithVerbs) { - if (instruction.startsWith(v)) { - // Already has a verb, return as-is (Valhalla instructions are already good) - return instruction - } - } - - // If instruction starts with direction (north, south, etc.), prepend verb - const directions = ['north', 'south', 'east', 'west', 'onto', 'on '] - for (const dir of directions) { - if (instruction.toLowerCase().startsWith(dir)) { - return `${verb} ${instruction}` - } - } - - return instruction -} - -export default function ManeuverList() { - const routeResult = useStore((s) => s.routeResult) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - const routeMode = useStore((s) => s.routeMode) - - if (routeLoading) { - return ( -
-
- - Calculating route... - -
- ) - } - - if (routeError) { - return ( -
- {routeError} -
- ) - } - - if (!routeResult?.summary) return null - - const summary = routeResult.summary - const features = routeResult.route?.features || [] - const networkMode = summary.network_mode || routeMode || 'foot' - - // Extract maneuvers from each segment type - const wildernessStartFeature = features.find(f => - f.properties?.segment_type === 'wilderness' && f.properties?.segment_position === 'start' - ) - const networkFeature = features.find(f => f.properties?.segment_type === 'network') - const wildernessEndFeature = features.find(f => - f.properties?.segment_type === 'wilderness' && f.properties?.segment_position === 'end' - ) - - const wildernessStartManeuvers = wildernessStartFeature?.properties?.maneuvers || [] - const networkManeuvers = networkFeature?.properties?.maneuvers || [] - const wildernessEndManeuvers = wildernessEndFeature?.properties?.maneuvers || [] - - const hasManeuvers = wildernessStartManeuvers.length > 0 || - networkManeuvers.length > 0 || - wildernessEndManeuvers.length > 0 - - return ( -
- {/* Total summary */} -
- - {formatDistance(null, summary.total_distance_km)} - - - {formatTimeMin(summary.total_effort_minutes)} - -
- - {/* Segment breakdown */} -
- {summary.wilderness_distance_km > 0 && ( -
- - Wilderness - - {formatDistance(null, summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)} - -
- )} - {summary.network_distance_km > 0 && ( -
- - Road/Trail - - {formatDistance(null, summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)} - -
- )} -
- - {/* Warnings */} - {(summary.barrier_crossings > 0 || summary.mvum_closed_crossings > 0) && ( -
- {summary.barrier_crossings > 0 && ( -
- - {summary.barrier_crossings} barrier crossing{summary.barrier_crossings > 1 ? 's' : ''} -
- )} - {summary.mvum_closed_crossings > 0 && ( -
- - {summary.mvum_closed_crossings} MVUM closure{summary.mvum_closed_crossings > 1 ? 's' : ''} -
- )} -
- )} - - {/* Turn-by-turn directions */} - {hasManeuvers && ( -
-
Directions
- - {/* Wilderness start maneuvers */} - {wildernessStartManeuvers.length > 0 && ( - <> -
- Wilderness — On Foot -
- {wildernessStartManeuvers.map((man, i) => ( -
- - - -
-

- {man.instruction} -

-
-
- ))} - - )} - - {/* Network maneuvers */} - {networkManeuvers.length > 0 && ( - <> - {wildernessStartManeuvers.length > 0 && ( -
- Road/Trail -
- )} - {networkManeuvers.map((man, i) => ( -
- - - -
-

- {formatNetworkInstruction(man.instruction, networkMode)} -

-

- {formatDistance(null, man.distance_km)} -

-
-
- ))} - - )} - - {/* Wilderness end maneuvers */} - {wildernessEndManeuvers.length > 0 && ( - <> -
- Wilderness — On Foot -
- {wildernessEndManeuvers.map((man, i) => ( -
- - - -
-

- {man.instruction} -

-
-
- ))} - - )} -
- )} -
- ) -} +import { + MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, + MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, + GitMerge, CornerRightDown, CornerRightUp, Navigation +} from 'lucide-react' +import { useStore } from '../store' + +function formatTime(seconds) { + if (seconds < 60) return `${Math.round(seconds)}s` + if (seconds < 3600) return `${Math.round(seconds / 60)} min` + const h = Math.floor(seconds / 3600) + const m = Math.round((seconds % 3600) / 60) + return m > 0 ? `${h}h ${m}m` : `${h}h` +} + +function formatDist(miles) { + if (miles < 0.1) return `${Math.round(miles * 5280)} ft` + return `${miles.toFixed(1)} mi` +} + +function ManeuverIcon({ type }) { + const size = 16 + const props = { size, strokeWidth: 1.5 } + switch (type) { + case 0: return + case 1: return + case 2: return + case 3: return + case 4: case 5: return + case 6: return + case 7: return + case 8: return + case 9: return + case 10: case 11: case 12: return + case 15: case 16: return + case 24: return + case 25: return + case 26: return + default: return + } +} + +export default function ManeuverList({ onManeuverClick }) { + const route = useStore((s) => s.route) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + + if (routeLoading) { + return ( +
+
+ + Calculating route... + +
+ ) + } + + if (routeError) { + return ( +
+ {routeError} +
+ ) + } + + if (!route || !route.legs) return null + + const totalTime = route.summary?.time || 0 + const totalDist = route.summary?.length || 0 + + const allManeuvers = [] + let timeRemaining = totalTime + + for (let legIdx = 0; legIdx < route.legs.length; legIdx++) { + const leg = route.legs[legIdx] + for (const man of leg.maneuvers || []) { + allManeuvers.push({ ...man, _legIndex: legIdx, timeRemaining }) + timeRemaining -= man.time || 0 + } + } + + return ( +
+ {/* Route summary */} +
+ + {formatDist(totalDist)} + + + {formatTime(totalTime)} + +
+ + {/* Maneuver steps */} +
+ {allManeuvers.map((man, i) => ( + + ))} +
+
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index e9131b3..47454ee 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -6,15 +6,13 @@ import { layers, namedTheme } from 'protomaps-themes-base' import { getTheme, getThemeSprite, getOverlayConfig } from '../themes/registry' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' -import { fetchReverse, requestOffroute } from '../api' +import { fetchReverse } from '../api' import { getConfig, hasFeature } from '../config' -import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus } from 'lucide-react' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' -import mlcontour from '@acalcutt/maplibre-contour-pmtiles' -let demSourceInstance = null /** Check if current theme is dark based on registry */ function isCurrentThemeDark() { @@ -25,12 +23,7 @@ function isCurrentThemeDark() { const ROUTE_SOURCE = 'route-source' const BOUNDARY_SOURCE = 'boundary-source' const BOUNDARY_LAYER = 'boundary-layer' -const STATE_BOUNDARIES_LAYER = 'state-boundaries-z4-z7' const ROUTE_LAYER_PREFIX = 'route-layer-' -const OFFROUTE_SOURCE = 'offroute-source' -const OFFROUTE_WILDERNESS_LAYER = 'offroute-wilderness' -const OFFROUTE_NETWORK_LAYER = 'offroute-network' -const OFFROUTE_MARKERS_LAYER = 'offroute-markers' const HILLSHADE_SOURCE = 'hillshade-dem' const HILLSHADE_LAYER = 'hillshade-layer' const TRAFFIC_SOURCE = 'traffic-tiles' @@ -39,9 +32,21 @@ 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-source' -const CONTOUR_LINE = 'contour-lines' -const CONTOUR_LABEL = 'contour-labels' +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' +const CONTOUR_TEST_10FT_SOURCE = 'contour-test-10ft-tiles' +const CONTOUR_TEST_10FT_MINOR = 'contour-test-10ft-minor' +const CONTOUR_TEST_10FT_INTERMEDIATE = 'contour-test-10ft-intermediate' +const CONTOUR_TEST_10FT_INDEX = 'contour-test-10ft-index' +const CONTOUR_TEST_10FT_LABEL = 'contour-test-10ft-label' const MEASURE_SOURCE = 'measure-source' const MEASURE_LINE_LAYER = 'measure-line-layer' const MEASURE_POINT_LAYER = 'measure-point-layer' @@ -259,35 +264,12 @@ function applyBaseLabelStyling(map) { 'text-halo-width': 1.8, } }) - - // Adjust label zoom ranges for proper hierarchy: - // - Countries: z1-z4 (fade out as states appear) - // - States/provinces: z4-z7 (appear as countries fade, fade as cities dominate) - // - Cities: unchanged (natural min_zoom in tile data) - try { - if (map.getLayer('places_country')) { - map.setLayerZoomRange('places_country', 1, 5) - } - if (map.getLayer('places_region')) { - map.setLayerZoomRange('places_region', 4, 8) - // FIX: The protomaps theme uses name:short which doesn't exist in tiles - // Use coalesce to fall back to ref (e.g., "CA") then name (e.g., "California") - map.setLayoutProperty('places_region', 'text-field', [ - 'coalesce', - ['get', 'name'], - ['get', 'ref'], - ['get', 'name:short'] - ]) - } - } catch (e) { - // Ignore if layers don't exist - } } /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() - const tileUrl = config?.tileset?.url || '/tiles/planet/planet-20260420.pmtiles' + const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles' const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM' // Use namedTheme directly for built-in themes, custom colors for others @@ -528,8 +510,6 @@ function addPublicLands(map, themeId) { type: 'symbol', source: PUBLIC_LANDS_SOURCE, 'source-layer': 'public_lands', - // Exclude PAD-US sub-polygons whose unit_nm is "Unknown " — USGS source artifact, not real label. - filter: ['!', ['==', ['slice', ['coalesce', ['get', 'name'], ''], 0, 8], 'Unknown ']], minzoom: 10, layout: { 'text-field': ['get', 'name'], @@ -559,84 +539,309 @@ function removePublicLands(map) { if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE) } -/** Add topographic contours via maplibre-contour */ -function addContours(map) { - console.log('[CONTOUR] addContours called, source exists:', !!map?.getSource(CONTOUR_SOURCE), 'demSource:', !!demSourceInstance) - if (!map || !demSourceInstance || map.getSource(CONTOUR_SOURCE)) return - const contourThresholds = { - 3: [5000, 25000], - 4: [2500, 10000], - 5: [1000, 5000], - 6: [1000, 5000], - 7: [500, 2500], - 8: [500, 2500], - 9: [250, 1000], - 10: [200, 1000], - 11: [200, 1000], - 12: [100, 500], - 13: [100, 500], - 14: [50, 200], - 15: [20, 100], - } +/** Add topographic contour vector tile overlay */ +function addContours(map, themeId) { + if (!map || map.getSource(CONTOUR_SOURCE)) return + + const c = getOverlayConfig(themeId, 'contours') + map.addSource(CONTOUR_SOURCE, { type: 'vector', - tiles: [demSourceInstance.contourProtocolUrl({ - multiplier: 3.28084, - thresholds: contourThresholds, - })], - maxzoom: 16, + url: 'pmtiles:///tiles/contours-na.pmtiles', }) - console.log('[CONTOUR] protocol URL:', demSourceInstance.contourProtocolUrl({ - multiplier: 3.28084, - thresholds: contourThresholds, - })) - console.log('[CONTOUR] source added:', !!map.getSource(CONTOUR_SOURCE)) + + // 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 } + if (layer.type === 'symbol') { + beforeId = layer.id + break + } } - const isDark = document.documentElement.getAttribute('data-theme') === 'dark' + + // Minor contours (40ft) — visible z11+ map.addLayer({ - id: CONTOUR_LINE, type: 'line', source: CONTOUR_SOURCE, + id: CONTOUR_MINOR, + type: 'line', + source: CONTOUR_SOURCE, 'source-layer': 'contours', + minzoom: 11, + filter: ['==', ['get', 'tier'], 'minor'], paint: { - 'line-color': 'rgba(0,0,0,0.35)', - 'line-width': [ - 'interpolate', ['linear'], ['zoom'], - 7, ['match', ['get', 'level'], 1, 1, 0.3], - 11, ['match', ['get', 'level'], 1, 1.5, 0.6], - 14, ['match', ['get', 'level'], 1, 2, 0.8], - ], + 'line-color': c.minorColor, + 'line-opacity': c.minorOpacity * c.opacityMod, + 'line-width': ['interpolate', ['linear'], ['zoom'], 11, c.minorWidth.z11, 14, c.minorWidth.z14], }, }, beforeId) + + // Intermediate contours (200ft) — visible z8+ map.addLayer({ - id: CONTOUR_LABEL, type: 'symbol', source: CONTOUR_SOURCE, + id: CONTOUR_INTERMEDIATE, + type: 'line', + source: CONTOUR_SOURCE, 'source-layer': 'contours', - filter: ['>', ['get', 'level'], 0], + minzoom: 8, + filter: ['==', ['get', 'tier'], 'intermediate'], + paint: { + 'line-color': c.intermediateColor, + 'line-opacity': c.intermediateOpacity * c.opacityMod, + 'line-width': ['interpolate', ['linear'], ['zoom'], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], + }, + }, 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': c.indexColor, + 'line-opacity': c.indexOpacity * c.opacityMod, + 'line-width': ['interpolate', ['linear'], ['zoom'], 4, c.indexWidth.z4, 14, c.indexWidth.z14], + }, + }, 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': c.labelSize, + 'text-font': c.labelFont, 'symbol-placement': 'line', - 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 9, 11, 11, 14, 13], - 'text-field': ['concat', ['number-format', ['get', 'ele'], {}], "'"], - 'text-font': ['Noto Sans Medium'], - 'text-max-angle': 25, + 'text-anchor': 'center', + 'symbol-spacing': 400, + 'text-max-angle': 30, + 'text-allow-overlap': false, }, paint: { - 'text-color': 'rgba(0,0,0,0.7)', - 'text-halo-color': 'rgba(255,255,255,0.9)', - 'text-halo-width': 1.5, + 'text-color': c.labelColor, + 'text-halo-color': c.labelHaloColor, + 'text-halo-width': c.labelHaloWidth, + 'text-opacity': c.labelOpacity, }, }) - console.log('[CONTOUR] layers added:', !!map.getLayer(CONTOUR_LINE), !!map.getLayer(CONTOUR_LABEL)) } /** Remove contour layers + source */ function removeContours(map) { if (!map) return if (map.getLayer(CONTOUR_LABEL)) map.removeLayer(CONTOUR_LABEL) - if (map.getLayer(CONTOUR_LINE)) map.removeLayer(CONTOUR_LINE) + 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, themeId) { + if (!map || map.getSource(CONTOUR_TEST_SOURCE)) return + + const c = getOverlayConfig(themeId, 'contoursTest') + + 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 + } + } + + // 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": c.minorColor, + "line-opacity": c.minorOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14], + }, + }, 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": c.intermediateColor, + "line-opacity": c.intermediateOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], + }, + }, 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": c.indexColor, + "line-opacity": c.indexOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14], + }, + }, 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": c.labelSize, + "text-font": c.labelFont, + "symbol-placement": "line", + "text-anchor": "center", + "symbol-spacing": 400, + "text-max-angle": 30, + "text-allow-overlap": false, + }, + paint: { + "text-color": c.labelColor, + "text-halo-color": c.labelHaloColor, + "text-halo-width": c.labelHaloWidth, + "text-opacity": c.labelOpacity, + }, + }) +} + +/** 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) +} + +/** Add TEST 10ft topographic contour overlay (green color scheme) */ +function addContoursTest10ft(map, themeId) { + if (!map || map.getSource(CONTOUR_TEST_10FT_SOURCE)) return + + const c = getOverlayConfig(themeId, 'contoursTest10ft') + + map.addSource(CONTOUR_TEST_10FT_SOURCE, { + type: "vector", + url: "pmtiles:///tiles/contours-test-10ft.pmtiles", + }) + + let beforeId = undefined + for (const layer of map.getStyle().layers) { + if (layer.type === "symbol") { + beforeId = layer.id + break + } + } + + // Minor contours (10ft) — green scheme + map.addLayer({ + id: CONTOUR_TEST_10FT_MINOR, + type: "line", + source: CONTOUR_TEST_10FT_SOURCE, + "source-layer": "contours", + minzoom: 11, + filter: ["==", ["get", "tier"], "minor"], + paint: { + "line-color": c.minorColor, + "line-opacity": c.minorOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14], + }, + }, beforeId) + + // Intermediate contours (50ft) — green scheme + map.addLayer({ + id: CONTOUR_TEST_10FT_INTERMEDIATE, + type: "line", + source: CONTOUR_TEST_10FT_SOURCE, + "source-layer": "contours", + minzoom: 8, + filter: ["==", ["get", "tier"], "intermediate"], + paint: { + "line-color": c.intermediateColor, + "line-opacity": c.intermediateOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], + }, + }, beforeId) + + // Index contours (250ft) — darker green + map.addLayer({ + id: CONTOUR_TEST_10FT_INDEX, + type: "line", + source: CONTOUR_TEST_10FT_SOURCE, + "source-layer": "contours", + minzoom: 4, + filter: ["==", ["get", "tier"], "index"], + paint: { + "line-color": c.indexColor, + "line-opacity": c.indexOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14], + }, + }, beforeId) + + // Elevation labels on index contours (z12+) + map.addLayer({ + id: CONTOUR_TEST_10FT_LABEL, + type: "symbol", + source: CONTOUR_TEST_10FT_SOURCE, + "source-layer": "contours", + minzoom: 12, + filter: ["==", ["get", "tier"], "index"], + layout: { + "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], "'"], + "text-size": c.labelSize, + "text-font": c.labelFont, + "symbol-placement": "line", + "text-anchor": "center", + "symbol-spacing": 400, + "text-max-angle": 30, + "text-allow-overlap": false, + }, + paint: { + "text-color": c.labelColor, + "text-halo-color": c.labelHaloColor, + "text-halo-width": c.labelHaloWidth, + "text-opacity": c.labelOpacity, + }, + }) +} + +/** Remove test 10ft contour layers + source */ +function removeContoursTest10ft(map) { + if (!map) return + if (map.getLayer(CONTOUR_TEST_10FT_LABEL)) map.removeLayer(CONTOUR_TEST_10FT_LABEL) + if (map.getLayer(CONTOUR_TEST_10FT_INDEX)) map.removeLayer(CONTOUR_TEST_10FT_INDEX) + if (map.getLayer(CONTOUR_TEST_10FT_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_10FT_INTERMEDIATE) + if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR) + if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE) +} /** Add USFS trails and roads vector tile overlay */ function addUsfsTrails(map, themeId) { if (!map || map.getSource(USFS_SOURCE)) return @@ -1118,114 +1323,81 @@ function updateSatellitePaint(map, themeId) { } // Track which vector layers are hidden in satellite/hybrid mode -// Track hidden layers for each mode - separate arrays for proper restoration -let hiddenFillLayers = [] -let hiddenLineLayers = [] -let hiddenSymbolLayers = [] +let hiddenVectorLayers = [] -// Layers we never hide (our own overlays) -function isProtectedLayer(id) { - return id.startsWith('public-lands') || - id.startsWith('boundary') || - id.startsWith('route') || - id.startsWith('offroute') || - id.startsWith('measure') || - id.startsWith('contour') || - id.startsWith('usfs') || - id.startsWith('blm') || - id.startsWith('hillshade') || - id.startsWith('traffic') || - id === SATELLITE_LAYER -} - -/** Hide a layer and track it */ -function hideLayer(map, layerId, trackingArray) { - if (!map.getLayer(layerId)) return - const vis = map.getLayoutProperty(layerId, 'visibility') - if (vis !== 'none') { - trackingArray.push(layerId) - map.setLayoutProperty(layerId, 'visibility', 'none') - } -} - -/** Show all layers in a tracking array */ -function showLayers(map, trackingArray) { - for (const id of trackingArray) { - if (map.getLayer(id)) { - map.setLayoutProperty(id, 'visibility', 'visible') +/** Hide vector fill layers for satellite mode */ +function hideVectorFills(map) { + if (!map) return + hiddenVectorLayers = [] + + const style = map.getStyle() + if (!style || !style.layers) return + + for (const layer of style.layers) { + // Hide fill layers (land, water, parks, buildings, etc.) + // But keep line, symbol, and circle layers + if (layer.type === 'fill' || layer.type === 'fill-extrusion') { + // Don't hide our own overlay fills (public lands, etc) + if (layer.id.startsWith('public-lands') || + layer.id.startsWith('boundary') || + layer.id.startsWith('route')) continue + + const visibility = map.getLayoutProperty(layer.id, 'visibility') + if (visibility !== 'none') { + hiddenVectorLayers.push(layer.id) + map.setLayoutProperty(layer.id, 'visibility', 'none') + } } } - trackingArray.length = 0 } -/** Set map to satellite-only mode - hide ALL vector layers except our overlays */ +/** Show all hidden vector layers */ +function showVectorFills(map) { + if (!map) return + + for (const layerId of hiddenVectorLayers) { + if (map.getLayer(layerId)) { + map.setLayoutProperty(layerId, 'visibility', 'visible') + } + } + hiddenVectorLayers = [] +} + +/** Set map to satellite-only mode */ function setSatelliteMode(map, themeId) { if (!map) return - - // First restore any previously hidden layers to clean slate - showLayers(map, hiddenFillLayers) - showLayers(map, hiddenLineLayers) - showLayers(map, hiddenSymbolLayers) - addSatelliteLayer(map, themeId) - + hideVectorFills(map) + // Also hide line layers in pure satellite mode (keep only labels for reference) const style = map.getStyle() - if (!style?.layers) return - - for (const layer of style.layers) { - if (isProtectedLayer(layer.id)) continue - - if (layer.type === 'fill' || layer.type === 'fill-extrusion' || layer.type === 'background') { - hideLayer(map, layer.id, hiddenFillLayers) - } else if (layer.type === 'line') { - hideLayer(map, layer.id, hiddenLineLayers) - } else if (layer.type === 'symbol') { - hideLayer(map, layer.id, hiddenSymbolLayers) + if (style && style.layers) { + for (const layer of style.layers) { + if (layer.type === 'line' && !layer.id.startsWith('route') && + !layer.id.startsWith('boundary') && !layer.id.startsWith('measure')) { + const visibility = map.getLayoutProperty(layer.id, 'visibility') + if (visibility !== 'none') { + hiddenVectorLayers.push(layer.id) + map.setLayoutProperty(layer.id, 'visibility', 'none') + } + } } } - - console.log('[Satellite] Hidden:', hiddenFillLayers.length, 'fills,', hiddenLineLayers.length, 'lines,', hiddenSymbolLayers.length, 'symbols') } -/** Set map to hybrid mode - satellite + roads + labels */ +/** Set map to hybrid mode (satellite + labels/roads) */ function setHybridMode(map, themeId) { if (!map) return - - // First restore any previously hidden layers to clean slate - showLayers(map, hiddenFillLayers) - showLayers(map, hiddenLineLayers) - showLayers(map, hiddenSymbolLayers) - addSatelliteLayer(map, themeId) - - const style = map.getStyle() - if (!style?.layers) return - - // In hybrid: hide fills/background, keep lines and symbols visible - for (const layer of style.layers) { - if (isProtectedLayer(layer.id)) continue - - if (layer.type === 'fill' || layer.type === 'fill-extrusion' || layer.type === 'background') { - hideLayer(map, layer.id, hiddenFillLayers) - } - // Lines and symbols stay visible for hybrid mode - } - - console.log('[Hybrid] Hidden:', hiddenFillLayers.length, 'fills, keeping lines and symbols visible') + hideVectorFills(map) + // In hybrid mode, keep road lines and labels visible + // They're already visible by default, just fills are hidden } /** Set map back to normal map mode */ function setMapMode(map) { if (!map) return - removeSatelliteLayer(map) - - // Restore all hidden layers - showLayers(map, hiddenFillLayers) - showLayers(map, hiddenLineLayers) - showLayers(map, hiddenSymbolLayers) - - console.log('[Map] Restored all vector layers') + showVectorFills(map) } @@ -1277,140 +1449,6 @@ function addBoundaryLayer(map) { }, firstSymbolId) } -/** - * FIX D: Add state/province boundary lines visible at z4-z7 - * These are administrative boundaries with kind_detail = 4 (state/province level) - * Uses theme-aware styling from the boundaries color - */ -function addStateBoundaries(map, themeId) { - if (!map || map.getLayer(STATE_BOUNDARIES_LAYER)) return - - // Get the boundaries color from the current theme - const theme = getTheme(themeId) - const boundaryColor = theme?.colors?.boundaries || '#808080' - - // Find first symbol layer to insert below labels - const layers = map.getStyle().layers - let firstSymbolId = null - for (const layer of layers) { - if (layer.type === 'symbol') { - firstSymbolId = layer.id - break - } - } - - // Add state/province boundaries layer for z4-z7 - // kind_detail 4 = state/province level administrative boundaries - map.addLayer({ - id: STATE_BOUNDARIES_LAYER, - type: 'line', - source: 'protomaps', - 'source-layer': 'boundaries', - filter: ['==', 'kind_detail', 4], - minzoom: 4, - maxzoom: 8, - paint: { - 'line-color': boundaryColor, - 'line-width': [ - 'interpolate', ['linear'], ['zoom'], - 4, 0.5, - 7, 1.0 - ], - 'line-opacity': [ - 'interpolate', ['linear'], ['zoom'], - 4, 0.4, - 7, 0.6 - ], - 'line-dasharray': [4, 2], - }, - }, firstSymbolId) -} - -/** Remove state boundaries layer */ -function removeStateBoundaries(map) { - if (!map) return - if (map.getLayer(STATE_BOUNDARIES_LAYER)) { - map.removeLayer(STATE_BOUNDARIES_LAYER) - } -} - - -/** Clear offroute display layers */ -function clearRouteDisplay(map) { - if (!map) return - if (map.getLayer(OFFROUTE_WILDERNESS_LAYER)) map.removeLayer(OFFROUTE_WILDERNESS_LAYER) - if (map.getLayer(OFFROUTE_NETWORK_LAYER)) map.removeLayer(OFFROUTE_NETWORK_LAYER) - if (map.getLayer(OFFROUTE_MARKERS_LAYER)) map.removeLayer(OFFROUTE_MARKERS_LAYER) - if (map.getSource(OFFROUTE_SOURCE)) map.removeSource(OFFROUTE_SOURCE) -} - -/** Update offroute display with route GeoJSON */ -function updateRouteDisplay(map, routeGeojson) { - if (!map || !routeGeojson) return - - // Clear existing layers - clearRouteDisplay(map) - - // Add source with route features - map.addSource(OFFROUTE_SOURCE, { - type: "geojson", - data: routeGeojson, - }) - - // Find first symbol layer for proper z-ordering - let beforeId = undefined - for (const layer of map.getStyle().layers) { - if (layer.type === "symbol") { - beforeId = layer.id - break - } - } - - // Wilderness segment - dashed orange line - map.addLayer({ - id: OFFROUTE_WILDERNESS_LAYER, - type: "line", - source: OFFROUTE_SOURCE, - filter: ["==", ["get", "segment_type"], "wilderness"], - layout: { "line-join": "round", "line-cap": "round" }, - paint: { - "line-color": "#f97316", // orange-500 - "line-width": 4, - "line-opacity": 0.9, - "line-dasharray": [8, 4], - }, - }, beforeId) - - // Network segment - solid blue line - map.addLayer({ - id: OFFROUTE_NETWORK_LAYER, - type: "line", - source: OFFROUTE_SOURCE, - filter: ["==", ["get", "segment_type"], "network"], - layout: { "line-join": "round", "line-cap": "round" }, - paint: { - "line-color": "#3b82f6", // blue-500 - "line-width": 5, - "line-opacity": 0.85, - }, - }, beforeId) - - // Fit bounds to route - const features = routeGeojson.features || [] - const allCoords = features - .filter(f => f.geometry?.coordinates) - .flatMap(f => f.geometry.coordinates) - - if (allCoords.length > 0) { - const bounds = allCoords.reduce( - (b, c) => b.extend(c), - new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) - ) - const leftPad = 420 - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) - } -} - const MapView = forwardRef(function MapView(_, ref) { const mapRef = useRef(null) const mapInstance = useRef(null) @@ -1421,31 +1459,30 @@ const MapView = forwardRef(function MapView(_, ref) { 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, usfsTrails: false, blmTrails: false }) + const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false, usfsTrails: false, blmTrails: false }) // Flag to suppress map-click when a stop pin was clicked const pinClickedRef = useRef(false) const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState const hoveredFeatureRef = useRef(null) // for hover highlight const updateBoundaryRef = useRef(null) // boundary update function - const lastFlyTargetRef = useRef(null) // track last fly target to avoid re-flying on metadata updates // Refs for measurement state (accessible in click handlers) const measuringRef = useRef({ active: false, points: [] }) const measureLabelsRef = useRef([]) // HTML label elements + 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 clickMarker = useStore((s) => s.clickMarker) const setClickMarker = useStore((s) => s.setClickMarker) const clearClickMarker = useStore((s) => s.clearClickMarker) + const gpsOrigin = useStore((s) => s.gpsOrigin) const geoPermission = useStore((s) => s.geoPermission) const setSheetState = useStore((s) => s.setSheetState) const setMapCenter = useStore((s) => s.setMapCenter) const pickingLocationFor = useStore((s) => s.pickingLocationFor) const setEditingContact = useStore((s) => s.setEditingContact) const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor) - const directionsMode = useStore((s) => s.directionsMode) - const activeDirectionsField = useStore((s) => s.activeDirectionsField) - const pickingRouteField = useStore((s) => s.pickingRouteField) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) @@ -1664,7 +1701,7 @@ const MapView = forwardRef(function MapView(_, ref) { const radialWedges = [ { - id: "to-here", + id: "directions-to", label: "To here", icon: ArrowDownLeft, onSelect: () => { @@ -1673,48 +1710,29 @@ const MapView = forwardRef(function MapView(_, ref) { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + source: "radial_menu", + matchCode: null, } - const { routeStart, setRouteStart, setRouteEnd, computeRoute, setDirectionsMode, geoPermission, userLocation } = useStore.getState() - - setRouteEnd(place) - setDirectionsMode(true) - - if (routeStart) { - computeRoute() - } else if (geoPermission === "granted" && userLocation) { - // Use GPS as origin fallback - setRouteStart({ - lat: userLocation.lat, - lon: userLocation.lon, - name: "Your location", - source: "gps", - }) - computeRoute() - } - // If no origin and no GPS, directions panel opens and origin field auto-focuses + useStore.getState().startDirections(place) }, }, { - id: "from-here", + id: "directions-from", label: "From here", icon: ArrowUpRight, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) + const { clearStops, addStop } = useStore.getState() + clearStops() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + source: "radial_menu", + matchCode: null, } - const { clearRoute, setRouteStart, routeEnd, computeRoute, setDirectionsMode } = useStore.getState() - clearRoute() - clearRouteDisplay(mapInstance.current) - setRouteStart(place) - setDirectionsMode(true) - - if (routeEnd) { - computeRoute() - } - // If no destination, directions panel opens and destination field auto-focuses + addStop(place) + useStore.setState({ gpsOrigin: false }) }, }, { @@ -1723,33 +1741,25 @@ const MapView = forwardRef(function MapView(_, ref) { icon: Plus, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) - const { addIntermediateStop, computeRoute, routeStart, routeEnd } = useStore.getState() + const { stops, addStop, clearStops } = useStore.getState() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + source: "radial_menu", + matchCode: null, } - const success = addIntermediateStop(place) - if (success) { - // If we have both origin and destination, recalculate route - if (routeStart && routeEnd) { - computeRoute() - } + if (stops.length === 0) { + addStop(place) + useStore.setState({ gpsOrigin: false }) } else { - toast("Maximum 8 intermediate stops reached") + const success = addStop(place) + if (!success) { + toast("Maximum 10 stops reached") + } } }, }, - { - id: "clear-route", - label: "Clear", - icon: Trash2, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - useStore.getState().clearRoute() - clearRouteDisplay(mapInstance.current) - }, - }, { id: "save-place", label: "Save", @@ -1871,6 +1881,30 @@ const MapView = forwardRef(function MapView(_, ref) { removeContours(map) activeLayersRef.current.contours = false }, + addContoursTestLayer() { + const map = mapInstance.current + if (!map) return + addContoursTest(map, currentThemeRef.current) + activeLayersRef.current.contoursTest = true + }, + removeContoursTestLayer() { + const map = mapInstance.current + if (!map) return + removeContoursTest(map) + activeLayersRef.current.contoursTest = false + }, + addContoursTest10ftLayer() { + const map = mapInstance.current + if (!map) return + addContoursTest10ft(map, currentThemeRef.current) + activeLayersRef.current.contoursTest10ft = true + }, + removeContoursTest10ftLayer() { + const map = mapInstance.current + if (!map) return + removeContoursTest10ft(map) + activeLayersRef.current.contoursTest10ft = false + }, addUsfsTrailsLayer() { const map = mapInstance.current if (!map) return @@ -1916,14 +1950,6 @@ const MapView = forwardRef(function MapView(_, ref) { updateSatellitePaint(map, currentThemeRef.current) }, - // Clear offroute route from map - clearRoute() { - const map = mapInstance.current - if (!map) return - clearRouteDisplay(map) - useStore.getState().clearRoute() - }, - })) // Initialize map @@ -1932,21 +1958,6 @@ const MapView = forwardRef(function MapView(_, ref) { maplibregl.addProtocol('pmtiles', protocol.tile) const config = getConfig() - - // Initialize DemSource for maplibre-contour (uses same PMTiles as hillshade) - if (!demSourceInstance) { - const hs = config?.tileset_hillshade - if (hs?.url) { - demSourceInstance = new mlcontour.DemSource({ - url: `pmtiles://${window.location.origin}${hs.url}`, - encoding: hs.encoding || 'terrarium', - maxzoom: hs.max_zoom || 12, - worker: true, - cacheSize: 100, - }) - demSourceInstance.setupMaplibre(maplibregl) - } - } 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] @@ -2013,33 +2024,7 @@ const MapView = forwardRef(function MapView(_, ref) { return } - // Handle explicit pick-from-map mode for route inputs - const { pickingRouteField, setRouteStart, setRouteEnd, clearPickingRouteField } = useStore.getState() - if (pickingRouteField) { - const { lng, lat } = e.lngLat - map.getCanvas().style.cursor = '' - // Reverse geocode for name - fetchReverse(lat, lng).then((place) => { - const name = place?.name || lat.toFixed(5) + ", " + lng.toFixed(5) - const location = { lat, lon: lng, name, source: "map_click" } - if (pickingRouteField === "origin") { - setRouteStart(location) - } else if (pickingRouteField === "destination") { - setRouteEnd(location) - } - clearPickingRouteField() - }).catch(() => { - const name = lat.toFixed(5) + ", " + lng.toFixed(5) - const location = { lat, lon: lng, name, source: "map_click" } - if (pickingRouteField === "origin") { - setRouteStart(location) - } else if (pickingRouteField === "destination") { - setRouteEnd(location) - } - clearPickingRouteField() - }) - return - } + const store = useStore.getState() const marker = store.clickMarker @@ -2080,21 +2065,15 @@ const MapView = forwardRef(function MapView(_, ref) { }) } } else { - // Outside circle → clear current selection and fall through to select new + // Outside circle → deselect, no new selection store.clearClickMarker() store.clearSelectedPlace() // Clear boundary when deselecting if (updateBoundaryRef.current) updateBoundaryRef.current(null) setSelectedHighlight(map, null) - // Fall through to State A to select new feature at click point } - } - - // Select new feature at click point (State A or after clearing previous selection) - { - const store = useStore.getState() // refresh store state after potential clear - if (store.clickMarker) return // already handled above - + } else { + // State A: nothing selected → select if (window.innerWidth < 768) setSheetState('collapsed') const { lng, lat } = e.lngLat @@ -2207,35 +2186,12 @@ const MapView = forwardRef(function MapView(_, ref) { const props = labelFeature.properties const geom = labelFeature.geometry - // CRITICAL: Always use CLICK coordinates for routing (lat, lng from e.lngLat) - // Feature coordinates are only for display/fetching details - let featureLat = lat // Click coordinate - used for routing - let featureLon = lng // Click coordinate - used for routing - let displayLat = lat // May be updated to feature coords for display - let displayLon = lng + // Get feature coordinates (Point geometry) + let featureLat = lat + let featureLon = lng if (geom && geom.type === 'Point' && geom.coordinates) { - // Store feature's canonical coords separately - NOT for routing - displayLon = geom.coordinates[0] - displayLat = geom.coordinates[1] - } - - // FIX A: For park-type features, also query polygon layers to get boundary geometry - const parkKinds = ['national_park', 'park', 'cemetery', 'protected_area', 'nature_reserve', 'forest', 'golf_course', 'wood', 'zoo', 'garden'] - let polygonGeometry = null - if (parkKinds.includes(props.kind)) { - // Query fill layers at the same point to find the polygon - const fillLayers = ['landuse_park', 'landuse_other'].filter(id => map.getLayer(id)) - if (fillLayers.length > 0) { - const fillFeatures = map.queryRenderedFeatures(e.point, { layers: fillLayers }) - // Find a polygon feature with matching name or at the same location - const matchingPolygon = fillFeatures.find(f => - f.properties?.name === props.name || - (f.geometry?.type === 'Polygon' || f.geometry?.type === 'MultiPolygon') - ) - if (matchingPolygon?.geometry) { - polygonGeometry = matchingPolygon.geometry - } - } + featureLon = geom.coordinates[0] + featureLat = geom.coordinates[1] } // Apply feature state highlight @@ -2256,12 +2212,6 @@ const MapView = forwardRef(function MapView(_, ref) { // For feature clicks, don't show pin marker store.clearClickMarker() - // If we found polygon geometry from the fill layer, use it as boundary directly - if (polygonGeometry && updateBoundaryRef.current) { - updateBoundaryRef.current(polygonGeometry) - } - - console.log('[TRACE-CLICK] Feature click setSelectedPlace:', { featureLat, featureLon, clickLat: lat, clickLng: lng, name: props.name }) store.setSelectedPlace({ lat: featureLat, lon: featureLon, @@ -2280,7 +2230,6 @@ const MapView = forwardRef(function MapView(_, ref) { kind: props.kind || null, kind_detail: props.kind_detail || null, elevation: props.elevation || null, - polygonGeometry: polygonGeometry || null, // Store polygon if found }, }) } else { @@ -2293,7 +2242,6 @@ const MapView = forwardRef(function MapView(_, ref) { circleRadiusPx: MARKER_RADIUS_PX, }) - console.log('[TRACE-CLICK] Reticle click setSelectedPlace:', { lat, lng }) store.setSelectedPlace({ lat, lon: lng, @@ -2371,9 +2319,6 @@ const MapView = forwardRef(function MapView(_, ref) { // Apply improved base label styling for readability applyBaseLabelStyling(map) - // FIX D: Add state/province boundary lines at z4-z7 - addStateBoundaries(map, currentThemeRef.current) - // Restore overlay layers from localStorage prefs try { const raw = localStorage.getItem('navi-layer-prefs') @@ -2424,7 +2369,7 @@ const MapView = forwardRef(function MapView(_, ref) { try { const coords = boundaryGeometry.type === 'Polygon' ? boundaryGeometry.coordinates[0] - : boundaryGeometry.coordinates.flat(2) + : boundaryGeometry.coordinates.flat(1) if (coords.length > 0) { let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity @@ -2434,18 +2379,11 @@ const MapView = forwardRef(function MapView(_, ref) { if (lat < minLat) minLat = lat if (lat > maxLat) maxLat = lat } - // Validate bounds before fitting - if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && - minLng < maxLng && minLat < maxLat) { - // FIX B: ALWAYS fitBounds when boundary exists - zoom in OR out - // The boundary defines what the user should see - const bounds = [[minLng, minLat], [maxLng, maxLat]] - map.fitBounds(bounds, { - padding: 50, - duration: 700, - maxZoom: 16, - }) - } + map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + padding: 50, + duration: 700, + maxZoom: 16, + }) } } catch (e) { console.warn('fitBounds error:', e) @@ -2455,12 +2393,6 @@ const MapView = forwardRef(function MapView(_, ref) { updateBoundaryRef.current = updateBoundaryFn useStore.getState().setUpdateBoundary(updateBoundaryFn) - // Register route display callbacks for store.computeRoute() - useStore.getState().setRouteDisplayCallbacks( - (routeGeojson) => updateRouteDisplay(map, routeGeojson), - () => clearRouteDisplay(map) - ) - // POI/label hover affordance — cursor pointer + highlight const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace'] @@ -2596,14 +2528,13 @@ const MapView = forwardRef(function MapView(_, ref) { // Apply improved base label styling for readability applyBaseLabelStyling(map) - // FIX D: Re-add state boundaries with new theme colors - addStateBoundaries(map, currentThemeRef.current) - // Re-add active overlay layers if (activeLayersRef.current.hillshade) addHillshade(map, currentThemeRef.current) if (activeLayersRef.current.traffic) addTraffic(map, currentThemeRef.current) if (activeLayersRef.current.publicLands) addPublicLands(map, currentThemeRef.current) if (activeLayersRef.current.contours) addContours(map, currentThemeRef.current) + if (activeLayersRef.current.contoursTest) addContoursTest(map, currentThemeRef.current) + if (activeLayersRef.current.contoursTest10ft) addContoursTest10ft(map, currentThemeRef.current) if (activeLayersRef.current.usfsTrails) addUsfsTrails(map, currentThemeRef.current) if (activeLayersRef.current.blmTrails) addBlmTrails(map, currentThemeRef.current) @@ -2621,8 +2552,10 @@ const MapView = forwardRef(function MapView(_, ref) { originalPaintValues = {} // Restore view - const currentRoute = useStore.getState().routeResult - if (currentRoute?.route) updateRouteDisplay(map, currentRoute.route) + map.jumpTo({ center, zoom, bearing, pitch }) + // Re-render route if exists + const currentRoute = useStore.getState().route + if (currentRoute) updateRoute(map, currentRoute) }) }, [theme]) @@ -2637,29 +2570,11 @@ const MapView = forwardRef(function MapView(_, ref) { previewMarkerRef.current = null } - if (!selectedPlace) { - lastFlyTargetRef.current = null - return - } + if (!selectedPlace) return - // Track place identity - only fly on NEW place selection, not metadata updates - const placeKey = `${selectedPlace.lat}-${selectedPlace.lon}-${selectedPlace.name}` - if (placeKey === lastFlyTargetRef.current) { - // Same place, skip flyTo (this is just a metadata update) - } else { - lastFlyTargetRef.current = placeKey - - // FIX B: Camera behavior depends on source and whether boundary exists - // - map_click / basemap_label: NO camera movement (boundary fitBounds handles it if exists) - // - search results: fly to center, but DON'T change zoom (user chose their zoom) - if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { - // Search result - fly to center without changing zoom - // Note: if this place has a boundary, the boundary fitBounds will zoom appropriately - const currentZoom = map.getZoom() - map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: currentZoom, duration: 800 }) - } - // For map_click and basemap_label: do nothing to camera - // The boundary fitBounds will handle zooming if a boundary is fetched + // Only fly to place if it came from search (not map-click which already centered) + if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { + map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) } // Different visual feedback based on mode @@ -2715,6 +2630,168 @@ const MapView = forwardRef(function MapView(_, ref) { return () => document.removeEventListener('keydown', handleKeyDown) }, [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('idle', handler) + return () => map.off('idle', 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]) + ) + // Single-panel: no floating detail + const leftPad = 420 // 360px panel + margin + 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( + `
+ ${stop.name} +
+
` + ) + .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: 420, right: 60 } }) + } + } + }, [stops, route, gpsOrigin, geoPermission]) + + // ESC key handler for measurement mode useEffect(() => { const handleKeyDown = (e) => { @@ -2740,22 +2817,6 @@ const MapView = forwardRef(function MapView(_, ref) { } }, [pickingLocationFor]) - // Handle directions mode cursor - useEffect(() => { - const map = mapInstance.current - if (!map) return - if (directionsMode && activeDirectionsField) { - map.getCanvas().style.cursor = 'crosshair' - } else if (!measuringRef.current.active && !pickingLocationFor) { - map.getCanvas().style.cursor = '' - } - return () => { - if (map && !measuringRef.current.active && !pickingLocationFor) { - map.getCanvas().style.cursor = '' - } - } - }, [directionsMode, activeDirectionsField]) - // ESC key handler for location pick mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index b89c661..2799a89 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,60 +1,53 @@ import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin, Target } from 'lucide-react' +import { LogIn, LogOut } from 'lucide-react' import ThemePicker from './ThemePicker' 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 DirectionsPanel from './DirectionsPanel' -import PlaceDetail from './PlaceDetail' +import { requestOptimizedRoute } from '../api' -const TRAVEL_MODES = [ - { id: 'auto', label: 'Drive', Icon: Car }, - { id: 'foot', label: 'Foot', Icon: Footprints }, - { id: 'mtb', label: 'MTB', Icon: Bike }, - { id: 'atv', label: 'ATV', Icon: Car }, - { id: 'vehicle', label: '4x4', Icon: Car }, -] - -const BOUNDARY_MODES = [ - { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, - { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, - { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, -] - -export default function Panel({ onClearRoute }) { +export default function Panel({ onManeuverClick }) { const selectedPlace = useStore((s) => s.selectedPlace) + const pendingDestination = useStore((s) => s.pendingDestination) const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) - const routeStart = useStore((s) => s.routeStart) - const routeEnd = useStore((s) => s.routeEnd) - const routeMode = useStore((s) => s.routeMode) - const boundaryMode = useStore((s) => s.boundaryMode) - const routeResult = useStore((s) => s.routeResult) + const clearPendingDestination = useStore((s) => s.clearPendingDestination) + 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 setRouteMode = useStore((s) => s.setRouteMode) - const setBoundaryMode = useStore((s) => s.setBoundaryMode) - const pickingRouteField = useStore((s) => s.pickingRouteField) - const setPickingRouteField = useStore((s) => s.setPickingRouteField) - const clearRoute = useStore((s) => s.clearRoute) + 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 auth = useStore((s) => s.auth) const setActiveTab = useStore((s) => s.setActiveTab) - const directionsMode = useStore((s) => s.directionsMode) - const setDirectionsMode = useStore((s) => s.setDirectionsMode) 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') + // Show contacts tab only if feature enabled AND user is authenticated const showContacts = hasFeature('has_contacts') && auth.authenticated + // Responsive detection useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) check() @@ -62,9 +55,61 @@ export default function Panel({ onClearRoute }) { return () => window.removeEventListener('resize', check) }, []) + // Auth handlers const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } + // 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 @@ -82,30 +127,21 @@ export default function Panel({ onClearRoute }) { } }, [setSheetState]) - const handleClearRoute = () => { - clearRoute() - onClearRoute?.() - } + const showOptimize = effectiveCount >= 3 + // Determine what to show based on panel state const showPreviewCard = panelState.startsWith('PREVIEW') - const hasRoutePoints = routeStart || routeEnd - const showRouteSection = hasRoutePoints || routeResult || routeLoading - const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination + const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED' + const showEmptyState = panelState === 'IDLE' && !pendingDestination - // Show side panel place card when building route (either mode) and place is selected - const showSidePlaceCard = (directionsMode || showRouteSection) && selectedPlace - - const routesContent = directionsMode ? ( - // Directions mode: just the directions panel, place card is shown in side panel - { - setDirectionsMode(false) - onClearRoute?.() - }} /> - ) : ( + // Routes tab content - now state-driven + const routesContent = ( <> - {showPreviewCard && selectedPlace && !showRouteSection && ( + {/* Preview card when place is selected */} + {showPreviewCard && selectedPlace && (
)} + {/* Route section with stops */} {showRouteSection && ( + <> +
+ +
+ +
+ + {showOptimize && ( + + )} + {pendingDestination && stops.length === 0 && ( + + )} +
+ + )} + + {/* Maneuvers when route is calculated */} + {showManeuvers && (route || routeLoading || routeError) && (
-
- - Route - - -
- -
-
- - - {routeStart?.name || 'Click pin to pick start'} - - -
-
- - - {routeEnd?.name || 'Click pin to pick destination'} - - -
-
- -
- {TRAVEL_MODES.map((m) => { - const active = routeMode === m.id - return ( - - ) - })} -
- - {routeMode !== 'auto' && ( -
- {BOUNDARY_MODES.map((m) => { - const active = boundaryMode === m.id - return ( - - ) - })} -
- )} - - +
)} + {/* Empty state */} {showEmptyState && (

Search or tap the map to explore

@@ -223,13 +203,13 @@ export default function Panel({ onClearRoute }) { {showContacts && (
) - // Side panel for place card during directions mode (desktop only) - const sidePlaceCardPanel = showSidePlaceCard && !isMobile && ( -
-
- - {selectedPlace?.name || 'Place Info'} - - -
- {/* Use PlaceCard in compact preview mode */} - -
- ) - - // Mobile overlay for place card during directions mode - const mobilePlaceCardOverlay = showSidePlaceCard && isMobile && ( -
-
- - {selectedPlace?.name || 'Place Info'} - - -
-
- -
-
- ) - + // Desktop: side panel (now 360px to accommodate PlaceCard) if (!isMobile) { return ( - <> -
- {header} - {content} -
- {sidePlaceCardPanel} - +
+ {header} + {content} +
) } + // Mobile: bottom sheet const sheetHeights = { collapsed: 'h-12', half: 'h-[45vh]', @@ -364,12 +280,13 @@ export default function Panel({ onClearRoute }) { return (
+ {/* Drag handle */}
{sheetState !== 'collapsed' && ( -
+
{header} {content} - {mobilePlaceCardOverlay}
)}
diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 84ecb6b..40c4660 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -352,9 +352,6 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl if (placeLat == null || placeLon == null) return // Skip for dropped pins - they get reverse geocoded by MapView if (place?.source === 'map_click') return - // Don't reverse geocode if we already identified the entity from a label click - // The basemap label provides name, kind, wikidata - reverse geocode would return wrong entity - if (place?.source === 'basemap_label' && place?.raw?.kind) return const controller = new AbortController() fetchReverse(placeLat, placeLon).then((result) => { @@ -408,16 +405,6 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl osm_relation_id: data.osm_relation_id, extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags }, })) - // Set osm_type/osm_id from osm_relation_id to trigger Effect 3 (wiki summary fetch) - if (data?.osm_relation_id) { - const current = useStore.getState().selectedPlace - if (current && current.lat === placeLat && current.lon === placeLon) { - useStore.getState().setSelectedPlace({ - ...current, - raw: { ...current.raw, osm_type: 'R', osm_id: data.osm_relation_id } - }) - } - } if (data?.boundary) { const current = useStore.getState().selectedPlace if (current && current.lat === placeLat && current.lon === placeLon) { @@ -476,7 +463,6 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon) const handleDirections = () => { - console.log('[TRACE-DIRECTIONS] PlaceCard handleDirections, place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) // No toast - empty origin slot is the visual prompt startDirections(place) } diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx index 1215a08..2e47bd9 100644 --- a/src/components/SearchBar.jsx +++ b/src/components/SearchBar.jsx @@ -6,30 +6,6 @@ import { buildAddress } from '../utils/place' import { searchGeocode } from '../api' import { hasFeature } from '../config' - -/** Parse coordinate input like "42.35, -114.30" or "42.35 -114.30" */ -function parseCoordinates(input) { - if (!input) return null - const trimmed = input.trim() - - // Pattern: lat, lon or lat lon (with optional comma) - // Supports: "42.35, -114.30", "42.35 -114.30", "42.35,-114.30" - const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ - const match = trimmed.match(pattern) - - if (!match) return null - - const lat = parseFloat(match[1]) - const lon = parseFloat(match[2]) - - // Validate ranges - if (isNaN(lat) || isNaN(lon)) return null - if (lat < -90 || lat > 90) return null - if (lon < -180 || lon > 180) return null - - return { lat, lon } -} - /** Get category icon based on result type/source */ function CategoryIcon({ result }) { const type = result.type || '' @@ -95,25 +71,6 @@ const SearchBar = forwardRef(function SearchBar(_, ref) { return } - // Check for coordinate input first - const coords = parseCoordinates(q) - if (coords) { - const coordResult = { - lat: coords.lat, - lon: coords.lon, - name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), - address: "Coordinates", - type: "coordinates", - source: "coordinates", - match_code: null, - raw: {}, - } - setResults([coordResult]) - setAutocompleteOpen(true) - setSearchLoading(false) - return - } - // Prepend matching contacts let contactResults = [] if (hasFeature('has_contacts') && contacts.length > 0) { diff --git a/src/config.js b/src/config.js index 97af6a1..274edba 100644 --- a/src/config.js +++ b/src/config.js @@ -10,7 +10,7 @@ const FALLBACK_CONFIG = { profile: 'home', region_name: 'North America', tileset: { - url: '/tiles/planet/current.pmtiles', + url: '/tiles/na.pmtiles', bounds: [-168, 14, -52, 72], max_zoom: 15, attribution: 'Protomaps © OSM', @@ -30,6 +30,8 @@ const FALLBACK_CONFIG = { has_landclass: false, has_public_lands_layer: false, has_contours: true, + has_contours_test: true, + has_contours_test_10ft: false, has_address_book_write: false, has_usfs_trails: false, has_blm_trails: false, diff --git a/src/store.js b/src/store.js index 069be9f..bc36648 100644 --- a/src/store.js +++ b/src/store.js @@ -1,314 +1,155 @@ -import { create } from "zustand" -import { requestOffroute } from "./api" - -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 }), - - // ── 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 }), - - // ── Unified Route State ── - // routeStart = origin (source of truth) - // routeEnd = destination (source of truth) - // stops[] = ONLY intermediate waypoints (not origin/destination) - routeStart: null, // { lat, lon, name } - routeEnd: null, // { lat, lon, name } - stops: [], // Intermediate waypoints only: [{ id, lat, lon, name }, ...] - routeMode: "auto", // foot | mtb | atv | vehicle - boundaryMode: "strict", // strict | pragmatic | emergency - routeResult: null, // Response from /api/offroute - routeLoading: false, - routeError: null, - - // Map display callback - set by MapView - _updateRouteDisplay: null, - _clearRouteDisplay: null, - setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }), - - setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), - setRouteEnd: (place) => set({ routeEnd: place }), - setRouteResult: (result) => set({ routeResult: result, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, routeResult: null }), - - // Mode/boundary setters that trigger recalculation - setRouteMode: (mode) => { - set({ routeMode: mode }) - get().computeRoute() - }, - setBoundaryMode: (mode) => { - set({ boundaryMode: mode }) - get().computeRoute() - }, - - clearRoute: () => { - const { _clearRouteDisplay } = get() - if (_clearRouteDisplay) _clearRouteDisplay() - set({ - routeStart: null, - routeEnd: null, - stops: [], - routeResult: null, - routeError: null, - }) - }, - - // ── INTERMEDIATE STOPS MANAGEMENT ── - // stops[] contains ONLY intermediate waypoints, not origin/destination - - // Add intermediate stop - can be called with or without place - // With place: creates pre-filled stop (from radial menu) - // Without place: creates empty placeholder (from Add Stop button) - addIntermediateStop: (place) => { - const { stops } = get() - if (stops.length >= 8) return false // Max 8 intermediate stops - const newStop = { - id: crypto.randomUUID(), - lat: place?.lat ?? null, - lon: place?.lon ?? null, - name: place?.name ?? "", - } - set({ stops: [...stops, newStop] }) - return true - }, - - updateStop: (id, place) => { - const { stops } = get() - const newStops = stops.map((s) => - s.id === id ? { ...s, lat: place.lat, lon: place.lon, name: place.name } : s - ) - set({ stops: newStops }) - // Trigger route recalculation if all waypoints have coordinates - get().computeRoute() - }, - - removeStop: (id) => { - const { stops } = get() - const newStops = stops.filter((s) => s.id !== id) - set({ stops: newStops }) - // Recalculate route without this stop - get().computeRoute() - }, - - setStops: (stops) => set({ stops }), - - // ── UNIFIED ROUTING TRIGGER ── - // Handles both 2-point and multi-point routing - computeRoute: async () => { - const { routeStart, routeEnd, stops, routeMode, boundaryMode, _updateRouteDisplay } = get() - - // Need both endpoints to route - if (!routeStart || !routeEnd) return - - // Filter out incomplete stops (no coordinates yet) - const validStops = stops.filter((s) => s.lat != null && s.lon != null) - - // Build full waypoint list: [origin, ...intermediates, destination] - const waypoints = [ - routeStart, - ...validStops, - routeEnd, - ] - - console.log("[TRACE-ROUTE] computeRoute with waypoints:", waypoints.length, waypoints.map(w => w.name)) - - set({ routeLoading: true, routeError: null }) - - try { - if (waypoints.length === 2) { - // Simple 2-point routing - const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) - if (data.status === "ok" && data.route) { - set({ routeResult: data, routeError: null }) - if (_updateRouteDisplay) _updateRouteDisplay(data.route) - } else { - set({ routeError: data.message || data.error || "No route found", routeResult: null }) - } - } else { - // Multi-point routing: chain sequential 2-point routes and merge - const segments = [] - let totalDistanceKm = 0 - let totalEffortMinutes = 0 - let allFeatures = [] - - for (let i = 0; i < waypoints.length - 1; i++) { - const from = waypoints[i] - const to = waypoints[i + 1] - const segmentData = await requestOffroute(from, to, routeMode, boundaryMode) - - if (segmentData.status !== "ok" || !segmentData.route) { - throw new Error("No route found between " + (from.name || "waypoint") + " and " + (to.name || "waypoint")) - } - - segments.push(segmentData) - - // Accumulate totals - if (segmentData.summary) { - totalDistanceKm += segmentData.summary.total_distance_km || 0 - totalEffortMinutes += segmentData.summary.total_effort_minutes || 0 - } - - // Collect features - if (segmentData.route?.features) { - allFeatures.push(...segmentData.route.features) - } - } - - // Build merged result - const mergedResult = { - status: "ok", - summary: { - total_distance_km: totalDistanceKm, - total_effort_minutes: totalEffortMinutes, - waypoint_count: waypoints.length, - }, - route: { - type: "FeatureCollection", - features: allFeatures, - }, - } - - set({ routeResult: mergedResult, routeError: null }) - if (_updateRouteDisplay) _updateRouteDisplay(mergedResult.route) - } - } catch (e) { - set({ routeError: e.message, routeResult: null }) - } finally { - set({ routeLoading: false }) - } - }, - - // ── Legacy compatibility ── - gpsOrigin: true, - pendingDestination: null, - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - // Master startDirections - enters directions mode with destination pre-filled - startDirections: (place) => { - console.log("[TRACE-STORE] startDirections received place:", { lat: place?.lat, lon: place?.lon, name: place?.name }) - const { geoPermission, userLocation, clearRoute } = get() - clearRoute() - - const destination = { - lat: place.lat, - lon: place.lon, - name: place.name, - source: place.source, - matchCode: place.matchCode, - } - - let origin = null - if (geoPermission === "granted" && userLocation) { - origin = { - lat: userLocation.lat, - lon: userLocation.lon, - name: "Your location", - source: "gps", - } - } - - set({ - routeEnd: destination, - routeStart: origin, - directionsMode: true, - activeDirectionsField: origin ? null : "origin", - selectedPlace: null, - }) - }, - - // ── Place detail ── - selectedPlace: null, - clickMarker: null, - - setSelectedPlace: (place) => set({ selectedPlace: place }), - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - - // ── UI state ── - sheetState: "half", - panelOpen: true, - autocompleteOpen: false, - directionsMode: false, - activeDirectionsField: null, - pickingRouteField: null, - theme: "dark", - themeOverride: null, - viewMode: (typeof localStorage !== "undefined" && localStorage.getItem("navi-view-mode")) || "map", - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem("navi-view-mode", mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? "origin" : null }), - setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), - setPickingRouteField: (field) => set({ pickingRouteField: field }), - clearPickingRouteField: () => set({ pickingRouteField: null }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem("navi-theme-override", override) - } else { - localStorage.removeItem("navi-theme-override") - } - }, - - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: "routes", - editingContact: null, - pickingLocationFor: null, - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.routeResult - const hasRoutePoints = !!s.routeStart || !!s.routeEnd - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasRoutePoints) return "ROUTING" - return "IDLE" - }) -} +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 }), + + // Boundary rendering function - set by MapView, called by PlaceCard + updateBoundary: null, + setUpdateBoundary: (fn) => set({ updateBoundary: fn }), + 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) + viewMode: 'map', // 'map' | 'satellite' | 'hybrid' + + setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem('navi-view-mode', mode) + }, + 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') + } + }, + // ── Auth state ── + auth: { authenticated: false, username: null, loaded: false }, + setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), + + // ── Contacts ── + contacts: [], + contactsLoaded: false, + activeTab: 'routes', // 'routes' | 'contacts' + editingContact: null, // null=closed, {}=new, {id:N}=edit + pickingLocationFor: null, // form data while user picks location on map + + setContacts: (c) => set({ contacts: c, contactsLoaded: true }), + setActiveTab: (tab) => set({ activeTab: tab }), + setEditingContact: (c) => set({ editingContact: c }), + clearEditingContact: () => set({ editingContact: null }), + setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), + clearPickingLocationFor: () => set({ pickingLocationFor: 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" + }) +}