Compare commits

...

19 commits

Author SHA1 Message Date
d4e3b68a13
Merge PR #1: public-lands: filter "Unknown <agency>" PAD-US label artifacts
public-lands: filter "Unknown <agency>" PAD-US label artifacts
2026-05-20 11:24:32 -06:00
41ea028d48 public-lands: filter "Unknown <agency>" PAD-US label artifacts
PAD-US v4.0 ships many small sub-polygons whose unit_nm is literally
"Unknown <state agency>" (e.g. "Unknown Idaho Department of Lands"). The
PMTiles build maps unit_nm -> feature name, so the label layer rendered
these spurious labels next to/over the legitimate umbrella label.

Filter them out at the PUBLIC_LANDS_LABEL (symbol) layer only via a name
prefix test. Fill and line layers are untouched — the polygon geometry
still renders, just without the bogus label.

Evidence: /api/landclass at (42.619853, -114.462106) returns a 12-acre
"Unknown Idaho Department of Lands" overlapping the 1.98M-acre
"Idaho Department of Lands" umbrella.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:03:35 +00:00
36171123f1 Merge feature/offroute-ui: directions panel, multi-stop routing, drag reorder, radial menu integration 2026-05-09 15:45:32 +00:00
bc453ff375 feat: drag-and-drop stop reordering and fix radial add-stop
- addIntermediateStop() now accepts optional place parameter
- Radial menu add-stop wedge uses addIntermediateStop with coordinates
- Replaced up/down chevron buttons with @dnd-kit drag-and-drop
- All rows (origin, stops, destination) can be reordered by dragging
- GripVertical drag handle on left of each row
- On drag end: first item → origin, last → destination, middle → stops

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-09 15:40:12 +00:00
0942b10b27 fix: swap button layout and add stop reorder buttons
- Swap button now inline on origin row (not absolute positioned)
- Swap button no longer overlaps intermediate stop controls
- Added up/down chevron buttons on each intermediate stop row
- Reordering stops triggers route recalculation
- Destination row has spacer to align with origin row

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-09 15:14:08 +00:00
79413014a5 fix: separate stops[] from routeStart/routeEnd for multi-stop routing
- stops[] now contains ONLY intermediate waypoints
- routeStart and routeEnd are separate sources of truth
- addIntermediateStop() adds empty placeholder to stops[]
- updateStop() and removeStop() manage intermediate waypoints
- computeRoute() chains sequential 2-point routes for multi-stop
- DirectionsPanel renders: origin -> stops.map() -> destination
- Each intermediate stop has remove button (Trash2 icon)

Test scenarios verified:
- Origin + destination routes normally (no stops involved)
- Add Stop creates empty input between origin and destination
- Setting intermediate location triggers route recalculation
- Multiple stops can be added sequentially
- Removing a stop recalculates route without it
- Clear all returns to empty state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-09 14:59:31 +00:00
2345334bc7 feat: wire up radial menu directions and multi-stop add button
- Radial menu "From here" now sets origin and opens directions panel
- Radial menu "To here" now sets destination, opens directions panel,
  and uses GPS as origin fallback when available
- DirectionsPanel "Add stop" button now creates intermediate stops
- Stops array initialized from routeStart/routeEnd when adding stops

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-09 08:23:38 +00:00
816ea8dd1f feat: wilderness maneuvers, pick-from-map, distance formatting, place card panel
- Wilderness maneuvers render with compass arrows and cardinal directions
- Network maneuvers prefixed with transport mode (Drive/Walk/Ride)
- Distances under 1 mile show feet with commas
- Pick-from-map mode replaces auto-fill-on-focus (crosshair + toast)
- ESC cancels pick mode
- Place card slides out right during active routing
- Removed debug toasts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-09 06:09:14 +00:00
19a96cba5e feat: improve directions panel with route legend and place card below
- Add route legend showing wilderness (dashed orange) vs road (solid blue)
- Show place card below directions panel when clicking map during routing
- Clean up error messages to be user-friendly (no offroute text)
- Legend only appears when route has wilderness segments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-09 03:37:05 +00:00
a6942b35ea fix: preserve click coordinates for wilderness routing
When clicking on a labeled feature (e.g., "Monument Peak"), the code
was using the feature's canonical coordinates instead of the actual
click coordinates. This caused wilderness clicks to snap to named
places that might be on roads, bypassing wilderness routing.

Fix: Always use click coordinates (e.lngLat) for routing purposes.
Feature coordinates are only used for display/detail fetching.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-08 23:08:38 +00:00
7523ddd0a2 feat: add directions panel with editable origin/destination inputs
New UX for Get Directions:
- DirectionsPanel component with two stacked input fields
- LocationInput component with autocomplete, coordinate parsing
- Swap button to flip origin/destination
- Travel mode selector (Drive default, Foot, MTB, ATV, 4x4)
- Boundary selector (only visible for non-Drive modes)
- Map click fills active input field with crosshair cursor
- Auto-route when both endpoints are filled
- X button closes directions and returns to search view

Store changes:
- directionsMode state for panel switching
- activeDirectionsField for map click targeting
- startDirections now enters directions mode with destination pre-filled

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-08 22:44:45 +00:00
09d68adf09 feat: unified routing with Drive mode default and Add stop wedge
- Add Drive (auto) as default route mode, first in travel modes list
- Hide boundary mode selector when Drive mode is active
- Restore Add stop radial menu wedge with stops system integration
- Unify routing through single computeRoute() function in store
- Add coordinate parsing to SearchBar for direct lat/lon input
- Bridge stops system with routeStart/routeEnd for seamless UX
- Support 3+ stops with Valhalla optimization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-08 21:59:10 +00:00
d6aa125215 feat: unified routing UI with wilderness + network segments
- Single routing system (removed duplicate Valhalla-only flow)
- Unified radial menu: From here, To here, Clear, Save, Measure
- Removed "Offroute" section from panel (single directions display)
- Better error messages without technical "Offroute" prefix
- ManeuverList shows wilderness + network breakdown
- PlaceCard integration for previews

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-08 19:03:44 +00:00
95dd4438fe docs: add traffic-intelligent routing and Idaho 511 planned features
- Append section 11 (On-Network Traffic Intelligence) to OFFROUTE-ARCHITECTURE.md
- Create navi-feature-ideas.md with planned features:
  - Traffic-aware Valhalla routing via TomTom tiles
  - Idaho 511 incident feed integration
  - ADS-B/AIS tracking
  - TAK Server + EUD integration
  - Native iOS app

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-08 07:02:00 +00:00
400dcbb8f2 docs: add OFFROUTE effort-based routing architecture
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-07 23:22:48 +00:00
0b1854bd5f cleanup: remove dead contour-test code and stale fallback config
- Remove contours-test.pmtiles and contours-test-10ft.pmtiles references
  (files deleted, feature flags disabled)
- Update fallback tileset URL from na.pmtiles to planet/current.pmtiles
- Remove has_contours_test and has_contours_test_10ft from fallback config
- Delete 46 .bak* files from src/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-07 21:56:14 +00:00
5df01b1428 fix: use Noto Sans Medium for contour labels (Bold not in protomaps assets) 2026-05-07 14:18:18 +00:00
400c485833 fix: contour overlay with pmtiles fork, absolute URL, extended zoom range
- Switch to @acalcutt/maplibre-contour-pmtiles for PMTiles support
- Use absolute URL for DemSource so Web Worker can resolve path
- Extend contour thresholds from z3-z15 for full zoom coverage
- Improve line styling with zoom-dependent width
- Improve label styling with bold font and better halo

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 03:58:46 +00:00
f3ec18bdf5 wip: contour overlay — broken, needs review 2026-05-07 02:54:25 +00:00
15 changed files with 2531 additions and 1011 deletions

View file

@ -0,0 +1,427 @@
# 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 24 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 = ~1316m 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, 15 seconds on 24M 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 ~2040M 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.01.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 24M 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 24.
- [ ] 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 (24) 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 24 (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 (510 min interval), stores active incidents in `navi.db`, expires automatically when cleared
- Affects both standalone Valhalla routing and offroute segments 24
- **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

View file

@ -0,0 +1,92 @@
# 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 24 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 510 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

7
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "navi", "name": "navi",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@acalcutt/maplibre-contour-pmtiles": "^0.1.2",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@ -37,6 +38,12 @@
"vite": "^8.0.9" "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": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",

View file

@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@acalcutt/maplibre-contour-pmtiles": "^0.1.2",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",

View file

@ -1,8 +1,7 @@
import { useEffect, useRef, useCallback } from 'react' import { useEffect, useRef, useCallback } from 'react'
import { useStore } from './store' import { useStore } from './store'
import { useTheme } from './hooks/useTheme' import { useTheme } from './hooks/useTheme'
import { requestRoute, fetchAuthState } from './api' import { fetchAuthState } from './api'
import { decodePolyline } from './utils/decode'
import MapView from './components/MapView' import MapView from './components/MapView'
import Panel from './components/Panel' import Panel from './components/Panel'
@ -12,20 +11,10 @@ import LocateButton from './components/LocateButton'
export default function App() { export default function App() {
const mapViewRef = useRef(null) const mapViewRef = useRef(null)
const routeDebounceRef = useRef(null)
// Initialize theme system // Initialize theme system
useTheme() 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) const setAuth = useStore((s) => s.setAuth)
// Initialize auth state on app load (single fetch, no polling) // Initialize auth state on app load (single fetch, no polling)
@ -33,67 +22,15 @@ export default function App() {
fetchAuthState().then(setAuth) fetchAuthState().then(setAuth)
}, [setAuth]) }, [setAuth])
// Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms) // Handle clear route from panel
useEffect(() => { const handleClearRoute = useCallback(() => {
if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) mapViewRef.current?.clearRoute?.()
}, [])
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 ( return (
<div className="relative w-screen h-screen overflow-hidden" style={{ background: 'var(--bg-base)' }}> <div className="relative w-screen h-screen overflow-hidden" style={{ background: 'var(--bg-base)' }}>
<MapView ref={mapViewRef} /> <MapView ref={mapViewRef} />
<Panel onManeuverClick={handleManeuverClick} /> <Panel onClearRoute={handleClearRoute} />
<ContactModal /> <ContactModal />

View file

@ -321,3 +321,70 @@ export async function fetchAuthState() {
return { authenticated: false, username: null } 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<object>} 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<object|null>} 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
}
}

View file

@ -0,0 +1,417 @@
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 (
<div ref={setNodeRef} style={style} className="flex items-center gap-1">
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="p-1 rounded cursor-grab active:cursor-grabbing hover:bg-[var(--bg-overlay)] transition-colors shrink-0 touch-none"
title="Drag to reorder"
>
<GripVertical size={14} style={{ color: "var(--text-tertiary)" }} />
</button>
{children}
</div>
)
}
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 (
<div className="flex flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
Directions
</span>
<button
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors"
title="Close directions"
>
<X size={18} style={{ color: "var(--text-tertiary)" }} />
</button>
</div>
{/* Drag-and-drop location list */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2">
{unifiedList.map((item, idx) => (
<SortableRow key={item.id} id={item.id}>
<div className="flex-1">
{item.type === "origin" && (
<LocationInput
value={routeStart}
onChange={setRouteStart}
placeholder={geoPermission === "granted" ? "Your location" : "Choose starting point"}
icon="origin"
fieldId="origin"
autoFocus={!routeStart}
/>
)}
{item.type === "destination" && (
<LocationInput
value={routeEnd}
onChange={setRouteEnd}
placeholder="Choose destination"
icon="destination"
fieldId="destination"
autoFocus={routeStart && !routeEnd}
/>
)}
{item.type === "stop" && (
<LocationInput
value={item.data.lat != null ? { lat: item.data.lat, lon: item.data.lon, name: item.data.name } : null}
onChange={(place) => {
if (place) {
updateStop(item.id, place)
}
}}
placeholder={`Stop ${idx}`}
icon="stop"
fieldId={`stop-${item.id}`}
autoFocus={item.data.lat == null}
/>
)}
</div>
{/* Remove button for intermediate stops only */}
{item.type === "stop" && (
<button
onClick={() => removeStop(item.id)}
className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors shrink-0"
title="Remove stop"
>
<Trash2 size={14} style={{ color: "var(--text-tertiary)" }} />
</button>
)}
{/* Spacer for origin/destination to align with stops that have remove button */}
{item.type !== "stop" && (
<div className="w-[30px] shrink-0" />
)}
</SortableRow>
))}
{/* Add stop button - only show when route exists */}
{routeStart && routeEnd && stops.length < 8 && (
<button
onClick={handleAddStop}
className="flex items-center justify-center gap-1.5 py-1.5 text-xs rounded-lg transition-colors ml-6"
style={{
background: "var(--bg-overlay)",
color: "var(--text-secondary)",
border: "1px dashed var(--border)",
}}
>
<Plus size={14} />
<span>Add stop</span>
</button>
)}
</div>
</SortableContext>
</DndContext>
{/* Travel mode selector */}
<div className="flex gap-1">
{TRAVEL_MODES.map((m) => {
const active = routeMode === m.id
return (
<button
key={m.id}
onClick={() => setRouteMode(m.id)}
className="flex-1 flex items-center justify-center gap-1 py-2 text-xs rounded-lg transition-colors"
style={{
background: active ? "var(--accent-muted)" : "var(--bg-overlay)",
color: active ? "var(--accent)" : "var(--text-tertiary)",
}}
title={m.label}
>
<m.Icon size={16} />
<span className="hidden sm:inline">{m.label}</span>
</button>
)
})}
</div>
{/* Boundary mode selector (only for non-auto modes) */}
{routeMode !== "auto" && (
<div className="flex gap-1">
{BOUNDARY_MODES.map((m) => {
const active = boundaryMode === m.id
return (
<button
key={m.id}
onClick={() => setBoundaryMode(m.id)}
className="flex-1 flex items-center justify-center gap-1 py-1.5 text-xs rounded-lg transition-colors"
style={{
background: active ? "var(--accent-muted)" : "var(--bg-overlay)",
color: active ? "var(--accent)" : "var(--text-tertiary)",
}}
title={m.title}
>
<m.Icon size={14} />
<span className="hidden sm:inline">{m.label}</span>
</button>
)
})}
</div>
)}
{/* Loading indicator */}
{routeLoading && (
<div className="flex items-center justify-center gap-2 py-3">
<div
className="w-5 h-5 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--accent)", borderTopColor: "transparent" }}
/>
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
Finding route...
</span>
</div>
)}
{/* Error message - friendly text, no "offroute" */}
{routeError && (
<div
className="px-3 py-2 rounded-lg text-sm"
style={{
background: "var(--error-bg, rgba(239, 68, 68, 0.1))",
color: "var(--error, #ef4444)",
}}
>
{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}
</div>
)}
{/* Route legend - only shown when route has wilderness segment */}
{routeResult && hasWilderness && !routeLoading && (
<div
className="flex items-center gap-4 px-3 py-2 rounded-lg text-xs"
style={{ background: "var(--bg-overlay)" }}
>
<div className="flex items-center gap-1.5">
<svg width="24" height="2" style={{ overflow: "visible" }}>
<line
x1="0" y1="1" x2="24" y2="1"
stroke="#f97316"
strokeWidth="3"
strokeDasharray="4,3"
/>
</svg>
<span style={{ color: "var(--text-secondary)" }}>Wilderness (on foot)</span>
</div>
<div className="flex items-center gap-1.5">
<svg width="24" height="2" style={{ overflow: "visible" }}>
<line
x1="0" y1="1" x2="24" y2="1"
stroke="#3b82f6"
strokeWidth="3"
/>
</svg>
<span style={{ color: "var(--text-secondary)" }}>Road/Trail</span>
</div>
</div>
)}
{/* Route summary and maneuvers */}
{routeResult && !routeLoading && (
<div className="border-t pt-3" style={{ borderColor: "var(--border)" }}>
<ManeuverList />
</div>
)}
{/* Hint when waiting for input */}
{!routeStart && !routeEnd && !routeLoading && (
<div className="text-center py-4">
<p className="text-xs" style={{ color: "var(--text-tertiary)" }}>
Enter addresses, paste coordinates, or click the map
</p>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,321 @@
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 <User size={size} />
if (source === "nickname") return <Star size={size} />
if (type === "coordinates") return <Crosshair size={size} />
if (type === "locality" || type === "city") return <Building2 size={size} />
const osmVal = result.raw?.osm_value || ""
if (osmVal.includes("cafe") || osmVal.includes("coffee")) return <Coffee size={size} />
if (osmVal.includes("fuel") || osmVal.includes("gas")) return <Fuel size={size} />
if (osmVal.includes("shop") || osmVal.includes("supermarket")) return <ShoppingBag size={size} />
if (osmVal.includes("hotel") || osmVal.includes("motel")) return <Hotel size={size} />
return <MapPin size={size} />
}
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 (
<div className="relative">
<div
className="flex items-center gap-2 px-3 py-2 rounded-lg transition-all"
style={{
background: "var(--bg-overlay)",
border: isActive ? "1px solid var(--accent)" : "1px solid var(--border)",
}}
>
{icon === "origin" ? (
<Navigation2 size={16} style={{ color: iconColor, transform: "rotate(45deg)" }} />
) : (
<MapPin size={16} style={{ color: iconColor }} />
)}
<input
ref={inputRef}
type="text"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
autoFocus={autoFocus}
className="flex-1 bg-transparent text-sm outline-none"
style={{ color: "var(--text-primary)" }}
/>
{/* Pick from map button */}
<button
onClick={handlePickFromMap}
className="p-1 rounded hover:bg-[var(--bg-overlay)] transition-colors"
style={{ color: isPicking ? "var(--accent)" : "var(--text-tertiary)" }}
title="Pick location from map"
>
<Target size={14} />
</button>
{loading ? (
<div
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--accent)", borderTopColor: "transparent" }}
/>
) : query ? (
<button onClick={handleClear} className="p-0.5" style={{ color: "var(--text-tertiary)" }}>
<X size={14} />
</button>
) : null}
</div>
{open && results.length > 0 && (
<ul
className="absolute z-50 mt-1 w-full rounded-lg overflow-hidden max-h-48 overflow-y-auto"
style={{
background: "var(--bg-overlay)",
border: "1px solid var(--border)",
boxShadow: "var(--shadow-lg)",
}}
>
{results.map((r, i) => {
const isPoi = r.type === "poi" && r.raw?.name
const isContact = r._isContact
const primary = isContact ? r.name : isPoi ? r.raw.name : r.name
const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null
return (
<li
key={`${r.lat}-${r.lon}-${i}`}
className="px-3 py-2 cursor-pointer text-sm"
style={{
background: i === activeIndex ? "var(--accent-muted)" : "transparent",
borderBottom: i < results.length - 1 ? "1px solid var(--border-subtle)" : "none",
}}
onClick={() => selectResult(r)}
onMouseEnter={() => setActiveIndex(i)}
>
<div className="flex items-center gap-2">
<span style={{ color: isContact ? "var(--accent)" : "var(--text-tertiary)" }}>
<CategoryIcon result={r} />
</span>
<span className="truncate flex-1" style={{ color: "var(--text-primary)" }}>
{primary}
</span>
</div>
{secondary && (
<div className="text-[11px] mt-0.5 ml-6 truncate" style={{ color: "var(--text-tertiary)" }}>
{secondary}
</div>
)}
</li>
)
})}
</ul>
)}
</div>
)
}

View file

@ -1,23 +1,87 @@
import { import {
MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft,
MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw,
GitMerge, CornerRightDown, CornerRightUp, Navigation GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle,
Compass, ArrowUp, ArrowUpRight, ArrowRight, ArrowDownRight, ArrowDown,
ArrowDownLeft, ArrowLeft, ArrowUpLeft, MapPin
} from 'lucide-react' } from 'lucide-react'
import { useStore } from '../store' import { useStore } from '../store'
function formatTime(seconds) { /**
if (seconds < 60) return `${Math.round(seconds)}s` * Format distance with commas for feet, one decimal for miles.
if (seconds < 3600) return `${Math.round(seconds / 60)} min` * Under 1 mile: "2,640 ft"
const h = Math.floor(seconds / 3600) * 1+ miles: "1.3 mi"
const m = Math.round((seconds % 3600) / 60) */
return m > 0 ? `${h}h ${m}m` : `${h}h` 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 formatDist(miles) { function formatTimeMin(minutes) {
if (miles < 0.1) return `${Math.round(miles * 5280)} ft` if (minutes < 60) return Math.round(minutes) + ' min'
return `${miles.toFixed(1)} mi` 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 (
<ArrowUp
size={size}
strokeWidth={2}
style={{ transform: `rotate(${bearing}deg)` }}
/>
)
}
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 <Icon {...props} />
}
// Wilderness maneuver icon
function WildernessIcon({ type, cardinal, bearing, size = 16 }) {
if (type === 'arrival') {
return <MapPin size={size} strokeWidth={1.5} />
}
return <CompassIcon cardinal={cardinal} bearing={bearing} size={size} />
}
// Network maneuver icon (Valhalla types)
function ManeuverIcon({ type }) { function ManeuverIcon({ type }) {
const size = 16 const size = 16
const props = { size, strokeWidth: 1.5 } const props = { size, strokeWidth: 1.5 }
@ -40,10 +104,55 @@ function ManeuverIcon({ type }) {
} }
} }
export default function ManeuverList({ onManeuverClick }) { /**
const route = useStore((s) => s.route) * 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 routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError) const routeError = useStore((s) => s.routeError)
const routeMode = useStore((s) => s.routeMode)
if (routeLoading) { if (routeLoading) {
return ( return (
@ -74,67 +183,161 @@ export default function ManeuverList({ onManeuverClick }) {
) )
} }
if (!route || !route.legs) return null if (!routeResult?.summary) return null
const totalTime = route.summary?.time || 0 const summary = routeResult.summary
const totalDist = route.summary?.length || 0 const features = routeResult.route?.features || []
const networkMode = summary.network_mode || routeMode || 'foot'
const allManeuvers = [] // Extract maneuvers from each segment type
let timeRemaining = totalTime 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'
)
for (let legIdx = 0; legIdx < route.legs.length; legIdx++) { const wildernessStartManeuvers = wildernessStartFeature?.properties?.maneuvers || []
const leg = route.legs[legIdx] const networkManeuvers = networkFeature?.properties?.maneuvers || []
for (const man of leg.maneuvers || []) { const wildernessEndManeuvers = wildernessEndFeature?.properties?.maneuvers || []
allManeuvers.push({ ...man, _legIndex: legIdx, timeRemaining })
timeRemaining -= man.time || 0 const hasManeuvers = wildernessStartManeuvers.length > 0 ||
} networkManeuvers.length > 0 ||
} wildernessEndManeuvers.length > 0
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{/* Route summary */} {/* Total summary */}
<div <div
className="flex items-center justify-between px-3 py-2 rounded mb-2" className="flex items-center justify-between px-3 py-2 rounded mb-2"
style={{ background: 'var(--bg-overlay)', border: '1px solid var(--border-subtle)' }} style={{ background: 'var(--bg-overlay)', border: '1px solid var(--border-subtle)' }}
> >
<span className="font-mono text-sm font-medium" style={{ color: 'var(--text-primary)' }}> <span className="font-mono text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{formatDist(totalDist)} {formatDistance(null, summary.total_distance_km)}
</span> </span>
<span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}> <span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}>
{formatTime(totalTime)} {formatTimeMin(summary.total_effort_minutes)}
</span> </span>
</div> </div>
{/* Maneuver steps */} {/* Segment breakdown */}
<div className="flex flex-col max-h-[50vh] overflow-y-auto"> <div className="flex flex-col gap-1 px-2 mb-2">
{allManeuvers.map((man, i) => ( {summary.wilderness_distance_km > 0 && (
<button <div className="flex items-center gap-2 text-sm">
key={i} <Mountain size={14} style={{ color: '#f97316' }} />
onClick={() => { <span style={{ color: 'var(--text-secondary)' }}>Wilderness</span>
if (man.begin_shape_index != null && onManeuverClick) onManeuverClick(man) <span className="ml-auto font-mono text-xs" style={{ color: 'var(--text-tertiary)' }}>
}} {formatDistance(null, summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)}
className="flex items-start gap-2 px-2 py-2 text-left rounded transition-colors duration-75" </span>
style={{ '--hover-bg': 'var(--bg-overlay)' }} </div>
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-overlay)'} )}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} {summary.network_distance_km > 0 && (
> <div className="flex items-center gap-2 text-sm">
<Map size={14} style={{ color: '#3b82f6' }} />
<span style={{ color: 'var(--text-secondary)' }}>Road/Trail</span>
<span className="ml-auto font-mono text-xs" style={{ color: 'var(--text-tertiary)' }}>
{formatDistance(null, summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)}
</span>
</div>
)}
</div>
{/* Warnings */}
{(summary.barrier_crossings > 0 || summary.mvum_closed_crossings > 0) && (
<div className="px-2 mb-2 flex flex-col gap-1">
{summary.barrier_crossings > 0 && (
<div className="flex items-center gap-1 text-xs" style={{ color: 'var(--status-warning)' }}>
<AlertTriangle size={12} />
<span>{summary.barrier_crossings} barrier crossing{summary.barrier_crossings > 1 ? 's' : ''}</span>
</div>
)}
{summary.mvum_closed_crossings > 0 && (
<div className="flex items-center gap-1 text-xs" style={{ color: 'var(--status-warning)' }}>
<AlertTriangle size={12} />
<span>{summary.mvum_closed_crossings} MVUM closure{summary.mvum_closed_crossings > 1 ? 's' : ''}</span>
</div>
)}
</div>
)}
{/* Turn-by-turn directions */}
{hasManeuvers && (
<div className="flex flex-col max-h-[40vh] overflow-y-auto">
<div className="text-xs px-2 mb-1" style={{ color: 'var(--text-tertiary)' }}>Directions</div>
{/* Wilderness start maneuvers */}
{wildernessStartManeuvers.length > 0 && (
<>
<div className="text-[10px] uppercase tracking-wide px-2 py-1 font-medium"
style={{ color: '#f97316', background: 'rgba(249,115,22,0.1)' }}>
Wilderness On Foot
</div>
{wildernessStartManeuvers.map((man, i) => (
<div key={`ws-${i}`} className="flex items-start gap-2 px-2 py-2 text-left">
<span className="w-5 shrink-0 mt-0.5" style={{ color: '#f97316' }}>
<WildernessIcon type={man.type} cardinal={man.cardinal} bearing={man.bearing} />
</span>
<div className="flex-1 min-w-0">
<p className="text-sm leading-tight" style={{ color: 'var(--text-primary)' }}>
{man.instruction}
</p>
</div>
</div>
))}
</>
)}
{/* Network maneuvers */}
{networkManeuvers.length > 0 && (
<>
{wildernessStartManeuvers.length > 0 && (
<div className="text-[10px] uppercase tracking-wide px-2 py-1 font-medium"
style={{ color: '#3b82f6', background: 'rgba(59,130,246,0.1)' }}>
Road/Trail
</div>
)}
{networkManeuvers.map((man, i) => (
<div key={`net-${i}`} className="flex items-start gap-2 px-2 py-2 text-left">
<span className="w-5 shrink-0 mt-0.5" style={{ color: 'var(--accent)' }}> <span className="w-5 shrink-0 mt-0.5" style={{ color: 'var(--accent)' }}>
<ManeuverIcon type={man.type} /> <ManeuverIcon type={man.type} />
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm leading-tight" style={{ color: 'var(--text-primary)' }}> <p className="text-sm leading-tight" style={{ color: 'var(--text-primary)' }}>
{man.instruction || man.verbal_pre_transition_instruction || 'Continue'} {formatNetworkInstruction(man.instruction, networkMode)}
</p> </p>
<p className="font-mono text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}> <p className="font-mono text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{formatDist(man.length || 0)} {formatDistance(null, man.distance_km)}
{man.timeRemaining > 0 && (
<span className="ml-2">{formatTime(man.timeRemaining)} left</span>
)}
</p> </p>
</div> </div>
</button>
))}
</div> </div>
))}
</>
)}
{/* Wilderness end maneuvers */}
{wildernessEndManeuvers.length > 0 && (
<>
<div className="text-[10px] uppercase tracking-wide px-2 py-1 font-medium"
style={{ color: '#f97316', background: 'rgba(249,115,22,0.1)' }}>
Wilderness On Foot
</div>
{wildernessEndManeuvers.map((man, i) => (
<div key={`we-${i}`} className="flex items-start gap-2 px-2 py-2 text-left">
<span className="w-5 shrink-0 mt-0.5" style={{ color: '#f97316' }}>
<WildernessIcon type={man.type} cardinal={man.cardinal} bearing={man.bearing} />
</span>
<div className="flex-1 min-w-0">
<p className="text-sm leading-tight" style={{ color: 'var(--text-primary)' }}>
{man.instruction}
</p>
</div>
</div>
))}
</>
)}
</div>
)}
</div> </div>
) )
} }

File diff suppressed because it is too large Load diff

View file

@ -1,53 +1,60 @@
import { useRef, useCallback, useEffect, useState } from 'react' import { useRef, useCallback, useEffect, useState } from 'react'
import { LogIn, LogOut } from 'lucide-react' import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin, Target } from 'lucide-react'
import ThemePicker from './ThemePicker' import ThemePicker from './ThemePicker'
import { useStore, usePanelState } from '../store' import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config' import { hasFeature } from '../config'
import SearchBar from './SearchBar' import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList' import ManeuverList from './ManeuverList'
import ContactList from './ContactList' import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard' import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api' import DirectionsPanel from './DirectionsPanel'
import PlaceDetail from './PlaceDetail'
export default function Panel({ onManeuverClick }) { 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 }) {
const selectedPlace = useStore((s) => s.selectedPlace) const selectedPlace = useStore((s) => s.selectedPlace)
const pendingDestination = useStore((s) => s.pendingDestination)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const clearPendingDestination = useStore((s) => s.clearPendingDestination) const routeStart = useStore((s) => s.routeStart)
const stops = useStore((s) => s.stops) const routeEnd = useStore((s) => s.routeEnd)
const mode = useStore((s) => s.mode) const routeMode = useStore((s) => s.routeMode)
const route = useStore((s) => s.route) const boundaryMode = useStore((s) => s.boundaryMode)
const routeResult = useStore((s) => s.routeResult)
const routeLoading = useStore((s) => s.routeLoading) const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError) const setRouteMode = useStore((s) => s.setRouteMode)
const setStops = useStore((s) => s.setStops) const setBoundaryMode = useStore((s) => s.setBoundaryMode)
const setRoute = useStore((s) => s.setRoute) const pickingRouteField = useStore((s) => s.pickingRouteField)
const setRouteError = useStore((s) => s.setRouteError) const setPickingRouteField = useStore((s) => s.setPickingRouteField)
const setRouteLoading = useStore((s) => s.setRouteLoading) const clearRoute = useStore((s) => s.clearRoute)
const sheetState = useStore((s) => s.sheetState) const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState) 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 activeTab = useStore((s) => s.activeTab)
const auth = useStore((s) => s.auth) const auth = useStore((s) => s.auth)
const setActiveTab = useStore((s) => s.setActiveTab) const setActiveTab = useStore((s) => s.setActiveTab)
const directionsMode = useStore((s) => s.directionsMode)
const setDirectionsMode = useStore((s) => s.setDirectionsMode)
const panelState = usePanelState() const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null) const sheetRef = useRef(null)
const dragStartY = useRef(0) const dragStartY = useRef(0)
const dragStartState = useRef('half') const dragStartState = useRef('half')
// Show contacts tab only if feature enabled AND user is authenticated
const showContacts = hasFeature('has_contacts') && auth.authenticated const showContacts = hasFeature('has_contacts') && auth.authenticated
// Responsive detection
useEffect(() => { useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768) const check = () => setIsMobile(window.innerWidth < 768)
check() check()
@ -55,61 +62,9 @@ export default function Panel({ onManeuverClick }) {
return () => window.removeEventListener('resize', check) return () => window.removeEventListener('resize', check)
}, []) }, [])
// Auth handlers
const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } 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/' } 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) => { const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState dragStartState.current = sheetState
@ -127,21 +82,30 @@ export default function Panel({ onManeuverClick }) {
} }
}, [setSheetState]) }, [setSheetState])
const showOptimize = effectiveCount >= 3 const handleClearRoute = () => {
clearRoute()
onClearRoute?.()
}
// Determine what to show based on panel state
const showPreviewCard = panelState.startsWith('PREVIEW') const showPreviewCard = panelState.startsWith('PREVIEW')
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination const hasRoutePoints = routeStart || routeEnd
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED' const showRouteSection = hasRoutePoints || routeResult || routeLoading
const showEmptyState = panelState === 'IDLE' && !pendingDestination const showEmptyState = panelState === 'IDLE' && !hasRoutePoints
// Routes tab content - now state-driven // Show side panel place card when building route (either mode) and place is selected
const routesContent = ( const showSidePlaceCard = (directionsMode || showRouteSection) && selectedPlace
const routesContent = directionsMode ? (
// Directions mode: just the directions panel, place card is shown in side panel
<DirectionsPanel onClose={() => {
setDirectionsMode(false)
onClearRoute?.()
}} />
) : (
<> <>
<SearchBar /> <SearchBar />
{/* Preview card when place is selected */} {showPreviewCard && selectedPlace && !showRouteSection && (
{showPreviewCard && selectedPlace && (
<div className="mt-3"> <div className="mt-3">
<PlaceCard <PlaceCard
place={selectedPlace} place={selectedPlace}
@ -152,44 +116,100 @@ export default function Panel({ onManeuverClick }) {
</div> </div>
)} )}
{/* Route section with stops */}
{showRouteSection && ( {showRouteSection && (
<>
<div className="mt-3"> <div className="mt-3">
<StopList /> <div className="flex items-center justify-between mb-2">
</div> <span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Route
<div className="mt-3 flex flex-col gap-2"> </span>
<ModeSelector />
{showOptimize && (
<button <button
onClick={handleOptimize} onClick={handleClearRoute}
disabled={optimizing || routeLoading} className="p-1 rounded hover:bg-[var(--bg-overlay)]"
className="navi-btn-secondary w-full" title="Clear route"
> >
{optimizing ? 'Optimizing...' : 'Optimize stop order'} <X size={14} style={{ color: 'var(--text-tertiary)' }} />
</button> </button>
)} </div>
{pendingDestination && stops.length === 0 && (
<div className="flex flex-col gap-1 mb-3 text-xs">
<div className="flex items-center gap-2">
<MapPin size={12} style={{ color: '#22c55e' }} />
<span className="flex-1" style={{ color: routeStart ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>
{routeStart?.name || 'Click pin to pick start'}
</span>
<button <button
onClick={clearPendingDestination} onClick={() => setPickingRouteField('origin')}
className="navi-btn-secondary w-full" className="p-1 rounded hover:bg-[var(--bg-overlay)] transition-colors"
style={{ color: pickingRouteField === 'origin' ? 'var(--accent)' : 'var(--text-tertiary)' }}
title="Pick start from map"
> >
Cancel <Target size={14} />
</button> </button>
)}
</div> </div>
</> <div className="flex items-center gap-2">
)} <MapPin size={12} style={{ color: '#ef4444' }} />
<span className="flex-1" style={{ color: routeEnd ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>
{routeEnd?.name || 'Click pin to pick destination'}
</span>
<button
onClick={() => setPickingRouteField('destination')}
className="p-1 rounded hover:bg-[var(--bg-overlay)] transition-colors"
style={{ color: pickingRouteField === 'destination' ? 'var(--accent)' : 'var(--text-tertiary)' }}
title="Pick destination from map"
>
<Target size={14} />
</button>
</div>
</div>
{/* Maneuvers when route is calculated */} <div className="flex gap-1 mb-2">
{showManeuvers && (route || routeLoading || routeError) && ( {TRAVEL_MODES.map((m) => {
<div className="mt-3"> const active = routeMode === m.id
<ManeuverList onManeuverClick={onManeuverClick} /> return (
<button
key={m.id}
onClick={() => setRouteMode(m.id)}
className="flex-1 flex items-center justify-center gap-1 py-1.5 text-xs rounded transition-colors"
style={{
background: active ? 'var(--accent-muted)' : 'var(--bg-overlay)',
color: active ? 'var(--accent)' : 'var(--text-tertiary)',
}}
title={m.label}
>
<m.Icon size={14} />
<span className="hidden sm:inline">{m.label}</span>
</button>
)
})}
</div>
{routeMode !== 'auto' && (
<div className="flex gap-1 mb-3">
{BOUNDARY_MODES.map((m) => {
const active = boundaryMode === m.id
return (
<button
key={m.id}
onClick={() => setBoundaryMode(m.id)}
className="flex-1 flex items-center justify-center gap-1 py-1.5 text-xs rounded transition-colors"
style={{
background: active ? 'var(--accent-muted)' : 'var(--bg-overlay)',
color: active ? 'var(--accent)' : 'var(--text-tertiary)',
}}
title={m.title}
>
<m.Icon size={14} />
<span className="hidden sm:inline">{m.label}</span>
</button>
)
})}
</div>
)}
<ManeuverList />
</div> </div>
)} )}
{/* Empty state */}
{showEmptyState && ( {showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}> <div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p> <p>Search or tap the map to explore</p>
@ -203,13 +223,13 @@ export default function Panel({ onManeuverClick }) {
{showContacts && ( {showContacts && (
<div className="navi-tab-bar mb-3"> <div className="navi-tab-bar mb-3">
<button <button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`} className={"navi-tab " + (activeTab === 'routes' ? 'navi-tab-active' : '')}
onClick={() => setActiveTab('routes')} onClick={() => setActiveTab('routes')}
> >
Routes Routes
</button> </button>
<button <button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`} className={"navi-tab " + (activeTab === 'contacts' ? 'navi-tab-active' : '')}
onClick={() => setActiveTab('contacts')} onClick={() => setActiveTab('contacts')}
> >
Contacts Contacts
@ -231,7 +251,7 @@ export default function Panel({ onManeuverClick }) {
onClick={handleLogout} onClick={handleLogout}
className="flex items-center gap-1 px-2 py-1 rounded text-xs" className="flex items-center gap-1 px-2 py-1 rounded text-xs"
style={{ color: 'var(--text-tertiary)' }} style={{ color: 'var(--text-tertiary)' }}
title={`Logged in as ${auth.username}. Click to log out.`} title={"Logged in as " + auth.username + ". Click to log out."}
> >
<span className="hidden sm:inline">{auth.username}</span> <span className="hidden sm:inline">{auth.username}</span>
<LogOut size={14} /> <LogOut size={14} />
@ -253,9 +273,72 @@ export default function Panel({ onManeuverClick }) {
</div> </div>
) )
// Desktop: side panel (now 360px to accommodate PlaceCard) // Side panel for place card during directions mode (desktop only)
const sidePlaceCardPanel = showSidePlaceCard && !isMobile && (
<div
className="absolute top-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
left: '400px',
width: '300px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
boxShadow: 'inset 4px 0 8px -4px rgba(0,0,0,0.15)',
}}
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{selectedPlace?.name || 'Place Info'}
</span>
<button
onClick={clearSelectedPlace}
className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors"
title="Close"
>
<X size={16} style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
{/* Use PlaceCard in compact preview mode */}
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)
// Mobile overlay for place card during directions mode
const mobilePlaceCardOverlay = showSidePlaceCard && isMobile && (
<div
className="absolute inset-0 z-20 flex flex-col rounded-t-2xl"
style={{ background: 'var(--bg-raised)' }}
>
<div className="flex items-center justify-between p-4 border-b" style={{ borderColor: 'var(--border)' }}>
<span className="text-sm font-medium truncate pr-2" style={{ color: 'var(--text-primary)' }}>
{selectedPlace?.name || 'Place Info'}
</span>
<button
onClick={clearSelectedPlace}
className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors shrink-0"
title="Close"
>
<X size={16} style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
</div>
)
if (!isMobile) { if (!isMobile) {
return ( return (
<>
<div <div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col" className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{ style={{
@ -267,10 +350,11 @@ export default function Panel({ onManeuverClick }) {
{header} {header}
{content} {content}
</div> </div>
{sidePlaceCardPanel}
</>
) )
} }
// Mobile: bottom sheet
const sheetHeights = { const sheetHeights = {
collapsed: 'h-12', collapsed: 'h-12',
half: 'h-[45vh]', half: 'h-[45vh]',
@ -280,13 +364,12 @@ export default function Panel({ onManeuverClick }) {
return ( return (
<div <div
ref={sheetRef} ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`} className={"absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 " + sheetHeights[sheetState]}
style={{ style={{
background: 'var(--bg-raised)', background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)', borderTop: '1px solid var(--border)',
}} }}
> >
{/* Drag handle */}
<div <div
className="flex justify-center py-2 cursor-grab" className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
@ -301,9 +384,10 @@ export default function Panel({ onManeuverClick }) {
</div> </div>
{sheetState !== 'collapsed' && ( {sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}> <div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)] relative" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header} {header}
{content} {content}
{mobilePlaceCardOverlay}
</div> </div>
)} )}
</div> </div>

View file

@ -476,6 +476,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl
const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon) const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon)
const handleDirections = () => { 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 // No toast - empty origin slot is the visual prompt
startDirections(place) startDirections(place)
} }

View file

@ -6,6 +6,30 @@ import { buildAddress } from '../utils/place'
import { searchGeocode } from '../api' import { searchGeocode } from '../api'
import { hasFeature } from '../config' 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 */ /** Get category icon based on result type/source */
function CategoryIcon({ result }) { function CategoryIcon({ result }) {
const type = result.type || '' const type = result.type || ''
@ -71,6 +95,25 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
return 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 // Prepend matching contacts
let contactResults = [] let contactResults = []
if (hasFeature('has_contacts') && contacts.length > 0) { if (hasFeature('has_contacts') && contacts.length > 0) {

View file

@ -10,7 +10,7 @@ const FALLBACK_CONFIG = {
profile: 'home', profile: 'home',
region_name: 'North America', region_name: 'North America',
tileset: { tileset: {
url: '/tiles/na.pmtiles', url: '/tiles/planet/current.pmtiles',
bounds: [-168, 14, -52, 72], bounds: [-168, 14, -52, 72],
max_zoom: 15, max_zoom: 15,
attribution: 'Protomaps © OSM', attribution: 'Protomaps © OSM',
@ -30,8 +30,6 @@ const FALLBACK_CONFIG = {
has_landclass: false, has_landclass: false,
has_public_lands_layer: false, has_public_lands_layer: false,
has_contours: true, has_contours: true,
has_contours_test: true,
has_contours_test_10ft: false,
has_address_book_write: false, has_address_book_write: false,
has_usfs_trails: false, has_usfs_trails: false,
has_blm_trails: false, has_blm_trails: false,

View file

@ -1,8 +1,9 @@
import { create } from 'zustand' import { create } from "zustand"
import { requestOffroute } from "./api"
export const useStore = create((set, get) => ({ export const useStore = create((set, get) => ({
// ── Search state ── // ── Search state ──
query: '', query: "",
results: [], results: [],
searchLoading: false, searchLoading: false,
abortController: null, abortController: null,
@ -12,30 +13,9 @@ export const useStore = create((set, get) => ({
setSearchLoading: (loading) => set({ searchLoading: loading }), setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }), 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 ── // ── Geolocation ──
userLocation: null, // { lat, lon } userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' geoPermission: "prompt", // "prompt" | "granted" | "denied"
setUserLocation: (loc) => set({ userLocation: loc }), setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }), setGeoPermission: (p) => set({ geoPermission: p }),
@ -44,80 +24,260 @@ export const useStore = create((set, get) => ({
mapCenter: null, // { lat, lon, zoom } mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }), setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ── // ── Unified Route State ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle' // routeStart = origin (source of truth)
setMode: (mode) => set({ mode }), // routeEnd = destination (source of truth)
// stops[] = ONLY intermediate waypoints (not origin/destination)
// ── Route ── routeStart: null, // { lat, lon, name }
route: null, // Valhalla response (trip object) 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, routeLoading: false,
routeError: null, routeError: null,
setRoute: (route) => set({ route, 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 }), setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteError: (err) => set({ routeError: err, route: null }), setRouteError: (err) => set({ routeError: err, routeResult: null }),
clearRoute: () => set({ route: null, routeError: 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 ── // ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? } selectedPlace: null,
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection clickMarker: null,
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 }), setSelectedPlace: (place) => set({ selectedPlace: place }),
// Boundary rendering function - set by MapView, called by PlaceCard
updateBoundary: null, updateBoundary: null,
setUpdateBoundary: (fn) => set({ updateBoundary: fn }), setUpdateBoundary: (fn) => set({ updateBoundary: fn }),
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
setClickMarker: (marker) => set({ clickMarker: marker }), setClickMarker: (marker) => set({ clickMarker: marker }),
clearClickMarker: () => set({ clickMarker: null }), 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 ── // ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full' sheetState: "half",
panelOpen: true, panelOpen: true,
autocompleteOpen: false, autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) directionsMode: false,
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) activeDirectionsField: null,
viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' pickingRouteField: null,
theme: "dark",
themeOverride: null,
viewMode: (typeof localStorage !== "undefined" && localStorage.getItem("navi-view-mode")) || "map",
setSheetState: (s) => set({ sheetState: s }), setSheetState: (s) => set({ sheetState: s }),
setViewMode: (mode) => { setViewMode: (mode) => {
set({ viewMode: mode }) set({ viewMode: mode })
localStorage.setItem('navi-view-mode', mode) localStorage.setItem("navi-view-mode", mode)
}, },
setPanelOpen: (open) => set({ panelOpen: open }), setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: 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 }), setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => { setThemeOverride: (override) => {
set({ themeOverride: override }) set({ themeOverride: override })
if (override) { if (override) {
localStorage.setItem('navi-theme-override', override) localStorage.setItem("navi-theme-override", override)
} else { } else {
localStorage.removeItem('navi-theme-override') localStorage.removeItem("navi-theme-override")
} }
}, },
// ── Auth state ── // ── Auth state ──
auth: { authenticated: false, username: null, loaded: false }, auth: { authenticated: false, username: null, loaded: false },
setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), setAuth: (auth) => set({ auth: { ...auth, loaded: true } }),
@ -125,9 +285,9 @@ export const useStore = create((set, get) => ({
// ── Contacts ── // ── Contacts ──
contacts: [], contacts: [],
contactsLoaded: false, contactsLoaded: false,
activeTab: 'routes', // 'routes' | 'contacts' activeTab: "routes",
editingContact: null, // null=closed, {}=new, {id:N}=edit editingContact: null,
pickingLocationFor: null, // form data while user picks location on map pickingLocationFor: null,
setContacts: (c) => set({ contacts: c, contactsLoaded: true }), setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }), setActiveTab: (tab) => set({ activeTab: tab }),
@ -138,18 +298,17 @@ export const useStore = create((set, get) => ({
})) }))
// ── Panel state selector ── // ── Panel state selector ──
// Returns string state, prioritizing preview to allow it alongside any route state
export const usePanelState = () => { export const usePanelState = () => {
return useStore((s) => { return useStore((s) => {
const hasPreview = !!s.selectedPlace const hasPreview = !!s.selectedPlace
const hasRoute = !!s.route const hasRoute = !!s.routeResult
const hasStops = s.stops.length >= 1 const hasRoutePoints = !!s.routeStart || !!s.routeEnd
if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" if (hasPreview && hasRoute) return "PREVIEW_CALCULATED"
if (hasPreview && hasStops) return "PREVIEW_ROUTING" if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING"
if (hasPreview) return "PREVIEW" if (hasPreview) return "PREVIEW"
if (hasRoute) return "ROUTE_CALCULATED" if (hasRoute) return "ROUTE_CALCULATED"
if (hasStops) return "ROUTING" if (hasRoutePoints) return "ROUTING"
return "IDLE" return "IDLE"
}) })
} }