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

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

+
+ )} +
+ ) +} diff --git a/src/components/LayerControl.jsx b/src/components/LayerControl.jsx index cf2e34e..31dcbc2 100644 --- a/src/components/LayerControl.jsx +++ b/src/components/LayerControl.jsx @@ -1,222 +1,242 @@ -import { useState, useEffect, useRef } from 'react' -import { Layers, Trees, Mountain } from 'lucide-react' -import { hasFeature, getConfig } from '../config' - -const STORAGE_KEY = 'navi-layer-prefs' - -function loadPrefs() { - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (raw) return JSON.parse(raw) - } catch {} - return null -} - -function savePrefs(prefs) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) -} - -export default function LayerControl({ mapRef }) { - const [open, setOpen] = useState(false) - const [hillshade, setHillshade] = useState(false) - const [traffic, setTraffic] = useState(false) - const [publicLands, setPublicLands] = useState(false) - const [contours, setContours] = useState(false) - const [contoursTest, setContoursTest] = useState(false) - const [contoursTest10ft, setContoursTest10ft] = useState(false) +import { useState, useEffect, useRef } from 'react' +import { Layers, Map, Satellite, Globe } from 'lucide-react' +import { hasFeature, getConfig } from '../config' +import { useConfig } from '../hooks/useConfig' +import { useStore } from '../store' + +const STORAGE_KEY = 'navi-layer-prefs' + +function loadPrefs() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) return JSON.parse(raw) + } catch {} + return null +} + +function savePrefs(prefs) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) +} + +export default function LayerControl({ mapRef }) { + const [open, setOpen] = useState(false) + const [hillshade, setHillshade] = useState(false) + const [traffic, setTraffic] = useState(false) + const [publicLands, setPublicLands] = useState(false) + const [contours, setContours] = useState(false) + const [contoursTest, setContoursTest] = useState(false) + const [contoursTest10ft, setContoursTest10ft] = useState(false) const [usfsTrails, setUsfsTrails] = useState(false) - const [blmTrails, setBlmTrails] = useState(false) - const panelRef = useRef(null) - - // Initialize from localStorage or defaults on mount - useEffect(() => { - const saved = loadPrefs() - const hsAvailable = hasFeature('has_hillshade') - const trAvailable = hasFeature('has_traffic_overlay') - const plAvailable = hasFeature('has_public_lands_layer') - const ctAvailable = hasFeature('has_contours') - const ctTestAvailable = hasFeature('has_contours_test') - const ctTest10ftAvailable = hasFeature('has_contours_test_10ft') + const [blmTrails, setBlmTrails] = useState(false) + const panelRef = useRef(null) + + // View mode: map | satellite | hybrid + const viewMode = useStore((s) => s.viewMode) + const setViewMode = useStore((s) => s.setViewMode) + + // Auth state — Traffic tiles are auth-gated at the edge (Caddy @authed_api), + // so the toggle is only usable when authenticated. config drives re-init once + // /api/config resolves (so saved prefs hydrate against known feature flags). + const auth = useStore((s) => s.auth) + const config = useConfig() + const trafficDisabled = !auth.loaded || !auth.authenticated + + // Initialize from localStorage or defaults on mount (re-runs when config loads) + useEffect(() => { + const saved = loadPrefs() + const hsAvailable = hasFeature('has_hillshade') + const trAvailable = hasFeature('has_traffic_overlay') + const plAvailable = hasFeature('has_public_lands_layer') + const ctAvailable = hasFeature('has_contours') + const ctTestAvailable = hasFeature('has_contours_test') + const ctTest10ftAvailable = hasFeature('has_contours_test_10ft') const usfsAvailable = hasFeature('has_usfs_trails') - const blmAvailable = hasFeature('has_blm_trails') - - if (saved) { - setHillshade(hsAvailable && (saved.hillshade ?? true)) - setTraffic(trAvailable && (saved.traffic ?? false)) - setPublicLands(plAvailable && (saved.publicLands ?? false)) - setContours(ctAvailable && (saved.contours ?? false)) - setContoursTest(ctTestAvailable && (saved.contoursTest ?? false)) - setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false)) + const blmAvailable = hasFeature('has_blm_trails') + + if (saved) { + setHillshade(hsAvailable && (saved.hillshade ?? true)) + setTraffic(trAvailable && auth.authenticated && (saved.traffic ?? false)) + setPublicLands(plAvailable && (saved.publicLands ?? false)) + setContours(ctAvailable && (saved.contours ?? false)) + setContoursTest(ctTestAvailable && (saved.contoursTest ?? false)) + setContoursTest10ft(ctTest10ftAvailable && (saved.contoursTest10ft ?? false)) setUsfsTrails(usfsAvailable && (saved.usfsTrails ?? false)) - setBlmTrails(blmAvailable && (saved.blmTrails ?? false)) - } else { - // Defaults: hillshade ON if available, others OFF - setHillshade(hsAvailable) - setTraffic(false) - setPublicLands(false) - setContours(false) - setContoursTest(false) - setContoursTest10ft(false) - setUsfsTrails(false) - } - }, []) - - // Apply layers when prefs change - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (hillshade && hasFeature('has_hillshade')) { - mapView.addHillshadeLayer?.() - } else { - mapView.removeHillshadeLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [hillshade, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (traffic && hasFeature('has_traffic_overlay')) { - mapView.addTrafficLayer?.() - } else { - mapView.removeTrafficLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [traffic, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (publicLands && hasFeature('has_public_lands_layer')) { - mapView.addPublicLandsLayer?.() - } else { - mapView.removePublicLandsLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [publicLands, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (contours && hasFeature('has_contours')) { - mapView.addContoursLayer?.() - } else { - mapView.removeContoursLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [contours, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (contoursTest && hasFeature('has_contours_test')) { - mapView.addContoursTestLayer?.() - } else { - mapView.removeContoursTestLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [contoursTest, mapRef]) - - // Apply contoursTest10ft layer - useEffect(() => { - const map = mapRef.current?.getMap?.() - if (!map) return - - const apply = () => { - if (contoursTest10ft && hasFeature('has_contours_test_10ft')) { - mapRef.current?.addContoursTest10ftLayer?.() - } else { - mapRef.current?.removeContoursTest10ftLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - }, [contoursTest10ft, mapRef]) - - // Apply usfsTrails layer - useEffect(() => { - const map = mapRef.current?.getMap?.() - if (!map) return - - const apply = () => { - if (usfsTrails && hasFeature('has_usfs_trails')) { - mapRef.current?.addUsfsTrailsLayer?.() - } else { - mapRef.current?.removeUsfsTrailsLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - }, [usfsTrails, mapRef]) + setBlmTrails(blmAvailable && (saved.blmTrails ?? false)) + } else { + // Defaults: hillshade ON if available, others OFF + setHillshade(hsAvailable) + setTraffic(false) + setPublicLands(false) + setContours(false) + setContoursTest(false) + setContoursTest10ft(false) + setUsfsTrails(false) + } + }, [config]) + + // Tear down traffic when the session goes anonymous (only after auth has + // loaded, so we don't tear down during the brief pre-whoami window on reload). + // Flipping the pref off drives the apply effect below -> removeTrafficLayer. + useEffect(() => { + if (auth.loaded && !auth.authenticated && traffic) setTraffic(false) + }, [auth.loaded, auth.authenticated]) // eslint-disable-line react-hooks/exhaustive-deps + + // Apply layers when prefs change + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + if (hillshade && hasFeature('has_hillshade')) { + mapView.addHillshadeLayer?.() + } else { + mapView.removeHillshadeLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + return () => map.off('style.load', apply) + }, [hillshade, mapRef]) + + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + if (traffic && hasFeature('has_traffic_overlay') && auth.authenticated) { + mapView.addTrafficLayer?.() + } else { + mapView.removeTrafficLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + return () => map.off('style.load', apply) + }, [traffic, mapRef, auth.authenticated]) + + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + if (publicLands && hasFeature('has_public_lands_layer')) { + mapView.addPublicLandsLayer?.() + } else { + mapView.removePublicLandsLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + return () => map.off('style.load', apply) + }, [publicLands, mapRef]) + + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + if (contours && hasFeature('has_contours')) { + mapView.addContoursLayer?.() + } else { + mapView.removeContoursLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + return () => map.off('style.load', apply) + }, [contours, mapRef]) + + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + if (contoursTest && hasFeature('has_contours_test')) { + mapView.addContoursTestLayer?.() + } else { + mapView.removeContoursTestLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + return () => map.off('style.load', apply) + }, [contoursTest, mapRef]) + + // Apply contoursTest10ft layer + useEffect(() => { + const map = mapRef.current?.getMap?.() + if (!map) return + + const apply = () => { + if (contoursTest10ft && hasFeature('has_contours_test_10ft')) { + mapRef.current?.addContoursTest10ftLayer?.() + } else { + mapRef.current?.removeContoursTest10ftLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + }, [contoursTest10ft, mapRef]) + + // Apply usfsTrails layer + useEffect(() => { + const map = mapRef.current?.getMap?.() + if (!map) return + + const apply = () => { + if (usfsTrails && hasFeature('has_usfs_trails')) { + mapRef.current?.addUsfsTrailsLayer?.() + } else { + mapRef.current?.removeUsfsTrailsLayer?.() + } + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) + }, [usfsTrails, mapRef]) // Apply blmTrails layer useEffect(() => { @@ -238,129 +258,181 @@ export default function LayerControl({ mapRef }) { } savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) }, [blmTrails, mapRef]) - - // Close on outside click - useEffect(() => { - if (!open) return - function handleClick(e) { - if (panelRef.current && !panelRef.current.contains(e.target)) { - setOpen(false) - } - } - document.addEventListener('pointerdown', handleClick) - return () => document.removeEventListener('pointerdown', handleClick) - }, [open]) - - const showHillshade = hasFeature('has_hillshade') - const showTraffic = hasFeature('has_traffic_overlay') - const showPublicLands = hasFeature('has_public_lands_layer') - const showContours = hasFeature('has_contours') - const showContoursTest = hasFeature('has_contours_test') - const showContoursTest10ft = hasFeature('has_contours_test_10ft') + + // Apply view mode changes + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + mapView.setViewMode?.(viewMode) + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + return () => map.off('style.load', apply) + }, [viewMode, mapRef]) + + // Close on outside click + useEffect(() => { + if (!open) return + function handleClick(e) { + if (panelRef.current && !panelRef.current.contains(e.target)) { + setOpen(false) + } + } + document.addEventListener('pointerdown', handleClick) + return () => document.removeEventListener('pointerdown', handleClick) + }, [open]) + + const showHillshade = hasFeature('has_hillshade') + const showTraffic = hasFeature('has_traffic_overlay') + const showPublicLands = hasFeature('has_public_lands_layer') + const showContours = hasFeature('has_contours') + const showContoursTest = hasFeature('has_contours_test') + const showContoursTest10ft = hasFeature('has_contours_test_10ft') const showUsfsTrails = hasFeature('has_usfs_trails') - const showBlmTrails = hasFeature('has_blm_trails') - - // Don't render if no overlay features available - if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails && !showBlmTrails) return null - - return ( -
- - - {open && ( -
-
Layers
- - {showHillshade && ( - - )} - - {showTraffic && ( - - )} - - {showPublicLands && ( - - )} - - {showContours && ( - - )} - - {showContoursTest && ( - - )} - - {showContoursTest10ft && ( - - )} - - {showUsfsTrails && ( - - )} + const showBlmTrails = hasFeature('has_blm_trails') + + // Don't render if no overlay features available + if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails && !showBlmTrails) return null + + return ( +
+ + + {open && ( +
+ {/* View mode segmented control */} +
+ + + +
+ +
Layers
+ + {showHillshade && ( + + )} + + {showTraffic && ( + + )} + + {showPublicLands && ( + + )} + + {showContours && ( + + )} + + {showContoursTest && ( + + )} + + {showContoursTest10ft && ( + + )} + + {showUsfsTrails && ( + + )} {showBlmTrails && ( )} -
- )} -
- ) -} +
+ )} +
+ ) +} diff --git a/src/components/LocateButton.jsx b/src/components/LocateButton.jsx index 3ec445f..55723cf 100644 --- a/src/components/LocateButton.jsx +++ b/src/components/LocateButton.jsx @@ -48,7 +48,7 @@ export default function LocateButton({ mapRef }) { title="My location" aria-label="Center map on my location" > - + ) } diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx new file mode 100644 index 0000000..eb0a204 --- /dev/null +++ b/src/components/LocationInput.jsx @@ -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 + if (source === "nickname") return + if (type === "coordinates") return + if (type === "locality" || type === "city") return + const osmVal = result.raw?.osm_value || "" + if (osmVal.includes("cafe") || osmVal.includes("coffee")) return + if (osmVal.includes("fuel") || osmVal.includes("gas")) return + if (osmVal.includes("shop") || osmVal.includes("supermarket")) return + if (osmVal.includes("hotel") || osmVal.includes("motel")) return + return +} + +export default function LocationInput({ + value, // { lat, lon, name } or null + onChange, // (place) => void + placeholder, + icon, // "origin" | "destination" | "stop" + fieldId, // unique id for this field (for map click targeting) + onFocus, // () => void + autoFocus, +}) { + const inputRef = useRef(null) + const [query, setQuery] = useState(value?.name || "") + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) + const debounceRef = useRef(null) + const abortRef = useRef(null) + + const contacts = useStore((s) => s.contacts) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField) + const pickingRouteField = useStore((s) => s.pickingRouteField) + const setPickingRouteField = useStore((s) => s.setPickingRouteField) + + // Sync display value when external value changes + useEffect(() => { + if (value?.name && value.name !== query) { + setQuery(value.name) + } else if (!value && query && !open) { + // Value cleared externally + setQuery("") + } + }, [value?.name, value?.lat, value?.lon]) + + const doSearch = useCallback(async (q) => { + if (abortRef.current) abortRef.current.abort() + + if (!q.trim()) { + setResults([]) + setOpen(false) + setLoading(false) + return + } + + // Check coordinates first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setOpen(true) + setLoading(false) + return + } + + // Contact matches + let contactResults = [] + if (hasFeature("has_contacts") && contacts.length > 0) { + const lower = q.trim().toLowerCase() + contactResults = contacts + .filter((c) => + (c.label || "").toLowerCase().startsWith(lower) || + (c.name || "").toLowerCase().startsWith(lower) || + (c.call_sign || "").toLowerCase().startsWith(lower) + ) + .slice(0, 3) + .map((c) => ({ + lat: c.lat, + lon: c.lon, + name: c.label, + address: c.address || c.name || "", + type: "contact", + source: "contacts", + match_code: null, + raw: { contact: c }, + _isContact: true, + })) + } + + const ctrl = new AbortController() + abortRef.current = ctrl + setLoading(true) + + try { + const data = await searchGeocode(q.trim(), 5, ctrl.signal) + const combined = [...contactResults, ...(data.results || [])] + setResults(combined) + setOpen(combined.length > 0) + setActiveIndex(-1) + } catch (e) { + if (e.name !== "AbortError") { + if (contactResults.length > 0) { + setResults(contactResults) + setOpen(true) + } else { + setResults([]) + setOpen(false) + } + } + } finally { + setLoading(false) + } + }, [contacts]) + + const handleChange = (e) => { + const val = e.target.value + setQuery(val) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => doSearch(val), 150) + } + + const handleClear = () => { + setQuery("") + setResults([]) + setOpen(false) + onChange(null) + inputRef.current?.focus() + } + + const selectResult = (result) => { + onChange({ + lat: result.lat, + lon: result.lon, + name: result.name, + source: result.source, + matchCode: result.match_code, + }) + setQuery(result.name) + setResults([]) + setOpen(false) + setActiveIndex(-1) + } + + const handleKeyDown = (e) => { + if (!open || results.length === 0) { + if (e.key === "Escape") setOpen(false) + return + } + switch (e.key) { + case "ArrowDown": + e.preventDefault() + setActiveIndex((prev) => Math.min(prev + 1, results.length - 1)) + break + case "ArrowUp": + e.preventDefault() + setActiveIndex((prev) => Math.max(prev - 1, -1)) + break + case "Enter": + e.preventDefault() + if (activeIndex >= 0 && activeIndex < results.length) { + selectResult(results[activeIndex]) + } + break + case "Escape": + e.preventDefault() + setOpen(false) + setActiveIndex(-1) + break + } + } + + const handleFocus = () => { + setActiveDirectionsField(fieldId) // For styling only, not map clicks + if (results.length > 0) setOpen(true) + onFocus?.() + } + + const handlePickFromMap = () => { + setPickingRouteField(fieldId) + toast("Click map to set location", { icon: "🎯", duration: 3000 }) + inputRef.current?.blur() // Unfocus input so user focuses on map + } + + const isPicking = pickingRouteField === fieldId + + const handleBlur = () => { + // Delay to allow click on dropdown + setTimeout(() => setOpen(false), 150) + } + + const isActive = activeDirectionsField === fieldId + + const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)" + + return ( +
+
+ {icon === "origin" ? ( + + ) : ( + + )} + + {/* Pick from map button */} + + {loading ? ( +
+ ) : query ? ( + + ) : null} +
+ + {open && results.length > 0 && ( +
    + {results.map((r, i) => { + const isPoi = r.type === "poi" && r.raw?.name + const isContact = r._isContact + const primary = isContact ? r.name : isPoi ? r.raw.name : r.name + const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null + return ( +
  • selectResult(r)} + onMouseEnter={() => setActiveIndex(i)} + > +
    + + + + + {primary} + +
    + {secondary && ( +
    + {secondary} +
    + )} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/components/ManeuverList.jsx b/src/components/ManeuverList.jsx index d869b66..44d1ffc 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -1,140 +1,343 @@ -import { - MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, - MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, - GitMerge, CornerRightDown, CornerRightUp, Navigation -} from 'lucide-react' -import { useStore } from '../store' - -function formatTime(seconds) { - if (seconds < 60) return `${Math.round(seconds)}s` - if (seconds < 3600) return `${Math.round(seconds / 60)} min` - const h = Math.floor(seconds / 3600) - const m = Math.round((seconds % 3600) / 60) - return m > 0 ? `${h}h ${m}m` : `${h}h` -} - -function formatDist(miles) { - if (miles < 0.1) return `${Math.round(miles * 5280)} ft` - return `${miles.toFixed(1)} mi` -} - -function ManeuverIcon({ type }) { - const size = 16 - const props = { size, strokeWidth: 1.5 } - switch (type) { - case 0: return - case 1: return - case 2: return - case 3: return - case 4: case 5: return - case 6: return - case 7: return - case 8: return - case 9: return - case 10: case 11: case 12: return - case 15: case 16: return - case 24: return - case 25: return - case 26: return - default: return - } -} - -export default function ManeuverList({ onManeuverClick }) { - const route = useStore((s) => s.route) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - - if (routeLoading) { - return ( -
-
- - Calculating route... - -
- ) - } - - if (routeError) { - return ( -
- {routeError} -
- ) - } - - if (!route || !route.legs) return null - - const totalTime = route.summary?.time || 0 - const totalDist = route.summary?.length || 0 - - const allManeuvers = [] - let timeRemaining = totalTime - - for (let legIdx = 0; legIdx < route.legs.length; legIdx++) { - const leg = route.legs[legIdx] - for (const man of leg.maneuvers || []) { - allManeuvers.push({ ...man, _legIndex: legIdx, timeRemaining }) - timeRemaining -= man.time || 0 - } - } - - return ( -
- {/* Route summary */} -
- - {formatDist(totalDist)} - - - {formatTime(totalTime)} - -
- - {/* Maneuver steps */} -
- {allManeuvers.map((man, i) => ( - - ))} -
-
- ) -} +import { + MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, + MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, + GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle, + Compass, ArrowUp, ArrowUpRight, ArrowRight, ArrowDownRight, ArrowDown, + ArrowDownLeft, ArrowLeft, ArrowUpLeft, MapPin +} from 'lucide-react' +import { useStore } from '../store' + +/** + * Format distance with commas for feet, one decimal for miles. + * Under 1 mile: "2,640 ft" + * 1+ miles: "1.3 mi" + */ +function formatDistance(distanceM, distanceKm) { + let meters = null + if (distanceM !== undefined && distanceM !== null) { + meters = distanceM + } else if (distanceKm !== undefined && distanceKm !== null) { + meters = distanceKm * 1000 + } + + if (meters === null) return '' + + const miles = meters / 1609.34 + if (miles < 1) { + const feet = Math.round(meters * 3.28084) + return feet.toLocaleString() + ' ft' + } + return miles.toFixed(1) + ' mi' +} + +function formatTimeMin(minutes) { + if (minutes < 60) return Math.round(minutes) + ' min' + const h = Math.floor(minutes / 60) + const m = Math.round(minutes % 60) + return m > 0 ? h + 'h ' + m + 'm' : h + 'h' +} + +// Compass arrow icon based on cardinal direction with rotation +function CompassIcon({ cardinal, bearing, size = 16 }) { + // Use bearing to rotate arrow, or fall back to cardinal-based icon + if (bearing !== undefined && bearing !== null) { + return ( + + ) + } + + const props = { size, strokeWidth: 2 } + const arrowMap = { + 'N': ArrowUp, + 'NNE': ArrowUpRight, + 'NE': ArrowUpRight, + 'ENE': ArrowRight, + 'E': ArrowRight, + 'ESE': ArrowRight, + 'SE': ArrowDownRight, + 'SSE': ArrowDownRight, + 'S': ArrowDown, + 'SSW': ArrowDownLeft, + 'SW': ArrowDownLeft, + 'WSW': ArrowLeft, + 'W': ArrowLeft, + 'WNW': ArrowLeft, + 'NW': ArrowUpLeft, + 'NNW': ArrowUpLeft, + } + const Icon = arrowMap[cardinal] || Compass + return +} + +// Wilderness maneuver icon +function WildernessIcon({ type, cardinal, bearing, size = 16 }) { + if (type === 'arrival') { + return + } + return +} + +// Network maneuver icon (Valhalla types) +function ManeuverIcon({ type }) { + const size = 16 + const props = { size, strokeWidth: 1.5 } + switch (type) { + case 0: return + case 1: return + case 2: return + case 3: return + case 4: case 5: return + case 6: return + case 7: return + case 8: return + case 9: return + case 10: case 11: case 12: return + case 15: case 16: return + case 24: return + case 25: return + case 26: return + default: return + } +} + +/** + * Add transport mode prefix to network maneuver instruction. + * "Drive east on..." for auto, "Walk south on..." for foot, "Ride north on..." for mtb + */ +function formatNetworkInstruction(instruction, mode) { + if (!instruction) return '' + + // Get verb based on mode + const modeVerbs = { + 'auto': 'Drive', + 'foot': 'Walk', + 'pedestrian': 'Walk', + 'mtb': 'Ride', + 'bicycle': 'Ride', + 'atv': 'Drive', + 'vehicle': 'Drive', + } + const verb = modeVerbs[mode] || 'Go' + + // Check if instruction starts with a direction verb we should replace + const startsWithVerbs = [ + 'Turn left', 'Turn right', 'Bear left', 'Bear right', + 'Keep left', 'Keep right', 'Continue', 'Head', 'Go', + 'Proceed', 'Make a', 'Take a', 'Start', 'Merge', 'Exit' + ] + + for (const v of startsWithVerbs) { + if (instruction.startsWith(v)) { + // Already has a verb, return as-is (Valhalla instructions are already good) + return instruction + } + } + + // If instruction starts with direction (north, south, etc.), prepend verb + const directions = ['north', 'south', 'east', 'west', 'onto', 'on '] + for (const dir of directions) { + if (instruction.toLowerCase().startsWith(dir)) { + return `${verb} ${instruction}` + } + } + + return instruction +} + +export default function ManeuverList() { + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + const routeMode = useStore((s) => s.routeMode) + + if (routeLoading) { + return ( +
+
+ + Calculating route... + +
+ ) + } + + if (routeError) { + return ( +
+ {routeError} +
+ ) + } + + if (!routeResult?.summary) return null + + const summary = routeResult.summary + const features = routeResult.route?.features || [] + const networkMode = summary.network_mode || routeMode || 'foot' + + // Extract maneuvers from each segment type + const wildernessStartFeature = features.find(f => + f.properties?.segment_type === 'wilderness' && f.properties?.segment_position === 'start' + ) + const networkFeature = features.find(f => f.properties?.segment_type === 'network') + const wildernessEndFeature = features.find(f => + f.properties?.segment_type === 'wilderness' && f.properties?.segment_position === 'end' + ) + + const wildernessStartManeuvers = wildernessStartFeature?.properties?.maneuvers || [] + const networkManeuvers = networkFeature?.properties?.maneuvers || [] + const wildernessEndManeuvers = wildernessEndFeature?.properties?.maneuvers || [] + + const hasManeuvers = wildernessStartManeuvers.length > 0 || + networkManeuvers.length > 0 || + wildernessEndManeuvers.length > 0 + + return ( +
+ {/* Total summary */} +
+ + {formatDistance(null, summary.total_distance_km)} + + + {formatTimeMin(summary.total_effort_minutes)} + +
+ + {/* Segment breakdown */} +
+ {summary.wilderness_distance_km > 0 && ( +
+ + Wilderness + + {formatDistance(null, summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)} + +
+ )} + {summary.network_distance_km > 0 && ( +
+ + Road/Trail + + {formatDistance(null, summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)} + +
+ )} +
+ + {/* Warnings */} + {(summary.barrier_crossings > 0 || summary.mvum_closed_crossings > 0) && ( +
+ {summary.barrier_crossings > 0 && ( +
+ + {summary.barrier_crossings} barrier crossing{summary.barrier_crossings > 1 ? 's' : ''} +
+ )} + {summary.mvum_closed_crossings > 0 && ( +
+ + {summary.mvum_closed_crossings} MVUM closure{summary.mvum_closed_crossings > 1 ? 's' : ''} +
+ )} +
+ )} + + {/* Turn-by-turn directions */} + {hasManeuvers && ( +
+
Directions
+ + {/* Wilderness start maneuvers */} + {wildernessStartManeuvers.length > 0 && ( + <> +
+ Wilderness — On Foot +
+ {wildernessStartManeuvers.map((man, i) => ( +
+ + + +
+

+ {man.instruction} +

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

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

+

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

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

+ {man.instruction} +

+
+
+ ))} + + )} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 5b8eb3c..5c185c9 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2,17 +2,19 @@ import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from 're import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' -import { layers } from 'protomaps-themes-base' -import { getTheme, getThemeColors, getThemeSprite, getOverlayConfig } from '../themes/registry' +import { layers, namedTheme } from 'protomaps-themes-base' +import { getTheme, getThemeSprite, getOverlayConfig } from '../themes/registry' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' -import { fetchReverse } from '../api' +import { fetchReverse, requestOffroute } from '../api' import { getConfig, hasFeature } from '../config' -import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } from 'lucide-react' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' +import mlcontour from '@acalcutt/maplibre-contour-pmtiles' +let demSourceInstance = null /** Check if current theme is dark based on registry */ function isCurrentThemeDark() { @@ -23,7 +25,12 @@ function isCurrentThemeDark() { const ROUTE_SOURCE = 'route-source' const BOUNDARY_SOURCE = 'boundary-source' const BOUNDARY_LAYER = 'boundary-layer' +const STATE_BOUNDARIES_LAYER = 'state-boundaries-z4-z7' const ROUTE_LAYER_PREFIX = 'route-layer-' +const OFFROUTE_SOURCE = 'offroute-source' +const OFFROUTE_WILDERNESS_LAYER = 'offroute-wilderness' +const OFFROUTE_NETWORK_LAYER = 'offroute-network' +const OFFROUTE_MARKERS_LAYER = 'offroute-markers' const HILLSHADE_SOURCE = 'hillshade-dem' const HILLSHADE_LAYER = 'hillshade-layer' const TRAFFIC_SOURCE = 'traffic-tiles' @@ -32,21 +39,9 @@ const PUBLIC_LANDS_SOURCE = 'public-lands-tiles' const PUBLIC_LANDS_FILL = 'public-lands-fill' const PUBLIC_LANDS_LINE = 'public-lands-line' const PUBLIC_LANDS_LABEL = 'public-lands-label' -const CONTOUR_SOURCE = 'contour-tiles' -const CONTOUR_MINOR = 'contour-minor' -const CONTOUR_INTERMEDIATE = 'contour-intermediate' -const CONTOUR_INDEX = 'contour-index' -const CONTOUR_LABEL = 'contour-label' -const CONTOUR_TEST_SOURCE = 'contour-test-tiles' -const CONTOUR_TEST_MINOR = 'contour-test-minor' -const CONTOUR_TEST_INTERMEDIATE = 'contour-test-intermediate' -const CONTOUR_TEST_INDEX = 'contour-test-index' -const CONTOUR_TEST_LABEL = 'contour-test-label' -const CONTOUR_TEST_10FT_SOURCE = 'contour-test-10ft-tiles' -const CONTOUR_TEST_10FT_MINOR = 'contour-test-10ft-minor' -const CONTOUR_TEST_10FT_INTERMEDIATE = 'contour-test-10ft-intermediate' -const CONTOUR_TEST_10FT_INDEX = 'contour-test-10ft-index' -const CONTOUR_TEST_10FT_LABEL = 'contour-test-10ft-label' +const CONTOUR_SOURCE = 'contour-source' +const CONTOUR_LINE = 'contour-lines' +const CONTOUR_LABEL = 'contour-labels' const MEASURE_SOURCE = 'measure-source' const MEASURE_LINE_LAYER = 'measure-line-layer' const MEASURE_POINT_LAYER = 'measure-point-layer' @@ -65,6 +60,8 @@ const BLM_ROUTES_SNOW = 'blm-routes-snow' const BLM_ROUTES_OTHER = 'blm-routes-other' const BLM_ROUTES_LABEL = 'blm-routes-label' const BLM_ROUTES_HIT = 'blm-routes-hit' +const SATELLITE_SOURCE = 'satellite-source' +const SATELLITE_LAYER = 'satellite-layer' // Highlight state - use data-driven expressions to target specific features @@ -262,14 +259,41 @@ function applyBaseLabelStyling(map) { 'text-halo-width': 1.8, } }) + + // Adjust label zoom ranges for proper hierarchy: + // - Countries: z1-z4 (fade out as states appear) + // - States/provinces: z4-z7 (appear as countries fade, fade as cities dominate) + // - Cities: unchanged (natural min_zoom in tile data) + try { + if (map.getLayer('places_country')) { + map.setLayerZoomRange('places_country', 1, 5) + } + if (map.getLayer('places_region')) { + map.setLayerZoomRange('places_region', 4, 8) + // FIX: The protomaps theme uses name:short which doesn't exist in tiles + // Use coalesce to fall back to ref (e.g., "CA") then name (e.g., "California") + map.setLayoutProperty('places_region', 'text-field', [ + 'coalesce', + ['get', 'name'], + ['get', 'ref'], + ['get', 'name:short'] + ]) + } + } catch (e) { + // Ignore if layers don't exist + } } /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() - const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles' + const tileUrl = config?.tileset?.url || '/tiles/planet/planet-20260420.pmtiles' const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM' + // Use namedTheme directly for built-in themes, custom colors for others + const theme = getTheme(themeName) + const colors = theme.colors || namedTheme(themeName) + return { version: 8, glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf', @@ -281,7 +305,7 @@ function buildStyle(themeName) { attribution, }, }, - layers: layers('protomaps', getThemeColors(themeName), { lang: 'en' }), + layers: layers('protomaps', colors, { lang: 'en' }), } } @@ -504,6 +528,8 @@ function addPublicLands(map, themeId) { type: 'symbol', source: PUBLIC_LANDS_SOURCE, 'source-layer': 'public_lands', + // Exclude PAD-US sub-polygons whose unit_nm is "Unknown " — USGS source artifact, not real label. + filter: ['!', ['==', ['slice', ['coalesce', ['get', 'name'], ''], 0, 8], 'Unknown ']], minzoom: 10, layout: { 'text-field': ['get', 'name'], @@ -533,309 +559,101 @@ function removePublicLands(map) { if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE) } -/** Add topographic contour vector tile overlay */ +/** Add topographic contours via maplibre-contour */ function addContours(map, themeId) { - if (!map || map.getSource(CONTOUR_SOURCE)) return - - const c = getOverlayConfig(themeId, 'contours') + console.log("[CONTOUR] addContours called, source exists:", !!map?.getSource(CONTOUR_SOURCE), "demSource:", !!demSourceInstance) + if (!map || !demSourceInstance || map.getSource(CONTOUR_SOURCE)) return + const c = getOverlayConfig(themeId, "contours") + const contourThresholds = { + 3: [5000, 25000], + 4: [2500, 10000], + 5: [1000, 5000], + 6: [1000, 5000], + 7: [500, 2500], + 8: [500, 2500], + 9: [250, 1000], + 10: [200, 1000], + 11: [200, 1000], + 12: [100, 500], + 13: [100, 500], + 14: [50, 200], + 15: [20, 100], + } map.addSource(CONTOUR_SOURCE, { - type: 'vector', - url: 'pmtiles:///tiles/contours-na.pmtiles', + type: "vector", + tiles: [demSourceInstance.contourProtocolUrl({ + multiplier: 3.28084, + thresholds: contourThresholds, + })], + maxzoom: 16, }) - - // Insert below first symbol layer (above hillshade, below labels) + console.log("[CONTOUR] protocol URL:", demSourceInstance.contourProtocolUrl({ + multiplier: 3.28084, + thresholds: contourThresholds, + })) + console.log("[CONTOUR] source added:", !!map.getSource(CONTOUR_SOURCE)) let beforeId = undefined for (const layer of map.getStyle().layers) { - if (layer.type === 'symbol') { - beforeId = layer.id - break - } + if (layer.type === "symbol") { beforeId = layer.id; break } } - // Minor contours (40ft) — visible z11+ + // Line layer with theme-aware colors + // maplibre-contour level: 0 = minor, 1 = index (major) + const opacityMod = c.opacityMod ?? 1 map.addLayer({ - id: CONTOUR_MINOR, - type: 'line', - source: CONTOUR_SOURCE, - 'source-layer': 'contours', - minzoom: 11, - filter: ['==', ['get', 'tier'], 'minor'], + id: CONTOUR_LINE, type: "line", source: CONTOUR_SOURCE, + "source-layer": "contours", paint: { - 'line-color': c.minorColor, - 'line-opacity': c.minorOpacity * c.opacityMod, - 'line-width': ['interpolate', ['linear'], ['zoom'], 11, c.minorWidth.z11, 14, c.minorWidth.z14], + "line-color": [ + "match", ["get", "level"], + 1, c.indexColor, + c.minorColor + ], + "line-opacity": [ + "match", ["get", "level"], + 1, c.indexOpacity * opacityMod, + c.minorOpacity * opacityMod + ], + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 7, ["match", ["get", "level"], 1, c.indexWidth?.z4 ?? 1.2, c.minorWidth?.z11 ?? 0.5], + 11, ["match", ["get", "level"], 1, ((c.indexWidth?.z4 ?? 1.2) + (c.indexWidth?.z14 ?? 1.8)) / 2, ((c.minorWidth?.z11 ?? 0.5) + (c.minorWidth?.z14 ?? 1.0)) / 2], + 14, ["match", ["get", "level"], 1, c.indexWidth?.z14 ?? 1.8, c.minorWidth?.z14 ?? 1.0], + ], }, }, beforeId) - // Intermediate contours (200ft) — visible z8+ + // Label layer for index contours (level > 0) map.addLayer({ - id: CONTOUR_INTERMEDIATE, - type: 'line', - source: CONTOUR_SOURCE, - 'source-layer': 'contours', - minzoom: 8, - filter: ['==', ['get', 'tier'], 'intermediate'], - paint: { - 'line-color': c.intermediateColor, - 'line-opacity': c.intermediateOpacity * c.opacityMod, - 'line-width': ['interpolate', ['linear'], ['zoom'], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], - }, - }, beforeId) - - // Index contours (1000ft) — visible z4+ - map.addLayer({ - id: CONTOUR_INDEX, - type: 'line', - source: CONTOUR_SOURCE, - 'source-layer': 'contours', - minzoom: 4, - filter: ['==', ['get', 'tier'], 'index'], - paint: { - 'line-color': c.indexColor, - 'line-opacity': c.indexOpacity * c.opacityMod, - 'line-width': ['interpolate', ['linear'], ['zoom'], 4, c.indexWidth.z4, 14, c.indexWidth.z14], - }, - }, beforeId) - - // Elevation labels on index contours (z12+) - map.addLayer({ - id: CONTOUR_LABEL, - type: 'symbol', - source: CONTOUR_SOURCE, - 'source-layer': 'contours', - minzoom: 12, - filter: ['==', ['get', 'tier'], 'index'], + id: CONTOUR_LABEL, type: "symbol", source: CONTOUR_SOURCE, + "source-layer": "contours", + filter: [">", ["get", "level"], 0], layout: { - 'text-field': ['concat', ['to-string', ['get', 'elevation_ft']], "'"], - 'text-size': c.labelSize, - 'text-font': c.labelFont, - 'symbol-placement': 'line', - 'text-anchor': 'center', - 'symbol-spacing': 400, - 'text-max-angle': 30, - 'text-allow-overlap': false, + "symbol-placement": "line", + "text-size": c.labelSize ?? 10, + "text-field": ["concat", ["number-format", ["get", "ele"], {}], "'"], + "text-font": c.labelFont ?? ["Noto Sans Regular"], + "text-max-angle": 25, }, paint: { - 'text-color': c.labelColor, - 'text-halo-color': c.labelHaloColor, - 'text-halo-width': c.labelHaloWidth, - 'text-opacity': c.labelOpacity, + "text-color": c.labelColor, + "text-halo-color": c.labelHaloColor, + "text-halo-width": c.labelHaloWidth ?? 1.5, + "text-opacity": (c.labelOpacity ?? 0.85) * opacityMod, }, }) + console.log("[CONTOUR] layers added:", !!map.getLayer(CONTOUR_LINE), !!map.getLayer(CONTOUR_LABEL)) } /** Remove contour layers + source */ function removeContours(map) { if (!map) return if (map.getLayer(CONTOUR_LABEL)) map.removeLayer(CONTOUR_LABEL) - if (map.getLayer(CONTOUR_INDEX)) map.removeLayer(CONTOUR_INDEX) - if (map.getLayer(CONTOUR_INTERMEDIATE)) map.removeLayer(CONTOUR_INTERMEDIATE) - if (map.getLayer(CONTOUR_MINOR)) map.removeLayer(CONTOUR_MINOR) + if (map.getLayer(CONTOUR_LINE)) map.removeLayer(CONTOUR_LINE) if (map.getSource(CONTOUR_SOURCE)) map.removeSource(CONTOUR_SOURCE) } -/** Add TEST topographic contour overlay (blue color scheme) */ -function addContoursTest(map, themeId) { - if (!map || map.getSource(CONTOUR_TEST_SOURCE)) return - - const c = getOverlayConfig(themeId, 'contoursTest') - - map.addSource(CONTOUR_TEST_SOURCE, { - type: "vector", - url: "pmtiles:///tiles/contours-test.pmtiles", - }) - - let beforeId = undefined - for (const layer of map.getStyle().layers) { - if (layer.type === "symbol") { - beforeId = layer.id - break - } - } - - // Minor contours (40ft) — blue scheme - map.addLayer({ - id: CONTOUR_TEST_MINOR, - type: "line", - source: CONTOUR_TEST_SOURCE, - "source-layer": "contours", - minzoom: 11, - filter: ["==", ["get", "tier"], "minor"], - paint: { - "line-color": c.minorColor, - "line-opacity": c.minorOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14], - }, - }, beforeId) - - // Intermediate contours (200ft) - map.addLayer({ - id: CONTOUR_TEST_INTERMEDIATE, - type: "line", - source: CONTOUR_TEST_SOURCE, - "source-layer": "contours", - minzoom: 8, - filter: ["==", ["get", "tier"], "intermediate"], - paint: { - "line-color": c.intermediateColor, - "line-opacity": c.intermediateOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], - }, - }, beforeId) - - // Index contours (1000ft) - map.addLayer({ - id: CONTOUR_TEST_INDEX, - type: "line", - source: CONTOUR_TEST_SOURCE, - "source-layer": "contours", - minzoom: 4, - filter: ["==", ["get", "tier"], "index"], - paint: { - "line-color": c.indexColor, - "line-opacity": c.indexOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14], - }, - }, beforeId) - - // Labels - map.addLayer({ - id: CONTOUR_TEST_LABEL, - type: "symbol", - source: CONTOUR_TEST_SOURCE, - "source-layer": "contours", - minzoom: 12, - filter: ["==", ["get", "tier"], "index"], - layout: { - "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], ""], - "text-size": c.labelSize, - "text-font": c.labelFont, - "symbol-placement": "line", - "text-anchor": "center", - "symbol-spacing": 400, - "text-max-angle": 30, - "text-allow-overlap": false, - }, - paint: { - "text-color": c.labelColor, - "text-halo-color": c.labelHaloColor, - "text-halo-width": c.labelHaloWidth, - "text-opacity": c.labelOpacity, - }, - }) -} - -/** Remove TEST contour layers + source */ -function removeContoursTest(map) { - if (!map) return - if (map.getLayer(CONTOUR_TEST_LABEL)) map.removeLayer(CONTOUR_TEST_LABEL) - if (map.getLayer(CONTOUR_TEST_INDEX)) map.removeLayer(CONTOUR_TEST_INDEX) - if (map.getLayer(CONTOUR_TEST_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_INTERMEDIATE) - if (map.getLayer(CONTOUR_TEST_MINOR)) map.removeLayer(CONTOUR_TEST_MINOR) - if (map.getSource(CONTOUR_TEST_SOURCE)) map.removeSource(CONTOUR_TEST_SOURCE) -} - -/** Add TEST 10ft topographic contour overlay (green color scheme) */ -function addContoursTest10ft(map, themeId) { - if (!map || map.getSource(CONTOUR_TEST_10FT_SOURCE)) return - - const c = getOverlayConfig(themeId, 'contoursTest10ft') - - map.addSource(CONTOUR_TEST_10FT_SOURCE, { - type: "vector", - url: "pmtiles:///tiles/contours-test-10ft.pmtiles", - }) - - let beforeId = undefined - for (const layer of map.getStyle().layers) { - if (layer.type === "symbol") { - beforeId = layer.id - break - } - } - - // Minor contours (10ft) — green scheme - map.addLayer({ - id: CONTOUR_TEST_10FT_MINOR, - type: "line", - source: CONTOUR_TEST_10FT_SOURCE, - "source-layer": "contours", - minzoom: 11, - filter: ["==", ["get", "tier"], "minor"], - paint: { - "line-color": c.minorColor, - "line-opacity": c.minorOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14], - }, - }, beforeId) - - // Intermediate contours (50ft) — green scheme - map.addLayer({ - id: CONTOUR_TEST_10FT_INTERMEDIATE, - type: "line", - source: CONTOUR_TEST_10FT_SOURCE, - "source-layer": "contours", - minzoom: 8, - filter: ["==", ["get", "tier"], "intermediate"], - paint: { - "line-color": c.intermediateColor, - "line-opacity": c.intermediateOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], - }, - }, beforeId) - - // Index contours (250ft) — darker green - map.addLayer({ - id: CONTOUR_TEST_10FT_INDEX, - type: "line", - source: CONTOUR_TEST_10FT_SOURCE, - "source-layer": "contours", - minzoom: 4, - filter: ["==", ["get", "tier"], "index"], - paint: { - "line-color": c.indexColor, - "line-opacity": c.indexOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14], - }, - }, beforeId) - - // Elevation labels on index contours (z12+) - map.addLayer({ - id: CONTOUR_TEST_10FT_LABEL, - type: "symbol", - source: CONTOUR_TEST_10FT_SOURCE, - "source-layer": "contours", - minzoom: 12, - filter: ["==", ["get", "tier"], "index"], - layout: { - "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], "'"], - "text-size": c.labelSize, - "text-font": c.labelFont, - "symbol-placement": "line", - "text-anchor": "center", - "symbol-spacing": 400, - "text-max-angle": 30, - "text-allow-overlap": false, - }, - paint: { - "text-color": c.labelColor, - "text-halo-color": c.labelHaloColor, - "text-halo-width": c.labelHaloWidth, - "text-opacity": c.labelOpacity, - }, - }) -} - -/** Remove test 10ft contour layers + source */ -function removeContoursTest10ft(map) { - if (!map) return - if (map.getLayer(CONTOUR_TEST_10FT_LABEL)) map.removeLayer(CONTOUR_TEST_10FT_LABEL) - if (map.getLayer(CONTOUR_TEST_10FT_INDEX)) map.removeLayer(CONTOUR_TEST_10FT_INDEX) - if (map.getLayer(CONTOUR_TEST_10FT_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_10FT_INTERMEDIATE) - if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR) - if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE) -} /** Add USFS trails and roads vector tile overlay */ function addUsfsTrails(map, themeId) { if (!map || map.getSource(USFS_SOURCE)) return @@ -1247,10 +1065,191 @@ function removeBlmTrails(map) { } -/** Add boundary polygon layers using theme-aware highlight config */ +// ═══════════════════════════════════════════════════════════════════════════ +// SATELLITE IMAGERY +// ═══════════════════════════════════════════════════════════════════════════ + +/** Add satellite raster source (called once on map load) */ +function addSatelliteSource(map) { + if (!map || map.getSource(SATELLITE_SOURCE)) return + map.addSource(SATELLITE_SOURCE, { + type: 'raster', + tiles: ['/tiles/satellite/{z}/{x}/{y}'], + tileSize: 256, + maxzoom: 18, + attribution: '© Esri', + }) +} + +/** Add satellite raster layer with theme-specific styling */ +function addSatelliteLayer(map, themeId) { + if (!map) return + if (map.getLayer(SATELLITE_LAYER)) return + if (!map.getSource(SATELLITE_SOURCE)) { + addSatelliteSource(map) + } + + const theme = getTheme(themeId) + const sat = theme.satellite || {} + + // Find the first layer to insert below (we want satellite at the bottom) + const layers = map.getStyle().layers + let firstLayerId = layers.length > 0 ? layers[0].id : undefined + + map.addLayer({ + id: SATELLITE_LAYER, + type: 'raster', + source: SATELLITE_SOURCE, + paint: { + 'raster-opacity': sat.opacity ?? 1.0, + 'raster-brightness-min': sat.brightnessMin ?? 0.0, + 'raster-brightness-max': sat.brightnessMax ?? 1.0, + 'raster-contrast': sat.contrast ?? 0.0, + 'raster-saturation': sat.saturation ?? 0.0, + 'raster-hue-rotate': sat.hueRotate ?? 0, + }, + }, firstLayerId) +} + +/** Remove satellite raster layer */ +function removeSatelliteLayer(map) { + if (!map) return + if (map.getLayer(SATELLITE_LAYER)) { + map.removeLayer(SATELLITE_LAYER) + } +} + +/** Update satellite layer paint properties for current theme */ +function updateSatellitePaint(map, themeId) { + if (!map || !map.getLayer(SATELLITE_LAYER)) return + + const theme = getTheme(themeId) + const sat = theme.satellite || {} + + map.setPaintProperty(SATELLITE_LAYER, 'raster-opacity', sat.opacity ?? 1.0) + map.setPaintProperty(SATELLITE_LAYER, 'raster-brightness-min', sat.brightnessMin ?? 0.0) + map.setPaintProperty(SATELLITE_LAYER, 'raster-brightness-max', sat.brightnessMax ?? 1.0) + map.setPaintProperty(SATELLITE_LAYER, 'raster-contrast', sat.contrast ?? 0.0) + map.setPaintProperty(SATELLITE_LAYER, 'raster-saturation', sat.saturation ?? 0.0) + map.setPaintProperty(SATELLITE_LAYER, 'raster-hue-rotate', sat.hueRotate ?? 0) +} + +// Track which vector layers are hidden in satellite/hybrid mode +// Track hidden layers for each mode - separate arrays for proper restoration +let hiddenFillLayers = [] +let hiddenLineLayers = [] +let hiddenSymbolLayers = [] + +// Layers we never hide (our own overlays) +function isProtectedLayer(id) { + return id.startsWith('public-lands') || + id.startsWith('boundary') || + id.startsWith('route') || + id.startsWith('offroute') || + id.startsWith('measure') || + id.startsWith('contour') || + id.startsWith('usfs') || + id.startsWith('blm') || + id.startsWith('hillshade') || + id.startsWith('traffic') || + id === SATELLITE_LAYER +} + +/** Hide a layer and track it */ +function hideLayer(map, layerId, trackingArray) { + if (!map.getLayer(layerId)) return + const vis = map.getLayoutProperty(layerId, 'visibility') + if (vis !== 'none') { + trackingArray.push(layerId) + map.setLayoutProperty(layerId, 'visibility', 'none') + } +} + +/** Show all layers in a tracking array */ +function showLayers(map, trackingArray) { + for (const id of trackingArray) { + if (map.getLayer(id)) { + map.setLayoutProperty(id, 'visibility', 'visible') + } + } + trackingArray.length = 0 +} + +/** Set map to satellite-only mode - hide ALL vector layers except our overlays */ +function setSatelliteMode(map, themeId) { + if (!map) return + + // First restore any previously hidden layers to clean slate + showLayers(map, hiddenFillLayers) + showLayers(map, hiddenLineLayers) + showLayers(map, hiddenSymbolLayers) + + addSatelliteLayer(map, themeId) + + const style = map.getStyle() + if (!style?.layers) return + + for (const layer of style.layers) { + if (isProtectedLayer(layer.id)) continue + + if (layer.type === 'fill' || layer.type === 'fill-extrusion' || layer.type === 'background') { + hideLayer(map, layer.id, hiddenFillLayers) + } else if (layer.type === 'line') { + hideLayer(map, layer.id, hiddenLineLayers) + } else if (layer.type === 'symbol') { + hideLayer(map, layer.id, hiddenSymbolLayers) + } + } + + console.log('[Satellite] Hidden:', hiddenFillLayers.length, 'fills,', hiddenLineLayers.length, 'lines,', hiddenSymbolLayers.length, 'symbols') +} + +/** Set map to hybrid mode - satellite + roads + labels */ +function setHybridMode(map, themeId) { + if (!map) return + + // First restore any previously hidden layers to clean slate + showLayers(map, hiddenFillLayers) + showLayers(map, hiddenLineLayers) + showLayers(map, hiddenSymbolLayers) + + addSatelliteLayer(map, themeId) + + const style = map.getStyle() + if (!style?.layers) return + + // In hybrid: hide fills/background, keep lines and symbols visible + for (const layer of style.layers) { + if (isProtectedLayer(layer.id)) continue + + if (layer.type === 'fill' || layer.type === 'fill-extrusion' || layer.type === 'background') { + hideLayer(map, layer.id, hiddenFillLayers) + } + // Lines and symbols stay visible for hybrid mode + } + + console.log('[Hybrid] Hidden:', hiddenFillLayers.length, 'fills, keeping lines and symbols visible') +} + +/** Set map back to normal map mode */ +function setMapMode(map) { + if (!map) return + + removeSatelliteLayer(map) + + // Restore all hidden layers + showLayers(map, hiddenFillLayers) + showLayers(map, hiddenLineLayers) + showLayers(map, hiddenSymbolLayers) + + console.log('[Map] Restored all vector layers') +} + + +/** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */ const BOUNDARY_FILL_LAYER = 'boundary-fill-layer' -function addBoundaryLayer(map, themeId) { +function addBoundaryLayer(map) { if (!map || map.getLayer(BOUNDARY_LAYER)) return if (!map.getSource(BOUNDARY_SOURCE)) { map.addSource(BOUNDARY_SOURCE, { @@ -1258,14 +1257,7 @@ function addBoundaryLayer(map, themeId) { data: { type: "FeatureCollection", features: [] }, }) } - // Get highlight config from theme overlay - const highlight = getOverlayConfig(themeId, "highlight") || {} - const lineColor = highlight.lineColor || "#7a9a6b" - const lineWidth = highlight.lineWidth || 2 - const lineDash = highlight.lineDash || [4, 4] - const lineOpacity = highlight.lineOpacity || 0.8 - const fillColor = highlight.fillColor || lineColor - const fillOpacity = highlight.fillOpacity || 0.08 + const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b" // Find first symbol layer to insert boundary layers below labels const layers = map.getStyle().layers @@ -1283,8 +1275,8 @@ function addBoundaryLayer(map, themeId) { type: "fill", source: BOUNDARY_SOURCE, paint: { - "fill-color": fillColor, - "fill-opacity": fillOpacity, + "fill-color": accentColor, + "fill-opacity": 0.05, }, }, firstSymbolId) @@ -1294,14 +1286,148 @@ function addBoundaryLayer(map, themeId) { type: "line", source: BOUNDARY_SOURCE, paint: { - "line-color": lineColor, - "line-width": lineWidth, - "line-opacity": lineOpacity, - "line-dasharray": lineDash, + "line-color": accentColor, + "line-width": 2, + "line-opacity": 0.7, + "line-dasharray": [3, 2], }, }, firstSymbolId) } +/** + * FIX D: Add state/province boundary lines visible at z4-z7 + * These are administrative boundaries with kind_detail = 4 (state/province level) + * Uses theme-aware styling from the boundaries color + */ +function addStateBoundaries(map, themeId) { + if (!map || map.getLayer(STATE_BOUNDARIES_LAYER)) return + + // Get the boundaries color from the current theme + const theme = getTheme(themeId) + const boundaryColor = theme?.colors?.boundaries || '#808080' + + // Find first symbol layer to insert below labels + const layers = map.getStyle().layers + let firstSymbolId = null + for (const layer of layers) { + if (layer.type === 'symbol') { + firstSymbolId = layer.id + break + } + } + + // Add state/province boundaries layer for z4-z7 + // kind_detail 4 = state/province level administrative boundaries + map.addLayer({ + id: STATE_BOUNDARIES_LAYER, + type: 'line', + source: 'protomaps', + 'source-layer': 'boundaries', + filter: ['==', 'kind_detail', 4], + minzoom: 4, + maxzoom: 8, + paint: { + 'line-color': boundaryColor, + 'line-width': [ + 'interpolate', ['linear'], ['zoom'], + 4, 0.5, + 7, 1.0 + ], + 'line-opacity': [ + 'interpolate', ['linear'], ['zoom'], + 4, 0.4, + 7, 0.6 + ], + 'line-dasharray': [4, 2], + }, + }, firstSymbolId) +} + +/** Remove state boundaries layer */ +function removeStateBoundaries(map) { + if (!map) return + if (map.getLayer(STATE_BOUNDARIES_LAYER)) { + map.removeLayer(STATE_BOUNDARIES_LAYER) + } +} + + +/** Clear offroute display layers */ +function clearRouteDisplay(map) { + if (!map) return + if (map.getLayer(OFFROUTE_WILDERNESS_LAYER)) map.removeLayer(OFFROUTE_WILDERNESS_LAYER) + if (map.getLayer(OFFROUTE_NETWORK_LAYER)) map.removeLayer(OFFROUTE_NETWORK_LAYER) + if (map.getLayer(OFFROUTE_MARKERS_LAYER)) map.removeLayer(OFFROUTE_MARKERS_LAYER) + if (map.getSource(OFFROUTE_SOURCE)) map.removeSource(OFFROUTE_SOURCE) +} + +/** Update offroute display with route GeoJSON */ +function updateRouteDisplay(map, routeGeojson) { + if (!map || !routeGeojson) return + + // Clear existing layers + clearRouteDisplay(map) + + // Add source with route features + map.addSource(OFFROUTE_SOURCE, { + type: "geojson", + data: routeGeojson, + }) + + // Find first symbol layer for proper z-ordering + let beforeId = undefined + for (const layer of map.getStyle().layers) { + if (layer.type === "symbol") { + beforeId = layer.id + break + } + } + + // Wilderness segment - dashed orange line + map.addLayer({ + id: OFFROUTE_WILDERNESS_LAYER, + type: "line", + source: OFFROUTE_SOURCE, + filter: ["==", ["get", "segment_type"], "wilderness"], + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": "#f97316", // orange-500 + "line-width": 4, + "line-opacity": 0.9, + "line-dasharray": [8, 4], + }, + }, beforeId) + + // Network segment - solid blue line + map.addLayer({ + id: OFFROUTE_NETWORK_LAYER, + type: "line", + source: OFFROUTE_SOURCE, + filter: ["==", ["get", "segment_type"], "network"], + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": "#3b82f6", // blue-500 + "line-width": 5, + "line-opacity": 0.85, + }, + }, beforeId) + + // Fit bounds to route + const features = routeGeojson.features || [] + const allCoords = features + .filter(f => f.geometry?.coordinates) + .flatMap(f => f.geometry.coordinates) + + if (allCoords.length > 0) { + const bounds = allCoords.reduce( + (b, c) => b.extend(c), + new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) + ) + const leftPad = 420 + map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) + } +} + const MapView = forwardRef(function MapView(_, ref) { const mapRef = useRef(null) const mapInstance = useRef(null) @@ -1312,30 +1438,31 @@ const MapView = forwardRef(function MapView(_, ref) { const watchIdRef = useRef(null) const currentThemeRef = useRef('dark') // Track which overlay layers are currently active (for theme swap re-add) - const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false, usfsTrails: false, blmTrails: false }) + const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, usfsTrails: false, blmTrails: false }) // Flag to suppress map-click when a stop pin was clicked const pinClickedRef = useRef(false) const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState const hoveredFeatureRef = useRef(null) // for hover highlight const updateBoundaryRef = useRef(null) // boundary update function + const lastFlyTargetRef = useRef(null) // track last fly target to avoid re-flying on metadata updates // Refs for measurement state (accessible in click handlers) const measuringRef = useRef({ active: false, points: [] }) const measureLabelsRef = useRef([]) // HTML label elements - const stops = useStore((s) => s.stops) - const route = useStore((s) => s.route) const theme = useStore((s) => s.theme) const selectedPlace = useStore((s) => s.selectedPlace) const clickMarker = useStore((s) => s.clickMarker) const setClickMarker = useStore((s) => s.setClickMarker) const clearClickMarker = useStore((s) => s.clearClickMarker) - const gpsOrigin = useStore((s) => s.gpsOrigin) const geoPermission = useStore((s) => s.geoPermission) const setSheetState = useStore((s) => s.setSheetState) const setMapCenter = useStore((s) => s.setMapCenter) const pickingLocationFor = useStore((s) => s.pickingLocationFor) const setEditingContact = useStore((s) => s.setEditingContact) const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor) + const directionsMode = useStore((s) => s.directionsMode) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const pickingRouteField = useStore((s) => s.pickingRouteField) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) @@ -1505,14 +1632,7 @@ const MapView = forwardRef(function MapView(_, ref) { type: "geojson", data: { type: "FeatureCollection", features: [] }, }) - // Get highlight config from theme overlay - const highlight = getOverlayConfig(themeId, "highlight") || {} - const lineColor = highlight.lineColor || "#7a9a6b" - const lineWidth = highlight.lineWidth || 2 - const lineDash = highlight.lineDash || [4, 4] - const lineOpacity = highlight.lineOpacity || 0.8 - const fillColor = highlight.fillColor || lineColor - const fillOpacity = highlight.fillOpacity || 0.08 + const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b" map.addLayer({ id: MEASURE_LINE_LAYER, type: "line", @@ -1561,7 +1681,7 @@ const MapView = forwardRef(function MapView(_, ref) { const radialWedges = [ { - id: "directions-to", + id: "to-here", label: "To here", icon: ArrowDownLeft, onSelect: () => { @@ -1570,29 +1690,48 @@ const MapView = forwardRef(function MapView(_, ref) { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, } - useStore.getState().startDirections(place) + const { routeStart, setRouteStart, setRouteEnd, computeRoute, setDirectionsMode, geoPermission, userLocation } = useStore.getState() + + setRouteEnd(place) + setDirectionsMode(true) + + if (routeStart) { + computeRoute() + } else if (geoPermission === "granted" && userLocation) { + // Use GPS as origin fallback + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + computeRoute() + } + // If no origin and no GPS, directions panel opens and origin field auto-focuses }, }, { - id: "directions-from", + id: "from-here", label: "From here", icon: ArrowUpRight, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) - const { clearStops, addStop } = useStore.getState() - clearStops() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, } - addStop(place) - useStore.setState({ gpsOrigin: false }) + const { clearRoute, setRouteStart, routeEnd, computeRoute, setDirectionsMode } = useStore.getState() + clearRoute() + clearRouteDisplay(mapInstance.current) + setRouteStart(place) + setDirectionsMode(true) + + if (routeEnd) { + computeRoute() + } + // If no destination, directions panel opens and destination field auto-focuses }, }, { @@ -1601,25 +1740,33 @@ const MapView = forwardRef(function MapView(_, ref) { icon: Plus, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) - const { stops, addStop, clearStops } = useStore.getState() + const { addIntermediateStop, computeRoute, routeStart, routeEnd } = useStore.getState() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, } - if (stops.length === 0) { - addStop(place) - useStore.setState({ gpsOrigin: false }) - } else { - const success = addStop(place) - if (!success) { - toast("Maximum 10 stops reached") + const success = addIntermediateStop(place) + if (success) { + // If we have both origin and destination, recalculate route + if (routeStart && routeEnd) { + computeRoute() } + } else { + toast("Maximum 8 intermediate stops reached") } }, }, + { + id: "clear-route", + label: "Clear", + icon: Trash2, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + useStore.getState().clearRoute() + clearRouteDisplay(mapInstance.current) + }, + }, { id: "save-place", label: "Save", @@ -1741,30 +1888,6 @@ const MapView = forwardRef(function MapView(_, ref) { removeContours(map) activeLayersRef.current.contours = false }, - addContoursTestLayer() { - const map = mapInstance.current - if (!map) return - addContoursTest(map, currentThemeRef.current) - activeLayersRef.current.contoursTest = true - }, - removeContoursTestLayer() { - const map = mapInstance.current - if (!map) return - removeContoursTest(map) - activeLayersRef.current.contoursTest = false - }, - addContoursTest10ftLayer() { - const map = mapInstance.current - if (!map) return - addContoursTest10ft(map, currentThemeRef.current) - activeLayersRef.current.contoursTest10ft = true - }, - removeContoursTest10ftLayer() { - const map = mapInstance.current - if (!map) return - removeContoursTest10ft(map) - activeLayersRef.current.contoursTest10ft = false - }, addUsfsTrailsLayer() { const map = mapInstance.current if (!map) return @@ -1790,6 +1913,34 @@ const MapView = forwardRef(function MapView(_, ref) { activeLayersRef.current.blmTrails = false }, + // View mode functions + setViewMode(mode) { + const map = mapInstance.current + if (!map) return + + if (mode === 'satellite') { + setSatelliteMode(map, currentThemeRef.current) + } else if (mode === 'hybrid') { + setHybridMode(map, currentThemeRef.current) + } else { + setMapMode(map) + } + }, + + updateSatelliteTheme() { + const map = mapInstance.current + if (!map) return + updateSatellitePaint(map, currentThemeRef.current) + }, + + // Clear offroute route from map + clearRoute() { + const map = mapInstance.current + if (!map) return + clearRouteDisplay(map) + useStore.getState().clearRoute() + }, + })) // Initialize map @@ -1798,6 +1949,21 @@ const MapView = forwardRef(function MapView(_, ref) { maplibregl.addProtocol('pmtiles', protocol.tile) const config = getConfig() + + // Initialize DemSource for maplibre-contour (uses same PMTiles as hillshade) + if (!demSourceInstance) { + const hs = config?.tileset_hillshade + if (hs?.url) { + demSourceInstance = new mlcontour.DemSource({ + url: `pmtiles://${window.location.origin}${hs.url}`, + encoding: hs.encoding || 'terrarium', + maxzoom: hs.max_zoom || 12, + worker: true, + cacheSize: 100, + }) + demSourceInstance.setupMaplibre(maplibregl) + } + } const DEFAULT_CENTER = config?.defaults?.center ? [config.defaults.center[1], config.defaults.center[0]] // config is [lat,lon], MapLibre wants [lon,lat] : [-114.6066, 42.5736] @@ -1864,7 +2030,33 @@ const MapView = forwardRef(function MapView(_, ref) { return } - + // Handle explicit pick-from-map mode for route inputs + const { pickingRouteField, setRouteStart, setRouteEnd, clearPickingRouteField } = useStore.getState() + if (pickingRouteField) { + const { lng, lat } = e.lngLat + map.getCanvas().style.cursor = '' + // Reverse geocode for name + fetchReverse(lat, lng).then((place) => { + const name = place?.name || lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (pickingRouteField === "origin") { + setRouteStart(location) + } else if (pickingRouteField === "destination") { + setRouteEnd(location) + } + clearPickingRouteField() + }).catch(() => { + const name = lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (pickingRouteField === "origin") { + setRouteStart(location) + } else if (pickingRouteField === "destination") { + setRouteEnd(location) + } + clearPickingRouteField() + }) + return + } const store = useStore.getState() const marker = store.clickMarker @@ -1905,23 +2097,31 @@ const MapView = forwardRef(function MapView(_, ref) { }) } } else { - // Outside circle → deselect, no new selection + // Outside circle → clear current selection and fall through to select new store.clearClickMarker() store.clearSelectedPlace() // Clear boundary when deselecting if (updateBoundaryRef.current) updateBoundaryRef.current(null) setSelectedHighlight(map, null) + // Fall through to State A to select new feature at click point } - } else { - // State A: nothing selected → select + } + + // Select new feature at click point (State A or after clearing previous selection) + { + const store = useStore.getState() // refresh store state after potential clear + if (store.clickMarker) return // already handled above + if (window.innerWidth < 768) setSheetState('collapsed') const { lng, lat } = e.lngLat const MARKER_RADIUS_PX = 14 // half of 28px preview marker // Check for USFS trails/roads click (show info popup) - const usfsLayers = [USFS_TRAILS_HIT, USFS_ROADS_HIT] - const usfsFeatures = map.queryRenderedFeatures(e.point, { layers: usfsLayers }) + const usfsLayers = [USFS_TRAILS_HIT, USFS_ROADS_HIT].filter(id => map.getLayer(id)) + const usfsFeatures = usfsLayers.length > 0 + ? map.queryRenderedFeatures(e.point, { layers: usfsLayers }) + : [] const usfsFeature = usfsFeatures.find(f => f.properties) if (usfsFeature && hasFeature('has_usfs_trails')) { const props = usfsFeature.properties @@ -1967,8 +2167,10 @@ const MapView = forwardRef(function MapView(_, ref) { } // Check for BLM routes click (show info popup) - const blmLayers = [BLM_ROUTES_HIT] - const blmFeatures = map.queryRenderedFeatures(e.point, { layers: blmLayers }) + const blmLayers = [BLM_ROUTES_HIT].filter(id => map.getLayer(id)) + const blmFeatures = blmLayers.length > 0 + ? map.queryRenderedFeatures(e.point, { layers: blmLayers }) + : [] const blmFeature = blmFeatures.find(f => f.properties) if (blmFeature && hasFeature("has_blm_trails")) { const props = blmFeature.properties @@ -2022,12 +2224,35 @@ const MapView = forwardRef(function MapView(_, ref) { const props = labelFeature.properties const geom = labelFeature.geometry - // Get feature coordinates (Point geometry) - let featureLat = lat - let featureLon = lng + // CRITICAL: Always use CLICK coordinates for routing (lat, lng from e.lngLat) + // Feature coordinates are only for display/fetching details + let featureLat = lat // Click coordinate - used for routing + let featureLon = lng // Click coordinate - used for routing + let displayLat = lat // May be updated to feature coords for display + let displayLon = lng if (geom && geom.type === 'Point' && geom.coordinates) { - featureLon = geom.coordinates[0] - featureLat = geom.coordinates[1] + // Store feature's canonical coords separately - NOT for routing + displayLon = geom.coordinates[0] + displayLat = geom.coordinates[1] + } + + // FIX A: For park-type features, also query polygon layers to get boundary geometry + const parkKinds = ['national_park', 'park', 'cemetery', 'protected_area', 'nature_reserve', 'forest', 'golf_course', 'wood', 'zoo', 'garden'] + let polygonGeometry = null + if (parkKinds.includes(props.kind)) { + // Query fill layers at the same point to find the polygon + const fillLayers = ['landuse_park', 'landuse_other'].filter(id => map.getLayer(id)) + if (fillLayers.length > 0) { + const fillFeatures = map.queryRenderedFeatures(e.point, { layers: fillLayers }) + // Find a polygon feature with matching name or at the same location + const matchingPolygon = fillFeatures.find(f => + f.properties?.name === props.name || + (f.geometry?.type === 'Polygon' || f.geometry?.type === 'MultiPolygon') + ) + if (matchingPolygon?.geometry) { + polygonGeometry = matchingPolygon.geometry + } + } } // Apply feature state highlight @@ -2048,6 +2273,12 @@ const MapView = forwardRef(function MapView(_, ref) { // For feature clicks, don't show pin marker store.clearClickMarker() + // If we found polygon geometry from the fill layer, use it as boundary directly + if (polygonGeometry && updateBoundaryRef.current) { + updateBoundaryRef.current(polygonGeometry) + } + + console.log('[TRACE-CLICK] Feature click setSelectedPlace:', { featureLat, featureLon, clickLat: lat, clickLng: lng, name: props.name }) store.setSelectedPlace({ lat: featureLat, lon: featureLon, @@ -2066,6 +2297,7 @@ const MapView = forwardRef(function MapView(_, ref) { kind: props.kind || null, kind_detail: props.kind_detail || null, elevation: props.elevation || null, + polygonGeometry: polygonGeometry || null, // Store polygon if found }, }) } else { @@ -2078,6 +2310,7 @@ const MapView = forwardRef(function MapView(_, ref) { circleRadiusPx: MARKER_RADIUS_PX, }) + console.log('[TRACE-CLICK] Reticle click setSelectedPlace:', { lat, lng }) store.setSelectedPlace({ lat, lon: lng, @@ -2128,6 +2361,17 @@ const MapView = forwardRef(function MapView(_, ref) { }) map.on('load', () => { + // Add satellite source (persists across view modes) + addSatelliteSource(map) + + // Restore view mode from localStorage + const savedViewMode = localStorage.getItem('navi-view-mode') || 'map' + if (savedViewMode === 'satellite') { + setSatelliteMode(map, currentThemeRef.current) + } else if (savedViewMode === 'hybrid') { + setHybridMode(map, currentThemeRef.current) + } + // Guard against double-mount in React strict mode if (!map.getSource(ROUTE_SOURCE)) { map.addSource(ROUTE_SOURCE, { @@ -2138,12 +2382,15 @@ const MapView = forwardRef(function MapView(_, ref) { // Boundary polygon layer for selected places if (!map.getLayer(BOUNDARY_LAYER)) { - addBoundaryLayer(map, currentThemeRef.current) + addBoundaryLayer(map) } // Apply improved base label styling for readability applyBaseLabelStyling(map) + // FIX D: Add state/province boundary lines at z4-z7 + addStateBoundaries(map, currentThemeRef.current) + // Restore overlay layers from localStorage prefs try { const raw = localStorage.getItem('navi-layer-prefs') @@ -2153,7 +2400,9 @@ const MapView = forwardRef(function MapView(_, ref) { addHillshade(map, currentThemeRef.current) activeLayersRef.current.hillshade = true } - if (prefs.traffic && hasFeature('has_traffic_overlay')) { + // Traffic tiles are auth-gated at the edge — don't re-add for an + // anonymous session (would 302 -> MapLibre retry loop). + if (prefs.traffic && hasFeature('has_traffic_overlay') && useStore.getState().auth.authenticated) { addTraffic(map, currentThemeRef.current) activeLayersRef.current.traffic = true } @@ -2194,7 +2443,7 @@ const MapView = forwardRef(function MapView(_, ref) { try { const coords = boundaryGeometry.type === 'Polygon' ? boundaryGeometry.coordinates[0] - : boundaryGeometry.coordinates.flat(1) + : boundaryGeometry.coordinates.flat(2) if (coords.length > 0) { let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity @@ -2204,11 +2453,18 @@ const MapView = forwardRef(function MapView(_, ref) { if (lat < minLat) minLat = lat if (lat > maxLat) maxLat = lat } - map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { - padding: 50, - duration: 700, - maxZoom: 16, - }) + // Validate bounds before fitting + if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && + minLng < maxLng && minLat < maxLat) { + // FIX B: ALWAYS fitBounds when boundary exists - zoom in OR out + // The boundary defines what the user should see + const bounds = [[minLng, minLat], [maxLng, maxLat]] + map.fitBounds(bounds, { + padding: 50, + duration: 700, + maxZoom: 16, + }) + } } } catch (e) { console.warn('fitBounds error:', e) @@ -2218,6 +2474,12 @@ const MapView = forwardRef(function MapView(_, ref) { updateBoundaryRef.current = updateBoundaryFn useStore.getState().setUpdateBoundary(updateBoundaryFn) + // Register route display callbacks for store.computeRoute() + useStore.getState().setRouteDisplayCallbacks( + (routeGeojson) => updateRouteDisplay(map, routeGeojson), + () => clearRouteDisplay(map) + ) + // POI/label hover affordance — cursor pointer + highlight const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace'] @@ -2347,31 +2609,39 @@ const MapView = forwardRef(function MapView(_, ref) { // Boundary polygon layer if (!map.getLayer(BOUNDARY_LAYER)) { - addBoundaryLayer(map, currentThemeRef.current) + addBoundaryLayer(map) } // Apply improved base label styling for readability applyBaseLabelStyling(map) + // FIX D: Re-add state boundaries with new theme colors + addStateBoundaries(map, currentThemeRef.current) + // Re-add active overlay layers if (activeLayersRef.current.hillshade) addHillshade(map, currentThemeRef.current) if (activeLayersRef.current.traffic) addTraffic(map, currentThemeRef.current) if (activeLayersRef.current.publicLands) addPublicLands(map, currentThemeRef.current) if (activeLayersRef.current.contours) addContours(map, currentThemeRef.current) - if (activeLayersRef.current.contoursTest) addContoursTest(map, currentThemeRef.current) - if (activeLayersRef.current.contoursTest10ft) addContoursTest10ft(map, currentThemeRef.current) if (activeLayersRef.current.usfsTrails) addUsfsTrails(map, currentThemeRef.current) if (activeLayersRef.current.blmTrails) addBlmTrails(map, currentThemeRef.current) + // Re-add satellite source and restore view mode + addSatelliteSource(map) + const savedViewMode = localStorage.getItem('navi-view-mode') || 'map' + if (savedViewMode === 'satellite') { + setSatelliteMode(map, currentThemeRef.current) + } else if (savedViewMode === 'hybrid') { + setHybridMode(map, currentThemeRef.current) + } + // Clear highlights on theme change (paint values will be re-stored on next interaction) clearAllHighlights(map) originalPaintValues = {} // Restore view - map.jumpTo({ center, zoom, bearing, pitch }) - // Re-render route if exists - const currentRoute = useStore.getState().route - if (currentRoute) updateRoute(map, currentRoute) + const currentRoute = useStore.getState().routeResult + if (currentRoute?.route) updateRouteDisplay(map, currentRoute.route) }) }, [theme]) @@ -2386,11 +2656,29 @@ const MapView = forwardRef(function MapView(_, ref) { previewMarkerRef.current = null } - if (!selectedPlace) return + if (!selectedPlace) { + lastFlyTargetRef.current = null + return + } - // Only fly to place if it came from search (not map-click which already centered) - if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { - map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) + // Track place identity - only fly on NEW place selection, not metadata updates + const placeKey = `${selectedPlace.lat}-${selectedPlace.lon}-${selectedPlace.name}` + if (placeKey === lastFlyTargetRef.current) { + // Same place, skip flyTo (this is just a metadata update) + } else { + lastFlyTargetRef.current = placeKey + + // FIX B: Camera behavior depends on source and whether boundary exists + // - map_click / basemap_label: NO camera movement (boundary fitBounds handles it if exists) + // - search results: fly to center, but DON'T change zoom (user chose their zoom) + if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { + // Search result - fly to center without changing zoom + // Note: if this place has a boundary, the boundary fitBounds will zoom appropriately + const currentZoom = map.getZoom() + map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: currentZoom, duration: 800 }) + } + // For map_click and basemap_label: do nothing to camera + // The boundary fitBounds will handle zooming if a boundary is fetched } // Different visual feedback based on mode @@ -2446,168 +2734,6 @@ const MapView = forwardRef(function MapView(_, ref) { return () => document.removeEventListener('keydown', handleKeyDown) }, [selectedPlace]) - // Update route polyline when route changes - useEffect(() => { - const map = mapInstance.current - if (!map) return - if (!map.isStyleLoaded()) { - const handler = () => updateRoute(map, route) - map.once('idle', handler) - return () => map.off('idle', handler) - } - updateRoute(map, route) - }, [route]) - - function updateRoute(map, routeData) { - if (!map) return - - // Remove old route layers - const style = map.getStyle() - if (style) { - for (const layer of style.layers) { - if (layer.id.startsWith(ROUTE_LAYER_PREFIX)) { - map.removeLayer(layer.id) - } - } - } - - if (!routeData || !routeData.legs) { - if (map.getSource(ROUTE_SOURCE)) { - map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] }) - } - return - } - - const features = [] - for (let i = 0; i < routeData.legs.length; i++) { - const leg = routeData.legs[i] - if (!leg.shape) continue - const coords = decodePolyline(leg.shape, 6) - features.push({ - type: 'Feature', - properties: { legIndex: i }, - geometry: { type: 'LineString', coordinates: coords }, - }) - } - - const source = map.getSource(ROUTE_SOURCE) - if (source) { - source.setData({ type: 'FeatureCollection', features }) - } else { - map.addSource(ROUTE_SOURCE, { - type: 'geojson', - data: { type: 'FeatureCollection', features }, - }) - } - - // Use CSS variable for route color (read computed value) - const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim() - - for (let i = 0; i < features.length; i++) { - const layerId = `${ROUTE_LAYER_PREFIX}${i}` - if (!map.getLayer(layerId)) { - map.addLayer({ - id: layerId, - type: 'line', - source: ROUTE_SOURCE, - filter: ['==', ['get', 'legIndex'], i], - layout: { 'line-join': 'round', 'line-cap': 'round' }, - paint: { - 'line-color': routeColor || '#7a9a6b', - 'line-width': 5, - 'line-opacity': 0.85, - }, - }) - } - } - - // Fit bounds to route - if (features.length > 0) { - const allCoords = features.flatMap((f) => f.geometry.coordinates) - const bounds = allCoords.reduce( - (b, c) => b.extend(c), - new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) - ) - // Single-panel: no floating detail - const leftPad = 420 // 360px panel + margin - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) - } - } - - // Update stop markers when stops change - useEffect(() => { - const map = mapInstance.current - if (!map) return - - // Remove old markers - for (const m of markersRef.current) m.remove() - markersRef.current = [] - if (popupRef.current) { - popupRef.current.remove() - popupRef.current = null - } - - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const indexOffset = hasGpsOrigin ? 1 : 0 - - stops.forEach((stop, i) => { - const displayIndex = i + indexOffset - const effectiveTotal = stops.length + indexOffset - - let pinClass = 'navi-pin navi-pin--intermediate' - if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin' - else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination' - - const label = String.fromCharCode(65 + Math.min(displayIndex, 25)) - - const el = document.createElement('div') - el.className = pinClass - el.textContent = label - - el.addEventListener('click', (e) => { - e.stopPropagation() - // Flag so the map-level click handler doesn't fire - pinClickedRef.current = true - if (popupRef.current) popupRef.current.remove() - const popup = new maplibregl.Popup({ offset: 20, closeButton: true }) - .setLngLat([stop.lon, stop.lat]) - .setHTML( - `
- ${stop.name} -
-
` - ) - .addTo(map) - - popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => { - useStore.getState().removeStop(stop.id) - popup.remove() - }) - popupRef.current = popup - }) - - const marker = new maplibregl.Marker({ element: el }) - .setLngLat([stop.lon, stop.lat]) - .addTo(map) - - markersRef.current.push(marker) - }) - - // If stops but no route yet, fit to stops - if (stops.length > 0 && !route) { - if (stops.length === 1) { - map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 }) - } else { - const bounds = stops.reduce( - (b, s) => b.extend([s.lon, s.lat]), - new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]) - ) - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 420, right: 60 } }) - } - } - }, [stops, route, gpsOrigin, geoPermission]) - - // ESC key handler for measurement mode useEffect(() => { const handleKeyDown = (e) => { @@ -2633,6 +2759,22 @@ const MapView = forwardRef(function MapView(_, ref) { } }, [pickingLocationFor]) + // Handle directions mode cursor + useEffect(() => { + const map = mapInstance.current + if (!map) return + if (directionsMode && activeDirectionsField) { + map.getCanvas().style.cursor = 'crosshair' + } else if (!measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + return () => { + if (map && !measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + } + }, [directionsMode, activeDirectionsField]) + // ESC key handler for location pick mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 2799a89..6f0a0ac 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,53 +1,62 @@ 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 { useStore, usePanelState } from '../store' import { hasFeature } from '../config' +import { useConfig } from '../hooks/useConfig' import SearchBar from './SearchBar' -import StopList from './StopList' -import ModeSelector from './ModeSelector' import ManeuverList from './ManeuverList' import ContactList from './ContactList' import { PlaceCard } from './PlaceCard' -import { requestOptimizedRoute } from '../api' +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 pendingDestination = useStore((s) => s.pendingDestination) const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) - const clearPendingDestination = useStore((s) => s.clearPendingDestination) - const stops = useStore((s) => s.stops) - const mode = useStore((s) => s.mode) - const route = useStore((s) => s.route) + const 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 setStops = useStore((s) => s.setStops) - const setRoute = useStore((s) => s.setRoute) - const setRouteError = useStore((s) => s.setRouteError) - const setRouteLoading = useStore((s) => s.setRouteLoading) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const pickingRouteField = useStore((s) => s.pickingRouteField) + const setPickingRouteField = useStore((s) => s.setPickingRouteField) + const clearRoute = useStore((s) => s.clearRoute) const sheetState = useStore((s) => s.sheetState) const setSheetState = useStore((s) => s.setSheetState) - const theme = useStore((s) => s.theme) - const themeOverride = useStore((s) => s.themeOverride) - const setThemeOverride = useStore((s) => s.setThemeOverride) - const gpsOrigin = useStore((s) => s.gpsOrigin) - const geoPermission = useStore((s) => s.geoPermission) const activeTab = useStore((s) => s.activeTab) const auth = useStore((s) => s.auth) const setActiveTab = useStore((s) => s.setActiveTab) + const directionsMode = useStore((s) => s.directionsMode) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) const panelState = usePanelState() const [isMobile, setIsMobile] = useState(false) - const [optimizing, setOptimizing] = useState(false) const sheetRef = useRef(null) const dragStartY = useRef(0) const dragStartState = useRef('half') - // Show contacts tab only if feature enabled AND user is authenticated const showContacts = hasFeature('has_contacts') && auth.authenticated + const cfg = useConfig() - // Responsive detection useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) check() @@ -55,61 +64,13 @@ export default function Panel({ onManeuverClick }) { return () => window.removeEventListener('resize', check) }, []) - // Auth handlers - const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } - const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } + // Auth URLs come from /api/config (config.auth.*); the literals are the + // current home-profile values, kept as fallback for an older backend that + // doesn't yet serve `auth`, or when FALLBACK_CONFIG is in use (offline). + // TODO(navi): add tests when test infra lands — see extraction #2 PR-C + const handleLogin = () => { window.location.href = cfg?.auth?.login_url ?? '/outpost.goauthentik.io/start?rd=%2F' } + const handleLogout = () => { window.location.href = cfg?.auth?.logout_url ?? 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } - // Optimize stops - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0) - - const handleOptimize = useCallback(async () => { - if (effectiveCount < 3 || optimizing) return - setOptimizing(true) - try { - const { userLocation } = useStore.getState() - let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon })) - if (hasGpsOrigin && userLocation) { - locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations] - } - const data = await requestOptimizedRoute(locations, mode) - if (data.trip) { - const wpOrder = hasGpsOrigin && userLocation - ? (data.trip.locations || []).slice(1) - : data.trip.locations - if (wpOrder && wpOrder.length === stops.length) { - const reordered = wpOrder.map((wp) => { - let closest = stops[0] - let minDist = Infinity - for (const s of stops) { - const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon) - if (d < minDist) { - minDist = d - closest = s - } - } - return closest - }) - const seen = new Set() - const unique = reordered.filter((s) => { - if (seen.has(s.id)) return false - seen.add(s.id) - return true - }) - if (unique.length === stops.length) { - setStops(unique) - } - } - setRoute(data.trip) - } - } catch (e) { - setRouteError(e.message) - } finally { - setOptimizing(false) - } - }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError]) - - // Mobile sheet drag handling const handleTouchStart = useCallback((e) => { dragStartY.current = e.touches[0].clientY dragStartState.current = sheetState @@ -127,21 +88,30 @@ export default function Panel({ onManeuverClick }) { } }, [setSheetState]) - const showOptimize = effectiveCount >= 3 + const handleClearRoute = () => { + clearRoute() + onClearRoute?.() + } - // Determine what to show based on panel state const showPreviewCard = panelState.startsWith('PREVIEW') - const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination - const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED' - const showEmptyState = panelState === 'IDLE' && !pendingDestination + const hasRoutePoints = routeStart || routeEnd + const showRouteSection = hasRoutePoints || routeResult || routeLoading + const showEmptyState = panelState === 'IDLE' && !hasRoutePoints - // Routes tab content - now state-driven - const routesContent = ( + // Show side panel place card when building route (either mode) and place is selected + const showSidePlaceCard = (directionsMode || showRouteSection) && selectedPlace + + const routesContent = directionsMode ? ( + // Directions mode: just the directions panel, place card is shown in side panel + { + setDirectionsMode(false) + onClearRoute?.() + }} /> + ) : ( <> - {/* Preview card when place is selected */} - {showPreviewCard && selectedPlace && ( + {showPreviewCard && selectedPlace && !showRouteSection && (
)} - {/* Route section with stops */} {showRouteSection && ( - <> -
- -
- -
- - {showOptimize && ( - - )} - {pendingDestination && stops.length === 0 && ( - - )} -
- - )} - - {/* Maneuvers when route is calculated */} - {showManeuvers && (route || routeLoading || routeError) && (
- +
+ + Route + + +
+ +
+
+ + + {routeStart?.name || 'Click pin to pick start'} + + +
+
+ + + {routeEnd?.name || 'Click pin to pick destination'} + + +
+
+ +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {routeMode !== 'auto' && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + +
)} - {/* Empty state */} {showEmptyState && (

Search or tap the map to explore

@@ -203,13 +229,13 @@ export default function Panel({ onManeuverClick }) { {showContacts && (
) - // Desktop: side panel (now 360px to accommodate PlaceCard) + // Side panel for place card during directions mode (desktop only) + const sidePlaceCardPanel = showSidePlaceCard && !isMobile && ( +
+
+ + {selectedPlace?.name || 'Place Info'} + + +
+ {/* Use PlaceCard in compact preview mode */} + +
+ ) + + // Mobile overlay for place card during directions mode + const mobilePlaceCardOverlay = showSidePlaceCard && isMobile && ( +
+
+ + {selectedPlace?.name || 'Place Info'} + + +
+
+ +
+
+ ) + if (!isMobile) { return ( -
- {header} - {content} -
+ <> +
+ {header} + {content} +
+ {sidePlaceCardPanel} + ) } - // Mobile: bottom sheet const sheetHeights = { collapsed: 'h-12', half: 'h-[45vh]', @@ -280,13 +370,12 @@ export default function Panel({ onManeuverClick }) { return (
- {/* Drag handle */}
{sheetState !== 'collapsed' && ( -
+
{header} {content} + {mobilePlaceCardOverlay}
)}
diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 8443e1f..84ecb6b 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -348,11 +348,13 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl // Reverse geocode to get OSM type/id if not present (e.g., basemap label clicks) useEffect(() => { if (!hasFeature('has_nominatim_details')) return - if (wikidataId) return // Prefer wikidata path for basemap features with wikidata - if (placeLat == null || placeLon == null) return if (osmType && osmId) return + if (placeLat == null || placeLon == null) return // Skip for dropped pins - they get reverse geocoded by MapView if (place?.source === 'map_click') return + // Don't reverse geocode if we already identified the entity from a label click + // The basemap label provides name, kind, wikidata - reverse geocode would return wrong entity + if (place?.source === 'basemap_label' && place?.raw?.kind) return const controller = new AbortController() fetchReverse(placeLat, placeLon).then((result) => { @@ -369,7 +371,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl } }) return () => controller.abort() - }, [wikidataId, placeLat, placeLon, osmType, osmId, place?.source]) + }, [placeLat, placeLon, osmType, osmId, place?.source]) useEffect(() => { @@ -394,6 +396,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl }, [osmType, osmId, placeLat, placeLon]) useEffect(() => { + if (osmType && osmId) return if (!wikidataId) return const controller = new AbortController() fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => { @@ -405,6 +408,16 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl osm_relation_id: data.osm_relation_id, extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags }, })) + // Set osm_type/osm_id from osm_relation_id to trigger Effect 3 (wiki summary fetch) + if (data?.osm_relation_id) { + const current = useStore.getState().selectedPlace + if (current && current.lat === placeLat && current.lon === placeLon) { + useStore.getState().setSelectedPlace({ + ...current, + raw: { ...current.raw, osm_type: 'R', osm_id: data.osm_relation_id } + }) + } + } if (data?.boundary) { const current = useStore.getState().selectedPlace if (current && current.lat === placeLat && current.lon === placeLon) { @@ -463,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 handleDirections = () => { + console.log('[TRACE-DIRECTIONS] PlaceCard handleDirections, place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) // No toast - empty origin slot is the visual prompt startDirections(place) } diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx index 2e47bd9..1215a08 100644 --- a/src/components/SearchBar.jsx +++ b/src/components/SearchBar.jsx @@ -6,6 +6,30 @@ import { buildAddress } from '../utils/place' import { searchGeocode } from '../api' import { hasFeature } from '../config' + +/** Parse coordinate input like "42.35, -114.30" or "42.35 -114.30" */ +function parseCoordinates(input) { + if (!input) return null + const trimmed = input.trim() + + // Pattern: lat, lon or lat lon (with optional comma) + // Supports: "42.35, -114.30", "42.35 -114.30", "42.35,-114.30" + const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ + const match = trimmed.match(pattern) + + if (!match) return null + + const lat = parseFloat(match[1]) + const lon = parseFloat(match[2]) + + // Validate ranges + if (isNaN(lat) || isNaN(lon)) return null + if (lat < -90 || lat > 90) return null + if (lon < -180 || lon > 180) return null + + return { lat, lon } +} + /** Get category icon based on result type/source */ function CategoryIcon({ result }) { const type = result.type || '' @@ -71,6 +95,25 @@ const SearchBar = forwardRef(function SearchBar(_, ref) { return } + // Check for coordinate input first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setAutocompleteOpen(true) + setSearchLoading(false) + return + } + // Prepend matching contacts let contactResults = [] if (hasFeature('has_contacts') && contacts.length > 0) { diff --git a/src/components/ThemePicker.jsx b/src/components/ThemePicker.jsx index b9f7792..15084a9 100644 --- a/src/components/ThemePicker.jsx +++ b/src/components/ThemePicker.jsx @@ -1,167 +1,176 @@ -import { useState, useRef, useEffect } from 'react' -import { Palette } from 'lucide-react' -import { themeList } from '../themes/registry' -import { useStore } from '../store' - -/** - * ThemeSwatch - Renders a circular swatch with 3 color segments - */ -function ThemeSwatch({ colors, size = 28, active = false }) { - // Split circle into 3 segments using conic gradient - const gradient = `conic-gradient( - ${colors[0]} 0deg 120deg, - ${colors[1]} 120deg 240deg, - ${colors[2]} 240deg 360deg - )` - - return ( -
- ) -} - -/** - * ThemePicker - Popover component for selecting themes - */ -export default function ThemePicker() { - const [isOpen, setIsOpen] = useState(false) - const theme = useStore((s) => s.theme) - const setThemeOverride = useStore((s) => s.setThemeOverride) - const triggerRef = useRef(null) - const popoverRef = useRef(null) - - const themes = themeList() - const currentTheme = themes.find(t => t.id === theme) || themes[0] - - // Handle click outside to close - useEffect(() => { - if (!isOpen) return - - function handleClickOutside(e) { - if ( - popoverRef.current && - !popoverRef.current.contains(e.target) && - triggerRef.current && - !triggerRef.current.contains(e.target) - ) { - setIsOpen(false) - } - } - - function handleEscape(e) { - if (e.key === 'Escape') { - setIsOpen(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - document.addEventListener('keydown', handleEscape) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - document.removeEventListener('keydown', handleEscape) - } - }, [isOpen]) - - const handleThemeSelect = (themeId) => { - setThemeOverride(themeId) - setIsOpen(false) - } - - return ( -
- {/* Trigger button */} - - - {/* Popover */} - {isOpen && ( -
-
- {themes.map((t) => ( - - ))} -
-
- )} -
- ) -} +import { useState, useRef, useEffect } from 'react' + +import { themeList } from '../themes/registry' +import { useStore } from '../store' + +/** + * ThemeSwatch - Renders a circular swatch with 3 color segments + */ +function ThemeSwatch({ colors, size = 28, active = false }) { + // Split circle into 3 segments using conic gradient + const gradient = `conic-gradient( + ${colors[0]} 0deg 120deg, + ${colors[1]} 120deg 240deg, + ${colors[2]} 240deg 360deg + )` + + return ( +
+ ) +} + +/** + * ThemePicker - Popover component for selecting themes + */ +export default function ThemePicker() { + const [isOpen, setIsOpen] = useState(false) + const theme = useStore((s) => s.theme) + const setThemeOverride = useStore((s) => s.setThemeOverride) + const triggerRef = useRef(null) + const popoverRef = useRef(null) + + const themes = themeList() + const currentTheme = themes.find(t => t.id === theme) || themes[0] + + // Handle click outside to close + useEffect(() => { + if (!isOpen) return + + function handleClickOutside(e) { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target) && + triggerRef.current && + !triggerRef.current.contains(e.target) + ) { + setIsOpen(false) + } + } + + function handleEscape(e) { + if (e.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen]) + + const handleThemeSelect = (themeId) => { + setThemeOverride(themeId) + setIsOpen(false) + } + + return ( +
+ {/* Trigger button - shows current theme name */} + + + {/* Popover */} + {isOpen && ( +
+
+ {themes.map((t) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/src/config.js b/src/config.js index 274edba..b5da749 100644 --- a/src/config.js +++ b/src/config.js @@ -10,7 +10,7 @@ const FALLBACK_CONFIG = { profile: 'home', region_name: 'North America', tileset: { - url: '/tiles/na.pmtiles', + url: '/tiles/planet/current.pmtiles', bounds: [-168, 14, -52, 72], max_zoom: 15, attribution: 'Protomaps © OSM', @@ -21,6 +21,10 @@ const FALLBACK_CONFIG = { address_book: '/api/address_book', valhalla: '/valhalla', }, + auth: { + login_url: '/outpost.goauthentik.io/start?rd=%2F', + logout_url: 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/', + }, features: { has_nominatim_details: false, has_kiwix_wiki: false, @@ -30,8 +34,6 @@ const FALLBACK_CONFIG = { has_landclass: false, has_public_lands_layer: false, has_contours: true, - has_contours_test: true, - has_contours_test_10ft: false, has_address_book_write: false, has_usfs_trails: false, has_blm_trails: false, diff --git a/src/index.css b/src/index.css index 6a2bd4c..2673a26 100644 --- a/src/index.css +++ b/src/index.css @@ -236,46 +236,117 @@ body { opacity: 1; } -/* ═══ LAYER CONTROL ═══ */ -.layer-control { +/* ═══ BOTTOM-RIGHT MAP CONTROLS ═══ */ +.map-controls-br { position: absolute; - bottom: 32px; + bottom: 80px; right: 10px; z-index: 10; + display: flex; + flex-direction: column; + gap: 8px; } -.layer-control-btn { - width: 36px; - height: 36px; +.map-control-btn { + width: 44px; + height: 44px; display: flex; align-items: center; justify-content: center; background: var(--bg-raised); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 10px; color: var(--text-secondary); cursor: pointer; box-shadow: var(--shadow); - transition: color 0.1s, border-color 0.1s; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.map-control-btn:hover { + color: var(--text-primary); + border-color: var(--accent); + background: var(--bg-overlay); +} + +.map-control-btn:active { + background: var(--bg-muted); +} + +/* ═══ LAYER CONTROL ═══ */ +.layer-control { + position: relative; +} + +.layer-control-btn { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text-secondary); + cursor: pointer; + box-shadow: var(--shadow); + transition: color 0.15s, border-color 0.15s, background 0.15s; } .layer-control-btn:hover { color: var(--text-primary); border-color: var(--accent); + background: var(--bg-overlay); +} + +.layer-control-btn:active { + background: var(--bg-muted); } .layer-control-popover { position: absolute; - bottom: 44px; - right: 0; - min-width: 160px; + bottom: 0; + right: 52px; + min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 10px; padding: 8px 0; box-shadow: var(--shadow-lg); } +.view-mode-control { + display: flex; + gap: 2px; + padding: 8px; + border-bottom: 1px solid var(--border-subtle); +} + +.view-mode-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; + transition: all 0.15s; +} + +.view-mode-btn:hover { + background: var(--bg-overlay); + color: var(--text-primary); +} + +.view-mode-btn.active { + background: var(--accent); + color: var(--text-inverse); +} + .layer-control-header { padding: 4px 12px 6px; font-size: var(--text-xs); @@ -367,27 +438,28 @@ body { /* ═══ LOCATE BUTTON ═══ */ .locate-btn { - position: absolute; - bottom: 80px; - right: 10px; - z-index: 10; - width: 36px; - height: 36px; + width: 44px; + height: 44px; display: flex; align-items: center; justify-content: center; background: var(--bg-raised); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 10px; color: var(--text-secondary); cursor: pointer; box-shadow: var(--shadow); - transition: color 0.1s, border-color 0.1s; + transition: color 0.15s, border-color 0.15s, background 0.15s; } .locate-btn:hover { color: var(--text-primary); border-color: var(--accent); + background: var(--bg-overlay); +} + +.locate-btn:active { + background: var(--bg-muted); } /* ═══ STOP REMOVE BUTTON (touch-friendly) ═══ */ @@ -406,16 +478,15 @@ body { overflow-x: hidden; } - .layer-control { - bottom: auto; - top: 120px; - right: 10px; + .map-controls-br { + bottom: 70px; + right: 8px; } - .locate-btn { - bottom: auto; - top: 166px; - right: 10px; + .layer-control-popover { + right: 52px; + max-height: 60vh; + overflow-y: auto; } .stop-remove-btn { @@ -501,3 +572,14 @@ body { line-height: 1.5; text-shadow: 0 0 2px rgba(0, 0, 0, 0.8); } + +/* ═══ MAPLIBRE CONTROL POSITIONING ═══ */ +.maplibregl-ctrl-bottom-right { + bottom: 24px; + right: 10px; +} + +.maplibregl-ctrl-bottom-right .maplibregl-ctrl-scale { + margin-right: 0; + margin-bottom: 0; +} diff --git a/src/store.js b/src/store.js index 9b4aa30..069be9f 100644 --- a/src/store.js +++ b/src/store.js @@ -1,150 +1,314 @@ -import { create } from 'zustand' - -export const useStore = create((set, get) => ({ - // ── Search state ── - query: '', - results: [], - searchLoading: false, - abortController: null, - - setQuery: (query) => set({ query }), - setResults: (results) => set({ results }), - setSearchLoading: (loading) => set({ searchLoading: loading }), - setAbortController: (ctrl) => set({ abortController: ctrl }), - - // ── Stop list ── - stops: [], - // Each stop: { id, lat, lon, name, source, matchCode, isOrigin } - - addStop: (stop) => { - const { stops } = get() - if (stops.length >= 10) return false - set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] }) - return true - }, - - removeStop: (id) => { - set({ stops: get().stops.filter((s) => s.id !== id) }) - }, - - reorderStops: (newStops) => set({ stops: newStops }), - - clearStops: () => set({ stops: [] }), - - setStops: (stops) => set({ stops }), - - // ── Geolocation ── - userLocation: null, // { lat, lon } - geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' - - setUserLocation: (loc) => set({ userLocation: loc }), - setGeoPermission: (p) => set({ geoPermission: p }), - - // ── Map viewport (for search bias) ── - mapCenter: null, // { lat, lon, zoom } - setMapCenter: (center) => set({ mapCenter: center }), - - // ── Mode ── - mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle' - setMode: (mode) => set({ mode }), - - // ── Route ── - route: null, // Valhalla response (trip object) - routeLoading: false, - routeError: null, - - setRoute: (route) => set({ route, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, route: null }), - clearRoute: () => set({ route: null, routeError: null }), - - // ── Place detail ── - selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? } - clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection - gpsOrigin: true, // whether GPS should be used as origin when available - pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) - - setSelectedPlace: (place) => set({ selectedPlace: place }), - - // Boundary rendering function - set by MapView, called by PlaceCard - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - startDirections: (place) => { - const { geoPermission, stops, addStop, clearStops } = get() - if (geoPermission === 'granted') { - clearStops() - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ gpsOrigin: true, selectedPlace: null }) - } else if (stops.length > 0) { - const origin = stops[0] - clearStops() - addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ selectedPlace: null }) - } else { - // GPS denied, no stops: set pendingDestination only; origin-picker will add both - set({ pendingDestination: place, selectedPlace: null }) - } - }, - - // ── UI state ── - sheetState: 'half', // 'collapsed' | 'half' | 'full' - panelOpen: true, - autocompleteOpen: false, - theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) - themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) - - setSheetState: (s) => set({ sheetState: s }), - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem('navi-theme-override', override) - } else { - localStorage.removeItem('navi-theme-override') - } - }, - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: 'routes', // 'routes' | 'contacts' - editingContact: null, // null=closed, {}=new, {id:N}=edit - pickingLocationFor: null, // form data while user picks location on map - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -// Returns string state, prioritizing preview to allow it alongside any route state -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.route - const hasStops = s.stops.length >= 1 - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasStops) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasStops) return "ROUTING" - return "IDLE" - }) -} +import { create } from "zustand" +import { requestOffroute } from "./api" + +export const useStore = create((set, get) => ({ + // ── Search state ── + query: "", + results: [], + searchLoading: false, + abortController: null, + + setQuery: (query) => set({ query }), + setResults: (results) => set({ results }), + setSearchLoading: (loading) => set({ searchLoading: loading }), + setAbortController: (ctrl) => set({ abortController: ctrl }), + + // ── Geolocation ── + userLocation: null, // { lat, lon } + geoPermission: "prompt", // "prompt" | "granted" | "denied" + + setUserLocation: (loc) => set({ userLocation: loc }), + setGeoPermission: (p) => set({ geoPermission: p }), + + // ── Map viewport (for search bias) ── + mapCenter: null, // { lat, lon, zoom } + setMapCenter: (center) => set({ mapCenter: center }), + + // ── Unified Route State ── + // routeStart = origin (source of truth) + // routeEnd = destination (source of truth) + // stops[] = ONLY intermediate waypoints (not origin/destination) + routeStart: null, // { lat, lon, name } + routeEnd: null, // { lat, lon, name } + stops: [], // Intermediate waypoints only: [{ id, lat, lon, name }, ...] + routeMode: "auto", // foot | mtb | atv | vehicle + boundaryMode: "strict", // strict | pragmatic | emergency + routeResult: null, // Response from /api/offroute + routeLoading: false, + routeError: null, + + // Map display callback - set by MapView + _updateRouteDisplay: null, + _clearRouteDisplay: null, + setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }), + + setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), + setRouteEnd: (place) => set({ routeEnd: place }), + setRouteResult: (result) => set({ routeResult: result, routeError: null }), + setRouteLoading: (loading) => set({ routeLoading: loading }), + setRouteError: (err) => set({ routeError: err, routeResult: null }), + + // Mode/boundary setters that trigger recalculation + setRouteMode: (mode) => { + set({ routeMode: mode }) + get().computeRoute() + }, + setBoundaryMode: (mode) => { + set({ boundaryMode: mode }) + get().computeRoute() + }, + + clearRoute: () => { + const { _clearRouteDisplay } = get() + if (_clearRouteDisplay) _clearRouteDisplay() + set({ + routeStart: null, + routeEnd: null, + stops: [], + routeResult: null, + routeError: null, + }) + }, + + // ── INTERMEDIATE STOPS MANAGEMENT ── + // stops[] contains ONLY intermediate waypoints, not origin/destination + + // Add intermediate stop - can be called with or without place + // With place: creates pre-filled stop (from radial menu) + // Without place: creates empty placeholder (from Add Stop button) + addIntermediateStop: (place) => { + const { stops } = get() + if (stops.length >= 8) return false // Max 8 intermediate stops + const newStop = { + id: crypto.randomUUID(), + lat: place?.lat ?? null, + lon: place?.lon ?? null, + name: place?.name ?? "", + } + set({ stops: [...stops, newStop] }) + return true + }, + + updateStop: (id, place) => { + const { stops } = get() + const newStops = stops.map((s) => + s.id === id ? { ...s, lat: place.lat, lon: place.lon, name: place.name } : s + ) + set({ stops: newStops }) + // Trigger route recalculation if all waypoints have coordinates + get().computeRoute() + }, + + removeStop: (id) => { + const { stops } = get() + const newStops = stops.filter((s) => s.id !== id) + set({ stops: newStops }) + // Recalculate route without this stop + get().computeRoute() + }, + + setStops: (stops) => set({ stops }), + + // ── UNIFIED ROUTING TRIGGER ── + // Handles both 2-point and multi-point routing + computeRoute: async () => { + const { routeStart, routeEnd, stops, routeMode, boundaryMode, _updateRouteDisplay } = get() + + // Need both endpoints to route + if (!routeStart || !routeEnd) return + + // Filter out incomplete stops (no coordinates yet) + const validStops = stops.filter((s) => s.lat != null && s.lon != null) + + // Build full waypoint list: [origin, ...intermediates, destination] + const waypoints = [ + routeStart, + ...validStops, + routeEnd, + ] + + console.log("[TRACE-ROUTE] computeRoute with waypoints:", waypoints.length, waypoints.map(w => w.name)) + + set({ routeLoading: true, routeError: null }) + + try { + if (waypoints.length === 2) { + // Simple 2-point routing + const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) + if (data.status === "ok" && data.route) { + set({ routeResult: data, routeError: null }) + if (_updateRouteDisplay) _updateRouteDisplay(data.route) + } else { + set({ routeError: data.message || data.error || "No route found", routeResult: null }) + } + } else { + // Multi-point routing: chain sequential 2-point routes and merge + const segments = [] + let totalDistanceKm = 0 + let totalEffortMinutes = 0 + let allFeatures = [] + + for (let i = 0; i < waypoints.length - 1; i++) { + const from = waypoints[i] + const to = waypoints[i + 1] + const segmentData = await requestOffroute(from, to, routeMode, boundaryMode) + + if (segmentData.status !== "ok" || !segmentData.route) { + throw new Error("No route found between " + (from.name || "waypoint") + " and " + (to.name || "waypoint")) + } + + segments.push(segmentData) + + // Accumulate totals + if (segmentData.summary) { + totalDistanceKm += segmentData.summary.total_distance_km || 0 + totalEffortMinutes += segmentData.summary.total_effort_minutes || 0 + } + + // Collect features + if (segmentData.route?.features) { + allFeatures.push(...segmentData.route.features) + } + } + + // Build merged result + const mergedResult = { + status: "ok", + summary: { + total_distance_km: totalDistanceKm, + total_effort_minutes: totalEffortMinutes, + waypoint_count: waypoints.length, + }, + route: { + type: "FeatureCollection", + features: allFeatures, + }, + } + + set({ routeResult: mergedResult, routeError: null }) + if (_updateRouteDisplay) _updateRouteDisplay(mergedResult.route) + } + } catch (e) { + set({ routeError: e.message, routeResult: null }) + } finally { + set({ routeLoading: false }) + } + }, + + // ── Legacy compatibility ── + gpsOrigin: true, + pendingDestination: null, + setGpsOrigin: (val) => set({ gpsOrigin: val }), + setPendingDestination: (place) => set({ pendingDestination: place }), + clearPendingDestination: () => set({ pendingDestination: null }), + + // Master startDirections - enters directions mode with destination pre-filled + startDirections: (place) => { + console.log("[TRACE-STORE] startDirections received place:", { lat: place?.lat, lon: place?.lon, name: place?.name }) + const { geoPermission, userLocation, clearRoute } = get() + clearRoute() + + const destination = { + lat: place.lat, + lon: place.lon, + name: place.name, + source: place.source, + matchCode: place.matchCode, + } + + let origin = null + if (geoPermission === "granted" && userLocation) { + origin = { + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + } + } + + set({ + routeEnd: destination, + routeStart: origin, + directionsMode: true, + activeDirectionsField: origin ? null : "origin", + selectedPlace: null, + }) + }, + + // ── Place detail ── + selectedPlace: null, + clickMarker: null, + + setSelectedPlace: (place) => set({ selectedPlace: place }), + updateBoundary: null, + setUpdateBoundary: (fn) => set({ updateBoundary: fn }), + clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), + setClickMarker: (marker) => set({ clickMarker: marker }), + clearClickMarker: () => set({ clickMarker: null }), + + // ── UI state ── + sheetState: "half", + panelOpen: true, + autocompleteOpen: false, + directionsMode: false, + activeDirectionsField: null, + pickingRouteField: null, + theme: "dark", + themeOverride: null, + viewMode: (typeof localStorage !== "undefined" && localStorage.getItem("navi-view-mode")) || "map", + + setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem("navi-view-mode", mode) + }, + setPanelOpen: (open) => set({ panelOpen: open }), + setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? "origin" : null }), + setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), + setPickingRouteField: (field) => set({ pickingRouteField: field }), + clearPickingRouteField: () => set({ pickingRouteField: null }), + setTheme: (theme) => set({ theme }), + setThemeOverride: (override) => { + set({ themeOverride: override }) + if (override) { + localStorage.setItem("navi-theme-override", override) + } else { + localStorage.removeItem("navi-theme-override") + } + }, + + // ── Auth state ── + auth: { authenticated: false, username: null, loaded: false }, + setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), + + // ── Contacts ── + contacts: [], + contactsLoaded: false, + activeTab: "routes", + editingContact: null, + pickingLocationFor: null, + + setContacts: (c) => set({ contacts: c, contactsLoaded: true }), + setActiveTab: (tab) => set({ activeTab: tab }), + setEditingContact: (c) => set({ editingContact: c }), + clearEditingContact: () => set({ editingContact: null }), + setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), + clearPickingLocationFor: () => set({ pickingLocationFor: null }), +})) + +// ── Panel state selector ── +export const usePanelState = () => { + return useStore((s) => { + const hasPreview = !!s.selectedPlace + const hasRoute = !!s.routeResult + const hasRoutePoints = !!s.routeStart || !!s.routeEnd + + if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" + if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" + if (hasPreview) return "PREVIEW" + if (hasRoute) return "ROUTE_CALCULATED" + if (hasRoutePoints) return "ROUTING" + return "IDLE" + }) +} diff --git a/src/themes/clean.js b/src/themes/clean.js index 2ed3b87..b13e52d 100644 --- a/src/themes/clean.js +++ b/src/themes/clean.js @@ -1,379 +1,369 @@ -/** - * Clean Theme for Navi - * - * A plain, familiar, Google Maps-inspired style focused on maximum usability. - * Clean, neutral, utilitarian. White/light gray land, soft pastel green parks, - * gentle blue water, classic gray→yellow→orange road hierarchy. No strong - * personality — everything serves readability and wayfinding. - * - * The theme equivalent of a rental car: nothing exciting, nothing wrong. - */ - -// ═══════════════════════════════════════════════════════════════════════════ -// PALETTE -// ═══════════════════════════════════════════════════════════════════════════ -// -// base: #f5f5f5 ← land, app background -// surface: #ffffff ← panels, cards, modals -// surfaceAlt: #f8f9fa ← secondary panels, hover states -// border: #dadce0 ← Google's standard border gray -// text: #202124 ← primary text (Google dark) -// textSecondary: #5f6368 ← secondary text -// textMuted: #9aa0a6 ← placeholders, hints -// accent: #1a73e8 ← Google blue — links, active states -// accentHover: #1557b0 ← darker blue hover -// success: #34a853 ← Google green -// warning: #fbbc04 ← Google yellow -// danger: #ea4335 ← Google red -// water: #aadaff ← soft sky blue (Google's water) -// waterDark: #73b3e8 ← water labels -// vegetation: #c3ecb2 ← pastel green parks -// forest: #a8dda0 ← slightly deeper green -// road: #ffffff ← minor roads — white -// roadPrimary: #fbc02d ← yellow -// roadMotorway: #f9a825 ← deeper yellow-orange -// roadCasing: #e0e0e0 ← light gray casing -// building: #e8e4de ← warm light gray -// contour: #c8b8a0 ← subtle warm brown -// -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Map flavor colors - protomaps-themes-base schema - * All 73 flat keys + pois + landcover nested objects - */ -const cleanColors = { - // Background & earth - background: '#e8e8e8', - earth: '#f5f5f5', - - // Land use areas - park_a: '#d4ecd0', - park_b: '#c3ecb2', - hospital: '#fde8e8', - industrial: '#ebeff1', - school: '#fff3e0', - wood_a: '#d8ecd4', - wood_b: '#a8dda0', - pedestrian: '#f0f0f0', - scrub_a: '#dcecd8', - scrub_b: '#c8e4c0', - glacier: '#f8fcff', - sand: '#f5f0e0', - beach: '#fef8e0', - aerodrome: '#eaecef', - runway: '#d0d0d0', - water: '#aadaff', - zoo: '#d8e8d8', - military: '#e8e8e8', - - // Tunnels - tunnel_other_casing: '#d8d8d8', - tunnel_minor_casing: '#d8d8d8', - tunnel_link_casing: '#d8d8d8', - tunnel_major_casing: '#d8d8d8', - tunnel_highway_casing: '#d8d8d8', - tunnel_other: '#e8e8e8', - tunnel_minor: '#e8e8e8', - tunnel_link: '#f0e0a0', - tunnel_major: '#f0e0a0', - tunnel_highway: '#f0d080', - - // Pier & buildings - pier: '#e0e0e0', - buildings: '#e8e4de', - - // Roads & casings - minor_service_casing: '#e0e0e0', - minor_casing: '#e0e0e0', - link_casing: '#d8c080', - major_casing_late: '#d8c080', - highway_casing_late: '#d8a860', - other: '#f0f0f0', - minor_service: '#ffffff', - minor_a: '#ffffff', - minor_b: '#ffffff', - link: '#fbc02d', - major_casing_early: '#d8c080', - major: '#fbc02d', - highway_casing_early: '#d8a860', - highway: '#f9a825', - railway: '#a0a0a0', - boundaries: '#c0c0c0', - - // Waterway label - waterway_label: '#73b3e8', - - // Bridges - bridges_other_casing: '#d0d0d0', - bridges_minor_casing: '#d0d0d0', - bridges_link_casing: '#d8c080', - bridges_major_casing: '#d8c080', - bridges_highway_casing: '#d8a860', - bridges_other: '#f0f0f0', - bridges_minor: '#ffffff', - bridges_link: '#fbc02d', - bridges_major: '#fbc02d', - bridges_highway: '#f9a825', - - // Labels - roads_label_minor: '#5f6368', - roads_label_minor_halo: '#ffffff', - roads_label_major: '#5f6368', - roads_label_major_halo: '#ffffff', - ocean_label: '#73b3e8', - peak_label: '#5f6368', - subplace_label: '#5f6368', - subplace_label_halo: '#ffffff', - city_label: '#202124', - city_label_halo: '#ffffff', - state_label: '#9aa0a6', - state_label_halo: '#ffffff', - country_label: '#5f6368', - address_label: '#5f6368', - address_label_halo: '#ffffff', - - // POI icon colors - pois: { - blue: '#1a73e8', - green: '#34a853', - lapis: '#4285f4', - pink: '#e91e63', - red: '#ea4335', - slategray: '#5f6368', - tangerine: '#f9a825', - turquoise: '#00bcd4', - }, - - // Landcover fill colors - landcover: { - grassland: 'rgba(200, 232, 192, 1)', - barren: 'rgba(240, 235, 220, 1)', - urban_area: 'rgba(235, 235, 235, 1)', - farmland: 'rgba(216, 240, 210, 1)', - glacier: 'rgba(250, 252, 255, 1)', - scrub: 'rgba(220, 236, 216, 1)', - forest: 'rgba(180, 224, 176, 1)', - }, -} - -/** - * UI CSS custom properties - app chrome styling - * Clean Google-inspired white panels with standard gray text - */ -const cleanUI = { - // Fonts - '--font-sans': "'Inter', system-ui, -apple-system, sans-serif", - '--font-mono': "'JetBrains Mono', ui-monospace, monospace", +/** + * Clean Theme for Navi + * + * A plain, familiar, Google Maps-inspired style focused on maximum usability. + * Clean, neutral, utilitarian. White/light gray land, soft pastel green parks, + * gentle blue water, classic gray→yellow→orange road hierarchy. No strong + * personality — everything serves readability and wayfinding. + * + * The theme equivalent of a rental car: nothing exciting, nothing wrong. + */ + +// ═══════════════════════════════════════════════════════════════════════════ +// PALETTE +// ═══════════════════════════════════════════════════════════════════════════ +// +// base: #f5f5f5 ← land, app background +// surface: #ffffff ← panels, cards, modals +// surfaceAlt: #f8f9fa ← secondary panels, hover states +// border: #dadce0 ← Google's standard border gray +// text: #202124 ← primary text (Google dark) +// textSecondary: #5f6368 ← secondary text +// textMuted: #9aa0a6 ← placeholders, hints +// accent: #1a73e8 ← Google blue — links, active states +// accentHover: #1557b0 ← darker blue hover +// success: #34a853 ← Google green +// warning: #fbbc04 ← Google yellow +// danger: #ea4335 ← Google red +// water: #aadaff ← soft sky blue (Google's water) +// waterDark: #73b3e8 ← water labels +// vegetation: #c3ecb2 ← pastel green parks +// forest: #a8dda0 ← slightly deeper green +// road: #ffffff ← minor roads — white +// roadPrimary: #fbc02d ← yellow +// roadMotorway: #f9a825 ← deeper yellow-orange +// roadCasing: #e0e0e0 ← light gray casing +// building: #e8e4de ← warm light gray +// contour: #c8b8a0 ← subtle warm brown +// +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const cleanColors = { + // Background & earth + background: '#e8e8e8', + earth: '#f5f5f5', + + // Land use areas + park_a: '#d4ecd0', + park_b: '#c3ecb2', + hospital: '#fde8e8', + industrial: '#ebeff1', + school: '#fff3e0', + wood_a: '#d8ecd4', + wood_b: '#a8dda0', + pedestrian: '#f0f0f0', + scrub_a: '#dcecd8', + scrub_b: '#c8e4c0', + glacier: '#f8fcff', + sand: '#f5f0e0', + beach: '#fef8e0', + aerodrome: '#eaecef', + runway: '#d0d0d0', + water: '#aadaff', + zoo: '#d8e8d8', + military: '#e8e8e8', + + // Tunnels + tunnel_other_casing: '#d8d8d8', + tunnel_minor_casing: '#d8d8d8', + tunnel_link_casing: '#d8d8d8', + tunnel_major_casing: '#d8d8d8', + tunnel_highway_casing: '#d8d8d8', + tunnel_other: '#e8e8e8', + tunnel_minor: '#e8e8e8', + tunnel_link: '#f0e0a0', + tunnel_major: '#f0e0a0', + tunnel_highway: '#f0d080', + + // Pier & buildings + pier: '#e0e0e0', + buildings: '#e8e4de', + + // Roads & casings + minor_service_casing: '#e0e0e0', + minor_casing: '#e0e0e0', + link_casing: '#d8c080', + major_casing_late: '#d8c080', + highway_casing_late: '#d8a860', + other: '#f0f0f0', + minor_service: '#ffffff', + minor_a: '#ffffff', + minor_b: '#ffffff', + link: '#fbc02d', + major_casing_early: '#d8c080', + major: '#fbc02d', + highway_casing_early: '#d8a860', + highway: '#f9a825', + railway: '#a0a0a0', + boundaries: '#c0c0c0', + + // Waterway label + waterway_label: '#73b3e8', + + // Bridges + bridges_other_casing: '#d0d0d0', + bridges_minor_casing: '#d0d0d0', + bridges_link_casing: '#d8c080', + bridges_major_casing: '#d8c080', + bridges_highway_casing: '#d8a860', + bridges_other: '#f0f0f0', + bridges_minor: '#ffffff', + bridges_link: '#fbc02d', + bridges_major: '#fbc02d', + bridges_highway: '#f9a825', + + // Labels + roads_label_minor: '#5f6368', + roads_label_minor_halo: '#ffffff', + roads_label_major: '#5f6368', + roads_label_major_halo: '#ffffff', + ocean_label: '#73b3e8', + peak_label: '#5f6368', + subplace_label: '#5f6368', + subplace_label_halo: '#ffffff', + city_label: '#202124', + city_label_halo: '#ffffff', + state_label: '#9aa0a6', + state_label_halo: '#ffffff', + country_label: '#5f6368', + address_label: '#5f6368', + address_label_halo: '#ffffff', + + // POI icon colors + pois: { + blue: '#1a73e8', + green: '#34a853', + lapis: '#4285f4', + pink: '#e91e63', + red: '#ea4335', + slategray: '#5f6368', + tangerine: '#f9a825', + turquoise: '#00bcd4', + }, + + // Landcover fill colors + landcover: { + grassland: 'rgba(200, 232, 192, 1)', + barren: 'rgba(240, 235, 220, 1)', + urban_area: 'rgba(235, 235, 235, 1)', + farmland: 'rgba(216, 240, 210, 1)', + glacier: 'rgba(250, 252, 255, 1)', + scrub: 'rgba(220, 236, 216, 1)', + forest: 'rgba(180, 224, 176, 1)', + }, +} + +/** + * UI CSS custom properties - app chrome styling + * Clean Google-inspired white panels with standard gray text + */ +const cleanUI = { + // Fonts + '--font-sans': "'Inter', system-ui, -apple-system, sans-serif", + '--font-mono': "'JetBrains Mono', ui-monospace, monospace", '--font-heading': "'Inter', system-ui, -apple-system, sans-serif", - // Backgrounds - '--bg-base': '#f5f5f5', - '--bg-raised': '#ffffff', - '--bg-overlay': '#ffffff', - '--bg-input': '#ffffff', - '--bg-inset': '#f0f0f0', - '--bg-muted': '#f8f9fa', - // Text - '--text-primary': '#202124', - '--text-secondary': '#5f6368', - '--text-tertiary': '#9aa0a6', - '--text-inverse': '#ffffff', - // Borders - '--border': '#dadce0', - '--border-subtle': '#e8eaed', - // Accent - '--accent': '#1a73e8', - '--accent-hover': '#1557b0', - '--accent-muted': '#e8f0fe', - // Tan - '--tan': '#f9a825', - '--tan-muted': '#fef7e0', - // Pins - '--pin-origin': '#34a853', - '--pin-destination': '#ea4335', - '--pin-intermediate': '#5f6368', - '--pin-stroke': '#ffffff', - // Status - '--status-success': '#34a853', - '--status-warning': '#fbbc04', - '--status-danger': '#ea4335', - '--success': '#34a853', - '--warning': '#fbbc04', - '--warning-muted': '#fef7e0', - // Route - '--route-line': '#1a73e8', - // Shadows - '--shadow': '0 1px 3px rgba(60, 64, 67, 0.15), 0 1px 2px rgba(60, 64, 67, 0.1)', - '--shadow-lg': '0 2px 6px rgba(60, 64, 67, 0.2), 0 1px 3px rgba(60, 64, 67, 0.15)', -} - -/** - * Overlay configuration overrides - * Light shadow hillshade, warm brown contours, standard public lands - */ -const cleanOverlay = { - // Hillshade - light and natural - hillshade: { - exaggeration: 0.4, - illuminationDirection: 315, - shadowColor: '#000000', - highlightColor: '#ffffff', - }, - - // Contours - warm brown, subtle - contours: { - opacityMod: 0.9, - minorColor: '#c8b8a0', - minorOpacity: 0.35, - minorWidth: { z11: 0.5, z14: 0.8 }, - intermediateColor: '#c8b8a0', - intermediateOpacity: 0.55, - intermediateWidth: { z8: 0.7, z14: 1.0 }, - indexColor: '#a89878', - indexOpacity: 0.75, - indexWidth: { z4: 1.0, z14: 1.5 }, - labelColor: '#8a7a60', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.8, - labelSize: 10, - labelFont: ['Noto Sans Regular'], - }, - - // Contours Test - blue variant - contoursTest: { - minorColor: '#5a9ab8', - intermediateColor: '#5a9ab8', - indexColor: '#3a7a98', - labelColor: '#3a6a88', - }, - - // Contours Test 10ft - green variant - contoursTest10ft: { - minorColor: '#4a9a5f', - intermediateColor: '#4a9a5f', - indexColor: '#2a7a4a', - labelColor: '#2a5a40', - }, - - // Public Lands - standard green tints with dark labels - publicLands: { - opacityMod: 0.9, - // Fill colors per category - fillWA: '#8a7a40', - fillNPS: '#4a8030', - fillUSFS: '#6a9040', - fillBLM: '#d4b880', - fillFWS: '#5a9068', - fillSTAT: '#6aa088', - fillLOC: '#9ab8a8', - fillDefault: '#b0b0b0', - // Fill opacities - fillOpacityWA: 0.25, - fillOpacityNPS: 0.25, - fillOpacityUSFS: 0.20, - fillOpacityBLM: 0.18, - fillOpacitySTAT: 0.22, - fillOpacityLOC: 0.18, - fillOpacityDefault: 0.12, - // Outline colors - outlineWA: '#6a5a28', - outlineNPS: '#2a5018', - outlineUSFS: '#4a6828', - outlineBLM: '#9a8050', - outlineFWS: '#3a6848', - outlineSTAT: '#4a7060', - outlineLOC: '#6a8070', - outlineDefault: '#808080', - // Outline opacities - outlineOpacityNPS: 0.65, - outlineOpacityUSFS: 0.55, - outlineOpacityDefault: 0.45, - // Outline width - outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, - // Labels - dark for readability - labelColor: '#2a3a28', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: { z10: 10, z14: 13 }, - labelFont: ['Noto Sans Regular'], - }, - - // USFS Trails - standard trail colors - usfsTrails: { - roadsColor: '#c09050', - roadsOpacity: 0.85, - roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, - trailsMotorized: '#e07030', - trailsBicycle: '#d0a030', - trailsHiker: '#50b040', - trailsDefault: '#b09050', - trailsOpacity: 0.85, - trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - trailsDash: [2, 1.5], - roadsLabelColor: '#5a4a30', - roadsLabelHaloColor: '#ffffff', - roadsLabelHaloWidth: 1.5, - roadsLabelOpacity: 0.85, - roadsLabelSize: 11, - trailsLabelColor: '#4a3a28', - trailsLabelHaloColor: '#ffffff', - trailsLabelHaloWidth: 1.5, - trailsLabelOpacity: 0.85, - trailsLabelSize: 11, - labelFont: ['Noto Sans Regular'], - hitWidth: 14, - }, - - // BLM Trails - standard route colors - blmTrails: { - color4wdHigh: '#e07030', - color4wdLow: '#d0a030', - colorAtv: '#d03030', - colorMotoSingle: '#a060b0', - color2wdLow: '#e0c060', - colorNonMech: '#50b040', - colorDefault: '#b09050', - colorSnow: '#6090c0', - lineOpacity: 0.85, - lineOpacityOther: 0.80, - lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - dashImproved: [4, 2], - dashAggregate: [1, 2], - dashSnow: [4, 2, 1, 2], - dashOther: [4, 2, 1, 2, 1, 2], - labelColor: '#4a3a28', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: 11, - labelFont: ['Noto Sans Regular'], - hitWidth: 14, - }, - - // ── Highlight (boundary/selection) ──────────────────────────────────────── - highlight: { - lineColor: "#1a73e8", // Google blue for selection - lineWidth: 2, - lineDash: [4, 4], - lineOpacity: 0.7, - fillColor: "#1a73e8", - fillOpacity: 0.06, - }, -} - -/** - * Clean theme configuration - */ -const cleanTheme = { - id: 'clean', - name: 'Clean', - dark: false, - colors: cleanColors, - satellite: null, // No adjustments — default clear view - overlay: cleanOverlay, - ui: cleanUI, -} - -export default cleanTheme + // Backgrounds + '--bg-base': '#f5f5f5', + '--bg-raised': '#ffffff', + '--bg-overlay': '#ffffff', + '--bg-input': '#ffffff', + '--bg-inset': '#f0f0f0', + '--bg-muted': '#f8f9fa', + // Text + '--text-primary': '#202124', + '--text-secondary': '#5f6368', + '--text-tertiary': '#9aa0a6', + '--text-inverse': '#ffffff', + // Borders + '--border': '#dadce0', + '--border-subtle': '#e8eaed', + // Accent + '--accent': '#1a73e8', + '--accent-hover': '#1557b0', + '--accent-muted': '#e8f0fe', + // Tan + '--tan': '#f9a825', + '--tan-muted': '#fef7e0', + // Pins + '--pin-origin': '#34a853', + '--pin-destination': '#ea4335', + '--pin-intermediate': '#5f6368', + '--pin-stroke': '#ffffff', + // Status + '--status-success': '#34a853', + '--status-warning': '#fbbc04', + '--status-danger': '#ea4335', + '--success': '#34a853', + '--warning': '#fbbc04', + '--warning-muted': '#fef7e0', + // Route + '--route-line': '#1a73e8', + // Shadows + '--shadow': '0 1px 3px rgba(60, 64, 67, 0.15), 0 1px 2px rgba(60, 64, 67, 0.1)', + '--shadow-lg': '0 2px 6px rgba(60, 64, 67, 0.2), 0 1px 3px rgba(60, 64, 67, 0.15)', +} + +/** + * Overlay configuration overrides + * Light shadow hillshade, warm brown contours, standard public lands + */ +const cleanOverlay = { + // Hillshade - light and natural + hillshade: { + exaggeration: 0.4, + illuminationDirection: 315, + shadowColor: '#000000', + highlightColor: '#ffffff', + }, + + // Contours - warm brown, subtle + contours: { + opacityMod: 0.9, + minorColor: '#c8b8a0', + minorOpacity: 0.35, + minorWidth: { z11: 0.5, z14: 0.8 }, + intermediateColor: '#c8b8a0', + intermediateOpacity: 0.55, + intermediateWidth: { z8: 0.7, z14: 1.0 }, + indexColor: '#a89878', + indexOpacity: 0.75, + indexWidth: { z4: 1.0, z14: 1.5 }, + labelColor: '#8a7a60', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 10, + labelFont: ['Noto Sans Regular'], + }, + + // Contours Test - blue variant + contoursTest: { + minorColor: '#5a9ab8', + intermediateColor: '#5a9ab8', + indexColor: '#3a7a98', + labelColor: '#3a6a88', + }, + + // Contours Test 10ft - green variant + contoursTest10ft: { + minorColor: '#4a9a5f', + intermediateColor: '#4a9a5f', + indexColor: '#2a7a4a', + labelColor: '#2a5a40', + }, + + // Public Lands - standard green tints with dark labels + publicLands: { + opacityMod: 0.9, + // Fill colors per category + fillWA: '#8a7a40', + fillNPS: '#4a8030', + fillUSFS: '#6a9040', + fillBLM: '#d4b880', + fillFWS: '#5a9068', + fillSTAT: '#6aa088', + fillLOC: '#9ab8a8', + fillDefault: '#b0b0b0', + // Fill opacities + fillOpacityWA: 0.25, + fillOpacityNPS: 0.25, + fillOpacityUSFS: 0.20, + fillOpacityBLM: 0.18, + fillOpacitySTAT: 0.22, + fillOpacityLOC: 0.18, + fillOpacityDefault: 0.12, + // Outline colors + outlineWA: '#6a5a28', + outlineNPS: '#2a5018', + outlineUSFS: '#4a6828', + outlineBLM: '#9a8050', + outlineFWS: '#3a6848', + outlineSTAT: '#4a7060', + outlineLOC: '#6a8070', + outlineDefault: '#808080', + // Outline opacities + outlineOpacityNPS: 0.65, + outlineOpacityUSFS: 0.55, + outlineOpacityDefault: 0.45, + // Outline width + outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, + // Labels - dark for readability + labelColor: '#2a3a28', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: { z10: 10, z14: 13 }, + labelFont: ['Noto Sans Regular'], + }, + + // USFS Trails - standard trail colors + usfsTrails: { + roadsColor: '#c09050', + roadsOpacity: 0.85, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + trailsMotorized: '#e07030', + trailsBicycle: '#d0a030', + trailsHiker: '#50b040', + trailsDefault: '#b09050', + trailsOpacity: 0.85, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + roadsLabelColor: '#5a4a30', + roadsLabelHaloColor: '#ffffff', + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.85, + roadsLabelSize: 11, + trailsLabelColor: '#4a3a28', + trailsLabelHaloColor: '#ffffff', + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.85, + trailsLabelSize: 11, + labelFont: ['Noto Sans Regular'], + hitWidth: 14, + }, + + // BLM Trails - standard route colors + blmTrails: { + color4wdHigh: '#e07030', + color4wdLow: '#d0a030', + colorAtv: '#d03030', + colorMotoSingle: '#a060b0', + color2wdLow: '#e0c060', + colorNonMech: '#50b040', + colorDefault: '#b09050', + colorSnow: '#6090c0', + lineOpacity: 0.85, + lineOpacityOther: 0.80, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + labelColor: '#4a3a28', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: 11, + labelFont: ['Noto Sans Regular'], + hitWidth: 14, + }, +} + +/** + * Clean theme configuration + */ +const cleanTheme = { + id: 'clean', + name: 'Clean', + dark: false, + colors: cleanColors, + satellite: null, // No adjustments — default clear view + overlay: cleanOverlay, + ui: cleanUI, +} + +export default cleanTheme diff --git a/src/themes/cyberpunk.js b/src/themes/cyberpunk.js index d5e09c4..8abaadb 100644 --- a/src/themes/cyberpunk.js +++ b/src/themes/cyberpunk.js @@ -230,22 +230,22 @@ const cyberpunkOverlay = { // Contours - very subtle dark purple-gray contours: { - opacityMod: 0.5, - minorColor: '#1e1e3e', - minorOpacity: 0.3, - minorWidth: { z11: 0.4, z14: 0.8 }, - intermediateColor: '#2a2a4a', - intermediateOpacity: 0.4, - intermediateWidth: { z8: 0.6, z14: 1.0 }, - indexColor: '#3a3a5a', - indexOpacity: 0.5, - indexWidth: { z4: 0.8, z14: 1.2 }, - labelColor: '#5a5a7a', - labelHaloColor: '#0a0a14', + opacityMod: 1.0, + minorColor: "#1a5566", + minorOpacity: 0.5, + minorWidth: { z11: 0.5, z14: 1.0 }, + intermediateColor: "#2a7788", + intermediateOpacity: 0.65, + intermediateWidth: { z8: 0.8, z14: 1.2 }, + indexColor: "#3a99aa", + indexOpacity: 0.8, + indexWidth: { z4: 1.2, z14: 1.8 }, + labelColor: "#55ccdd", + labelHaloColor: "#0a0a14", labelHaloWidth: 1.5, - labelOpacity: 0.6, + labelOpacity: 0.85, labelSize: 10, - labelFont: ['Noto Sans Regular'], + labelFont: ["Noto Sans Regular"], }, // Contours Test - cyan variant @@ -368,16 +368,6 @@ const cyberpunkOverlay = { // Hit layer hitWidth: 14, }, - - // ── Highlight (boundary/selection) ──────────────────────────────────────── - highlight: { - lineColor: "#00f0ff", // Electric cyan for selection - lineWidth: 2, - lineDash: [4, 4], - lineOpacity: 0.9, - fillColor: "#00f0ff", - fillOpacity: 0.1, - }, } /** diff --git a/src/themes/nightops.js b/src/themes/nightops.js new file mode 100644 index 0000000..ee3c82d --- /dev/null +++ b/src/themes/nightops.js @@ -0,0 +1,329 @@ +/** + * Night Ops Theme for Navi + * + * Black and red tactical night operations display optimized for absolute + * darkness. Inspired by military cockpit instruments, submarine control + * rooms, and ship bridge displays designed for total darkness. + * + * Red preserves scotopic (dark-adapted) vision better than any other color. + * This is MORE aggressive than Tactical — zero ambient light, eyes fully + * dark-adapted, any non-red light is unacceptable. + * + * Red-on-black rules: + * - ONLY red and black. No green, no blue, no amber, no white, no gray. + * - Text is red on black, not white on black. + * - "Bright" means brighter red (#cc3333), "dim" means darker red (#551111). + * - Water is pure black — no blue tint whatsoever. + * - Vegetation is very dark red-brown, barely distinguishable from land. + * - The ONLY contrast axis is light-red to dark-red to black. + */ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const nightopsColors = { + // Background & earth - pure black with faint red + background: "#0a0000", + earth: "#080000", + + // Land use areas - very dark red-brown + park_a: "#120505", + park_b: "#150606", + hospital: "#100404", + industrial: "#0c0303", + school: "#100404", + wood_a: "#120505", + wood_b: "#150606", + pedestrian: "#0a0202", + scrub_a: "#100404", + scrub_b: "#120505", + glacier: "#0c0303", + sand: "#0c0303", + beach: "#100404", + aerodrome: "#0a0202", + runway: "#1a0808", + water: "#050000", + zoo: "#120505", + military: "#100404", + + // Tunnels - black casings + tunnel_other_casing: "#080000", + tunnel_minor_casing: "#080000", + tunnel_link_casing: "#080000", + tunnel_major_casing: "#080000", + tunnel_highway_casing: "#080000", + tunnel_other: "#150606", + tunnel_minor: "#150606", + tunnel_link: "#1a0808", + tunnel_major: "#2a0a0a", + tunnel_highway: "#3a1010", + + // Pier & buildings - very dark red-brown + pier: "#1a0808", + buildings: "#150606", + + // Roads & casings - red spectrum by brightness + minor_service_casing: "#080000", + minor_casing: "#080000", + link_casing: "#080000", + major_casing_late: "#080000", + highway_casing_late: "#080000", + other: "#1a0808", + minor_service: "#1a0808", + minor_a: "#2a0a0a", + minor_b: "#1a0808", + link: "#3a1010", + major_casing_early: "#080000", + major: "#551515", + highway_casing_early: "#080000", + highway: "#772222", + railway: "#150606", + boundaries: "#3a1010", + + // Waterway label - dim red on black water + waterway_label: "#551515", + + // Bridges - same red spectrum + bridges_other_casing: "#0a0202", + bridges_minor_casing: "#080000", + bridges_link_casing: "#080000", + bridges_major_casing: "#080000", + bridges_highway_casing: "#080000", + bridges_other: "#1a0808", + bridges_minor: "#2a0a0a", + bridges_link: "#3a1010", + bridges_major: "#551515", + bridges_highway: "#772222", + + // Labels - red with BLACK halos + roads_label_minor: "#441111", + roads_label_minor_halo: "#0a0000", + roads_label_major: "#551515", + roads_label_major_halo: "#0a0000", + ocean_label: "#551515", + peak_label: "#551515", + subplace_label: "#441111", + subplace_label_halo: "#0a0000", + city_label: "#cc3333", + city_label_halo: "#0a0000", + state_label: "#3a1010", + state_label_halo: "#0a0000", + country_label: "#551515", + address_label: "#441111", + address_label_halo: "#0a0000", + + // POI icon colors - ALL red spectrum, differentiated by brightness + pois: { + blue: "#551515", + green: "#882222", + lapis: "#441111", + pink: "#772222", + red: "#cc3333", + slategray: "#3a1010", + tangerine: "#882222", + turquoise: "#551515", + }, + + // Landcover fill colors - very dark red-brown + landcover: { + grassland: "rgba(18, 5, 5, 1)", + barren: "rgba(12, 3, 3, 1)", + urban_area: "rgba(10, 2, 2, 1)", + farmland: "rgba(15, 4, 4, 1)", + glacier: "rgba(12, 3, 3, 1)", + scrub: "rgba(15, 5, 5, 1)", + forest: "rgba(21, 6, 6, 1)", + }, +} + +/** + * UI CSS custom properties - red-on-black terminal + */ +const nightopsUI = { + "--font-sans": "'Inter', system-ui, -apple-system, sans-serif", + "--font-mono": "'JetBrains Mono', ui-monospace, monospace", + "--font-heading": "'Inter', system-ui, -apple-system, sans-serif", + "--bg-base": "#0a0000", + "--bg-raised": "#120000", + "--bg-overlay": "#1a0505", + "--bg-input": "#0c0202", + "--bg-inset": "#080000", + "--bg-muted": "#150505", + "--text-primary": "#cc3333", + "--text-secondary": "#882222", + "--text-tertiary": "#551515", + "--text-inverse": "#0a0000", + "--border": "#2a0a0a", + "--border-subtle": "#1a0606", + "--accent": "#cc3333", + "--accent-hover": "#dd4444", + "--accent-muted": "#2a0a0a", + "--tan": "#aa2222", + "--tan-muted": "#1a0606", + "--pin-origin": "#cc3333", + "--pin-destination": "#aa2222", + "--pin-intermediate": "#882222", + "--pin-stroke": "#0a0000", + "--status-success": "#883322", + "--status-warning": "#cc4422", + "--status-danger": "#ff2222", + "--success": "#883322", + "--warning": "#cc4422", + "--warning-muted": "#2a0a05", + "--route-line": "#cc3333", + "--shadow": "0 2px 8px rgba(0, 0, 0, 0.8)", + "--shadow-lg": "0 4px 16px rgba(0, 0, 0, 0.9)", +} + +/** + * Overlay configuration - monochrome red + */ +const nightopsOverlay = { + hillshade: { + exaggeration: 0.2, + illuminationDirection: 315, + shadowColor: "#000000", + highlightColor: "#0a0000", + }, + traffic: { + opacity: 0.4, + }, + contours: { + opacityMod: 0.8, + minorColor: "#2a0808", + minorOpacity: 0.5, + minorWidth: { z11: 0.5, z14: 1.0 }, + intermediateColor: "#3a1010", + intermediateOpacity: 0.6, + intermediateWidth: { z8: 0.8, z14: 1.2 }, + indexColor: "#441111", + indexOpacity: 0.8, + indexWidth: { z4: 1.2, z14: 1.8 }, + labelColor: "#551515", + labelHaloColor: "#0a0000", + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 10, + labelFont: ["Noto Sans Regular"], + }, + contoursTest: { + minorColor: "#2a0808", + intermediateColor: "#3a1010", + indexColor: "#441111", + labelColor: "#551515", + }, + contoursTest10ft: { + minorColor: "#1a0606", + intermediateColor: "#2a0808", + indexColor: "#3a1010", + labelColor: "#441111", + }, + publicLands: { + opacityMod: 0.4, + fillWA: "#150606", + fillNPS: "#120505", + fillUSFS: "#150606", + fillBLM: "#100404", + fillFWS: "#120505", + fillSTAT: "#150606", + fillLOC: "#100404", + fillDefault: "#0c0303", + fillOpacityWA: 0.20, + fillOpacityNPS: 0.20, + fillOpacityUSFS: 0.18, + fillOpacityBLM: 0.15, + fillOpacitySTAT: 0.18, + fillOpacityLOC: 0.15, + fillOpacityDefault: 0.10, + outlineWA: "#1a0808", + outlineNPS: "#1a0808", + outlineUSFS: "#1a0808", + outlineBLM: "#150606", + outlineFWS: "#1a0808", + outlineSTAT: "#1a0808", + outlineLOC: "#150606", + outlineDefault: "#150606", + outlineOpacityNPS: 0.5, + outlineOpacityUSFS: 0.4, + outlineOpacityDefault: 0.3, + outlineWidth: { z4: 0.3, z8: 0.6, z12: 0.9 }, + labelColor: "#551515", + labelHaloColor: "#0a0000", + labelHaloWidth: 1.5, + labelOpacity: 0.7, + labelSize: { z10: 10, z14: 12 }, + labelFont: ["Noto Sans Regular"], + }, + usfsTrails: { + roadsColor: "#3a1010", + roadsOpacity: 0.8, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + trailsMotorized: "#772222", + trailsBicycle: "#661818", + trailsHiker: "#551515", + trailsDefault: "#3a1010", + trailsOpacity: 0.8, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + roadsLabelColor: "#551515", + roadsLabelHaloColor: "#0a0000", + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.8, + roadsLabelSize: 11, + trailsLabelColor: "#551515", + trailsLabelHaloColor: "#0a0000", + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.8, + trailsLabelSize: 11, + labelFont: ["Noto Sans Regular"], + hitWidth: 14, + }, + blmTrails: { + color4wdHigh: "#772222", + color4wdLow: "#661818", + colorAtv: "#772222", + colorMotoSingle: "#661818", + color2wdLow: "#551515", + colorNonMech: "#551515", + colorDefault: "#3a1010", + colorSnow: "#661818", + lineOpacity: 0.8, + lineOpacityOther: 0.7, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + labelColor: "#551515", + labelHaloColor: "#0a0000", + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 11, + labelFont: ["Noto Sans Regular"], + hitWidth: 14, + }, +} + +const nightopsSatellite = { + opacity: 0.5, + brightnessMin: 0.0, + brightnessMax: 0.15, + contrast: 0.0, + saturation: -1.0, + hueRotate: 0, +} + +const nightopsTheme = { + id: "nightops", + name: "Night Ops", + dark: true, + swatch: ["#0a0000", "#cc3333", "#551515"], + fontImports: [], + colors: nightopsColors, + satellite: nightopsSatellite, + overlay: nightopsOverlay, + ui: nightopsUI, +} + +export default nightopsTheme diff --git a/src/themes/parchment.js b/src/themes/parchment.js new file mode 100644 index 0000000..03bc22d --- /dev/null +++ b/src/themes/parchment.js @@ -0,0 +1,374 @@ +/** + * Parchment Theme for Navi + * + * Medieval manuscript cartography. An ancient vellum map unrolled on a table — + * deep rich ultramarine water (not modern pale blue), warm aged parchment land, + * dark sepia ink labels, burnt sienna roads. Forests are olive-gold tints, not + * modern green. The feel of a map you'd find in a monastery scriptorium or a + * leather-bound explorer's journal. + * + * CUSTOM FONT: IM Fell English — a revival of a 1672 Fell typeface. + * Irregular, warm, distinctly pre-modern. Used for ALL text. + * + * Parchment rules: + * - Water is DEEP ultramarine (#1a3a6a), not modern pale blue + * - Land is warm parchment/vellum — aged, not white + * - Vegetation is olive-gold, NOT modern green + * - Roads are brown ink — darker = more important + * - Labels are dark sepia ink with parchment halos + * - Everything should feel handmade, warm, aged + */ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const parchmentColors = { + // Background & earth - warm parchment + background: "#c8b888", + earth: "#d8c8a0", + + // Land use areas - warm parchment variations + park_a: "#c8c088", + park_b: "#b8b078", + hospital: "#d8c8a0", + industrial: "#c8b890", + school: "#d0c098", + wood_a: "#a8a060", + wood_b: "#8a8848", + pedestrian: "#d0c098", + scrub_a: "#b8b070", + scrub_b: "#a8a060", + glacier: "#e0d8c0", + sand: "#d8c8a0", + beach: "#e0d0a8", + aerodrome: "#c8c0a0", + runway: "#b8a888", + water: "#1a3a6a", + zoo: "#c0b880", + military: "#c8b898", + + // Tunnels - parchment casings + tunnel_other_casing: "#b8a070", + tunnel_minor_casing: "#b8a070", + tunnel_link_casing: "#b8a070", + tunnel_major_casing: "#b8a070", + tunnel_highway_casing: "#b8a070", + tunnel_other: "#c8b898", + tunnel_minor: "#c8b898", + tunnel_link: "#b8a078", + tunnel_major: "#a89068", + tunnel_highway: "#988058", + + // Pier & buildings - warm stone + pier: "#c0a878", + buildings: "#c0a878", + + // Roads & casings - brown ink progression + minor_service_casing: "#b8a070", + minor_casing: "#b8a070", + link_casing: "#b8a070", + major_casing_late: "#b8a070", + highway_casing_late: "#b8a070", + other: "#a89068", + minor_service: "#a89068", + minor_a: "#9a8058", + minor_b: "#a89068", + link: "#8a6a3a", + major_casing_early: "#b8a070", + major: "#7a5a2a", + highway_casing_early: "#b8a070", + highway: "#6a3a1a", + railway: "#8a7050", + boundaries: "#8a6a3a", + + // Waterway label - parchment on dark water + waterway_label: "#c8b890", + + // Bridges - same brown ink colors + bridges_other_casing: "#c0a880", + bridges_minor_casing: "#b8a070", + bridges_link_casing: "#b8a070", + bridges_major_casing: "#b8a070", + bridges_highway_casing: "#b8a070", + bridges_other: "#a89068", + bridges_minor: "#9a8058", + bridges_link: "#8a6a3a", + bridges_major: "#7a5a2a", + bridges_highway: "#6a3a1a", + + // Labels - dark sepia ink with PARCHMENT halos + roads_label_minor: "#6a4a20", + roads_label_minor_halo: "#d8c8a0", + roads_label_major: "#5a3a10", + roads_label_major_halo: "#d8c8a0", + ocean_label: "#c8b890", + peak_label: "#5a4020", + subplace_label: "#6a4a20", + subplace_label_halo: "#d8c8a0", + city_label: "#2a1a0a", + city_label_halo: "#d8c8a0", + state_label: "#8a7050", + state_label_halo: "#d8c8a0", + country_label: "#5a4020", + address_label: "#6a4a20", + address_label_halo: "#d8c8a0", + + // POI icon colors - period-appropriate muted palette + pois: { + blue: "#1a3a6a", + green: "#4a6830", + lapis: "#2a4a7a", + pink: "#8a5040", + red: "#8b2500", + slategray: "#6a5a4a", + tangerine: "#8b4513", + turquoise: "#3a5a6a", + }, + + // Landcover fill colors - olive-gold tints, NOT modern green + landcover: { + grassland: "rgba(184, 176, 120, 1)", + barren: "rgba(200, 184, 136, 1)", + urban_area: "rgba(208, 192, 152, 1)", + farmland: "rgba(200, 184, 128, 1)", + glacier: "rgba(224, 216, 192, 1)", + scrub: "rgba(176, 168, 112, 1)", + forest: "rgba(138, 136, 72, 1)", + }, +} + +/** + * UI CSS custom properties - medieval manuscript aesthetic + * Warm parchment panels with dark sepia ink text + */ +const parchmentUI = { + // Fonts - IM Fell English for everything + "--font-sans": "'IM Fell English', serif", + "--font-mono": "'IM Fell English', serif", + "--font-heading": "'IM Fell English', serif", + // Backgrounds - warm parchment + "--bg-base": "#d8c8a0", + "--bg-raised": "#e4d8b8", + "--bg-overlay": "#ddd0a8", + "--bg-input": "#e8dcc0", + "--bg-inset": "#d0c090", + "--bg-muted": "#e0d4b0", + // Text - dark sepia ink + "--text-primary": "#2a1a0a", + "--text-secondary": "#5a4020", + "--text-tertiary": "#8a7050", + "--text-inverse": "#e8d8b8", + // Borders - aged paper edge + "--border": "#b8a070", + "--border-subtle": "#c8b890", + // Accent - saddle brown + "--accent": "#8b4513", + "--accent-hover": "#a05520", + "--accent-muted": "#e0d0b0", + // Tan - brown ink variants + "--tan": "#7a5a2a", + "--tan-muted": "#e8d8c0", + // Pins - brown ink and ultramarine + "--pin-origin": "#8b4513", + "--pin-destination": "#1a3a6a", + "--pin-intermediate": "#6a5a40", + "--pin-stroke": "#2a1a0a", + // Status - period-appropriate colors + "--status-success": "#4a6830", + "--status-warning": "#b8860b", + "--status-danger": "#8b2500", + "--success": "#4a6830", + "--warning": "#b8860b", + "--warning-muted": "#e8d8b8", + // Route - saddle brown + "--route-line": "#8b4513", + // Shadows - warm brown tinted, subtle + "--shadow": "0 2px 8px rgba(42, 26, 10, 0.15)", + "--shadow-lg": "0 4px 16px rgba(42, 26, 10, 0.20)", +} + +/** + * Overlay configuration - warm brown ink on parchment + */ +const parchmentOverlay = { + // Hillshade - warm dramatic terrain + hillshade: { + exaggeration: 0.6, + illuminationDirection: 315, + shadowColor: "#3a2a1a", + highlightColor: "#f0e8d8", + }, + + // Contours - brown ink elevation lines + contours: { + opacityMod: 1.0, + minorColor: "#a88060", + minorOpacity: 0.5, + minorWidth: { z11: 0.5, z14: 1.0 }, + intermediateColor: "#8a6a3a", + intermediateOpacity: 0.7, + intermediateWidth: { z8: 0.8, z14: 1.2 }, + indexColor: "#6a4a20", + indexOpacity: 0.9, + indexWidth: { z4: 1.2, z14: 1.8 }, + labelColor: "#5a3a10", + labelHaloColor: "#d8c8a0", + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: 10, + labelFont: ["Noto Sans Regular"], + }, + + // Contours Test - same warm brown + contoursTest: { + minorColor: "#a88060", + intermediateColor: "#8a6a3a", + indexColor: "#6a4a20", + labelColor: "#5a3a10", + }, + + // Contours Test 10ft - slightly lighter brown + contoursTest10ft: { + minorColor: "#b89070", + intermediateColor: "#9a7a4a", + indexColor: "#7a5a30", + labelColor: "#6a4a20", + }, + + // Public Lands - muted olive-gold fills + publicLands: { + opacityMod: 0.8, + // Fill colors - olive-gold tints + fillWA: "#b8b070", + fillNPS: "#a8a060", + fillUSFS: "#b0a868", + fillBLM: "#c0b080", + fillFWS: "#a0a058", + fillSTAT: "#b8b078", + fillLOC: "#c0b888", + fillDefault: "#c8c090", + // Fill opacities + fillOpacityWA: 0.25, + fillOpacityNPS: 0.25, + fillOpacityUSFS: 0.22, + fillOpacityBLM: 0.18, + fillOpacitySTAT: 0.22, + fillOpacityLOC: 0.18, + fillOpacityDefault: 0.15, + // Outline colors - brown ink + outlineWA: "#8a7040", + outlineNPS: "#7a6030", + outlineUSFS: "#8a7040", + outlineBLM: "#9a8050", + outlineFWS: "#7a6030", + outlineSTAT: "#8a7040", + outlineLOC: "#9a8050", + outlineDefault: "#a89060", + // Outline opacities + outlineOpacityNPS: 0.7, + outlineOpacityUSFS: 0.6, + outlineOpacityDefault: 0.5, + // Outline width + outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, + // Labels - sepia ink with parchment halo + labelColor: "#5a4020", + labelHaloColor: "#d8c8a0", + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: { z10: 10, z14: 13 }, + labelFont: ["Noto Sans Regular"], + }, + + // USFS Trails - brown/sienna/olive ink family + usfsTrails: { + // Roads - brown ink + roadsColor: "#7a5a2a", + roadsOpacity: 0.9, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + // Trails - warm earth tones only + trailsMotorized: "#8b4513", + trailsBicycle: "#8a6a3a", + trailsHiker: "#6a5a40", + trailsDefault: "#7a5a2a", + trailsOpacity: 0.9, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + // Road labels + roadsLabelColor: "#5a4020", + roadsLabelHaloColor: "#d8c8a0", + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.9, + roadsLabelSize: 11, + // Trail labels + trailsLabelColor: "#5a4020", + trailsLabelHaloColor: "#d8c8a0", + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.9, + trailsLabelSize: 11, + labelFont: ["Noto Sans Regular"], + // Hit layer + hitWidth: 14, + }, + + // BLM Trails - brown/sienna/olive ink family + blmTrails: { + // Route colors - warm earth tones + color4wdHigh: "#8b4513", + color4wdLow: "#8a6a3a", + colorAtv: "#8b2500", + colorMotoSingle: "#7a5a2a", + color2wdLow: "#9a7a4a", + colorNonMech: "#6a5a40", + colorDefault: "#8a6a3a", + colorSnow: "#5a6a7a", + lineOpacity: 0.9, + lineOpacityOther: 0.85, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + // Dash patterns + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + // Labels + labelColor: "#5a4020", + labelHaloColor: "#d8c8a0", + labelHaloWidth: 1.5, + labelOpacity: 0.9, + labelSize: 11, + labelFont: ["Noto Sans Regular"], + // Hit layer + hitWidth: 14, + }, +} + +/** + * Satellite adjustments - warm sepia shift + */ +const parchmentSatellite = { + opacity: 0.85, + brightnessMin: 0.0, + brightnessMax: 0.85, + contrast: 0.15, + saturation: -0.4, + hueRotate: 30, +} + +/** + * Parchment theme configuration + */ +const parchmentTheme = { + id: "parchment", + name: "Parchment", + dark: false, + swatch: ["#d8c8a0", "#8b4513", "#1a3a6a"], + fontImports: [ + "https://fonts.googleapis.com/css2?family=IM+Fell+English:ital@0;1&display=swap", + ], + colors: parchmentColors, + satellite: parchmentSatellite, + overlay: parchmentOverlay, + ui: parchmentUI, +} + +export default parchmentTheme diff --git a/src/themes/ranger.js b/src/themes/ranger.js new file mode 100644 index 0000000..5ae8ca6 --- /dev/null +++ b/src/themes/ranger.js @@ -0,0 +1,325 @@ +/** + * Ranger Theme for Navi + * + * Military topographic map meets NVG-compatible night display. Dark olive/charcoal + * base with muted sage greens for text — readable but low-signature. Subdued amber + * for roads and primary actions (amber preserves night vision better than blue/white). + * Danger in muted red. Low contrast by design — intentional for night use. + * + * Water is dark blue-gray, land is dark olive. Contours are PROMINENT in olive-brown — + * this is a topo-first theme. The feel is a ruggedized field tablet displaying a + * mil-spec moving map. + * + * Designed for field use and eventual ATAK/iTAK integration. + * Functional, not decorative. + */ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const rangerColors = { + // Background & earth - dark olive + background: "#0a0e0a", + earth: "#0d110d", + + // Land use areas - olive family + park_a: "#141c14", + park_b: "#182418", + hospital: "#1a1818", + industrial: "#121612", + school: "#181814", + wood_a: "#141c14", + wood_b: "#182418", + pedestrian: "#101410", + scrub_a: "#161c16", + scrub_b: "#182018", + glacier: "#101814", + sand: "#181816", + beach: "#1c1c18", + aerodrome: "#101410", + runway: "#2a3028", + water: "#0a1018", + zoo: "#141c14", + military: "#181c18", + + // Tunnels - dark olive casings + tunnel_other_casing: "#0d110d", + tunnel_minor_casing: "#0d110d", + tunnel_link_casing: "#0d110d", + tunnel_major_casing: "#0d110d", + tunnel_highway_casing: "#0d110d", + tunnel_other: "#1a201a", + tunnel_minor: "#1a201a", + tunnel_link: "#2a3328", + tunnel_major: "#4a4830", + tunnel_highway: "#6a6028", + + // Pier & buildings - olive + pier: "#2a302a", + buildings: "#1a221a", + + // Roads & casings - olive to amber progression + minor_service_casing: "#0d110d", + minor_casing: "#0d110d", + link_casing: "#0d110d", + major_casing_late: "#0d110d", + highway_casing_late: "#0d110d", + other: "#2a3328", + minor_service: "#2a3328", + minor_a: "#3a4338", + minor_b: "#2a3328", + link: "#5a5838", + major_casing_early: "#0d110d", + major: "#8a7a40", + highway_casing_early: "#0d110d", + highway: "#c89030", + railway: "#1a201a", + boundaries: "#4a5a48", + + // Waterway label - muted steel blue + waterway_label: "#4a6a7a", + + // Bridges - same olive/amber colors + bridges_other_casing: "#101410", + bridges_minor_casing: "#0d110d", + bridges_link_casing: "#0d110d", + bridges_major_casing: "#0d110d", + bridges_highway_casing: "#0d110d", + bridges_other: "#2a3328", + bridges_minor: "#3a4338", + bridges_link: "#5a5838", + bridges_major: "#8a7a40", + bridges_highway: "#c89030", + + // Labels - sage green with DARK olive halos + roads_label_minor: "#6a7a60", + roads_label_minor_halo: "#0d110d", + roads_label_major: "#8a9a80", + roads_label_major_halo: "#0d110d", + ocean_label: "#4a6a7a", + peak_label: "#7a8a70", + subplace_label: "#6a7a60", + subplace_label_halo: "#0d110d", + city_label: "#a0b090", + city_label_halo: "#0d110d", + state_label: "#5a6a50", + state_label_halo: "#0d110d", + country_label: "#7a8a70", + address_label: "#6a7a60", + address_label_halo: "#0d110d", + + // POI icon colors - olive/amber/sage family, NO bright blues + pois: { + blue: "#5a7a6a", + green: "#5a8a40", + lapis: "#6a7a50", + pink: "#8a6a60", + red: "#aa3333", + slategray: "#6a7a68", + tangerine: "#c89030", + turquoise: "#5a8a70", + }, + + // Landcover fill colors - dark olive family + landcover: { + grassland: "rgba(20, 30, 18, 1)", + barren: "rgba(24, 24, 20, 1)", + urban_area: "rgba(16, 20, 16, 1)", + farmland: "rgba(18, 26, 18, 1)", + glacier: "rgba(16, 24, 20, 1)", + scrub: "rgba(22, 28, 20, 1)", + forest: "rgba(24, 36, 28, 1)", + }, +} + +/** + * UI CSS custom properties - ranger field display + */ +const rangerUI = { + "--font-sans": "'Inter', system-ui, -apple-system, sans-serif", + "--font-mono": "'JetBrains Mono', ui-monospace, monospace", + "--font-heading": "'Inter', system-ui, -apple-system, sans-serif", + "--bg-base": "#0d110d", + "--bg-raised": "#141a14", + "--bg-overlay": "#1a2219", + "--bg-input": "#101410", + "--bg-inset": "#0a0e0a", + "--bg-muted": "#182018", + "--text-primary": "#a0b090", + "--text-secondary": "#7a8a70", + "--text-tertiary": "#5a6a50", + "--text-inverse": "#0d110d", + "--border": "#2a332a", + "--border-subtle": "#1a221a", + "--accent": "#c89030", + "--accent-hover": "#d8a040", + "--accent-muted": "#3a3020", + "--tan": "#a09060", + "--tan-muted": "#2a2818", + "--pin-origin": "#c89030", + "--pin-destination": "#8aaa70", + "--pin-intermediate": "#6a7a60", + "--pin-stroke": "#0d110d", + "--status-success": "#5a8a40", + "--status-warning": "#c89030", + "--status-danger": "#aa3333", + "--success": "#5a8a40", + "--warning": "#c89030", + "--warning-muted": "#2a2818", + "--route-line": "#c89030", + "--shadow": "0 2px 8px rgba(0, 0, 0, 0.5)", + "--shadow-lg": "0 4px 16px rgba(0, 0, 0, 0.6)", +} + +/** + * Overlay configuration - prominent contours, subdued everything else + */ +const rangerOverlay = { + hillshade: { + exaggeration: 0.5, + illuminationDirection: 315, + shadowColor: "#000000", + highlightColor: "#1a221a", + }, + traffic: { + opacity: 0.5, + }, + contours: { + opacityMod: 1.0, + minorColor: "#6a5a38", + minorOpacity: 0.6, + minorWidth: { z11: 0.6, z14: 1.2 }, + intermediateColor: "#7a6a42", + intermediateOpacity: 0.8, + intermediateWidth: { z8: 1.0, z14: 1.5 }, + indexColor: "#8a7a4a", + indexOpacity: 1.0, + indexWidth: { z4: 1.5, z14: 2.2 }, + labelColor: "#8a7a58", + labelHaloColor: "#0d110d", + labelHaloWidth: 1.5, + labelOpacity: 0.9, + labelSize: 10, + labelFont: ["Noto Sans Regular"], + }, + contoursTest: { + minorColor: "#5a5a38", + intermediateColor: "#6a6a42", + indexColor: "#7a7a4a", + labelColor: "#8a8a58", + }, + contoursTest10ft: { + minorColor: "#4a5a38", + intermediateColor: "#5a6a42", + indexColor: "#6a7a4a", + labelColor: "#7a8a58", + }, + publicLands: { + opacityMod: 0.6, + fillWA: "#3a4030", + fillNPS: "#2a3a28", + fillUSFS: "#344030", + fillBLM: "#4a4a38", + fillFWS: "#2a4038", + fillSTAT: "#344838", + fillLOC: "#3a4a3a", + fillDefault: "#3a3a30", + fillOpacityWA: 0.25, + fillOpacityNPS: 0.25, + fillOpacityUSFS: 0.20, + fillOpacityBLM: 0.18, + fillOpacitySTAT: 0.22, + fillOpacityLOC: 0.18, + fillOpacityDefault: 0.12, + outlineWA: "#4a5040", + outlineNPS: "#3a4a38", + outlineUSFS: "#445040", + outlineBLM: "#5a5a48", + outlineFWS: "#3a5048", + outlineSTAT: "#445848", + outlineLOC: "#4a5a4a", + outlineDefault: "#4a4a40", + outlineOpacityNPS: 0.6, + outlineOpacityUSFS: 0.5, + outlineOpacityDefault: 0.4, + outlineWidth: { z4: 0.3, z8: 0.7, z12: 1.0 }, + labelColor: "#8aaa70", + labelHaloColor: "#0d110d", + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: { z10: 10, z14: 12 }, + labelFont: ["Noto Sans Regular"], + }, + usfsTrails: { + roadsColor: "#8a7a40", + roadsOpacity: 0.85, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + trailsMotorized: "#c89030", + trailsBicycle: "#a09040", + trailsHiker: "#6a9a50", + trailsDefault: "#8a8a50", + trailsOpacity: 0.85, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + roadsLabelColor: "#9a9a70", + roadsLabelHaloColor: "#0d110d", + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.85, + roadsLabelSize: 11, + trailsLabelColor: "#8a9a60", + trailsLabelHaloColor: "#0d110d", + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.85, + trailsLabelSize: 11, + labelFont: ["Noto Sans Regular"], + hitWidth: 14, + }, + blmTrails: { + color4wdHigh: "#c89030", + color4wdLow: "#a08030", + colorAtv: "#aa5030", + colorMotoSingle: "#8a7a50", + color2wdLow: "#b09040", + colorNonMech: "#6a9a50", + colorDefault: "#8a8a50", + colorSnow: "#6a8a7a", + lineOpacity: 0.85, + lineOpacityOther: 0.75, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + labelColor: "#9a9a70", + labelHaloColor: "#0d110d", + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: 11, + labelFont: ["Noto Sans Regular"], + hitWidth: 14, + }, +} + +const rangerSatellite = { + opacity: 0.75, + brightnessMin: 0.0, + brightnessMax: 0.35, + contrast: 0.0, + saturation: -0.7, + hueRotate: 0, +} + +const rangerTheme = { + id: "ranger", + name: "Ranger", + dark: true, + swatch: ["#0d110d", "#c89030", "#a0b090"], + fontImports: [], + colors: rangerColors, + satellite: rangerSatellite, + overlay: rangerOverlay, + ui: rangerUI, +} + +export default rangerTheme diff --git a/src/themes/registry.js b/src/themes/registry.js index 7f6b74c..a65a490 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -16,9 +16,12 @@ * fontImports: string[] - URLs for font CSS imports (empty for system fonts) */ -import { namedTheme } from 'protomaps-themes-base' import cleanTheme from './clean.js' import cyberpunkTheme from './cyberpunk.js' +import rangerTheme from './ranger.js' +import tacticalTheme from './tactical.js' +import nightopsTheme from './nightops.js' +import parchmentTheme from './parchment.js' // ═══════════════════════════════════════════════════════════════════════════ // UI CSS CUSTOM PROPERTIES @@ -288,16 +291,6 @@ const darkOverlay = { // Hit layer hitWidth: 14, }, - - // ── Highlight (boundary/selection) ──────────────────────────────────────── - highlight: { - lineColor: "#7a9a6b", // Muted olive-green for dark backgrounds - lineWidth: 2, - lineDash: [4, 4], - lineOpacity: 0.8, - fillColor: "#7a9a6b", - fillOpacity: 0.08, - }, } /** @@ -460,16 +453,6 @@ const lightOverlay = { // Hit layer hitWidth: 14, }, - - // ── Highlight (boundary/selection) ──────────────────────────────────────── - highlight: { - lineColor: "#4a7040", // Forest green for light backgrounds - lineWidth: 2, - lineDash: [4, 4], - lineOpacity: 0.7, - fillColor: "#4a7040", - fillOpacity: 0.06, - }, } // ═══════════════════════════════════════════════════════════════════════════ @@ -511,6 +494,10 @@ const themes = { fontImports: [], }, cyberpunk: cyberpunkTheme, + ranger: rangerTheme, + tactical: tacticalTheme, + nightops: nightopsTheme, + parchment: parchmentTheme, // Custom themes go here. Example: // 'midnight': { // id: 'midnight', @@ -538,21 +525,6 @@ export function getTheme(id) { return themes[id] || themes.dark } -/** - * Get the color flavor for a theme - * For built-in themes, calls namedTheme(). For custom themes, returns colors directly. - * @param {string} id - Theme ID - * @returns {object} Flavor object for use with protomaps layers() - */ -export function getThemeColors(id) { - const theme = getTheme(id) - if (theme.colors === null) { - // Built-in theme - use namedTheme from protomaps-themes-base - return namedTheme(id) - } - return theme.colors -} - /** * Get the sprite URL for a theme * Built-in themes use their own sprites. Custom themes fall back to diff --git a/src/themes/tactical.js b/src/themes/tactical.js new file mode 100644 index 0000000..9ac5ddf --- /dev/null +++ b/src/themes/tactical.js @@ -0,0 +1,328 @@ +/** + * Tactical Theme for Navi + * + * Green phosphor military display. The aesthetic of night vision goggles, + * submarine sonar screens, 1980s radar consoles, and classic green-screen + * terminals. Pure black background with ALL visual information in the green + * spectrum only. + * + * Named "Tactical" because this is the recon/military working display — + * matches the Echo6/RECON platform identity. + * + * Monochrome green rules: + * - ONLY green and black. No red, no blue, no amber, no white. + * - Text is green on black, not white on black. + * - Water is pure black — no blue tint. + * - The ONLY contrast axis is bright-green to dark-green to black. + * - The green is warm phosphor green (#00cc44), not cold cyan-green. + */ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const tacticalColors = { + // Background & earth - pure black with faint green + background: "#000800", + earth: "#000a00", + + // Land use areas - very dark green + park_a: "#001508", + park_b: "#001a0a", + hospital: "#001208", + industrial: "#001005", + school: "#001208", + wood_a: "#001508", + wood_b: "#001a0a", + pedestrian: "#000c03", + scrub_a: "#001206", + scrub_b: "#001508", + glacier: "#000e04", + sand: "#001005", + beach: "#001206", + aerodrome: "#000c03", + runway: "#002a0a", + water: "#000500", + zoo: "#001508", + military: "#001206", + + // Tunnels - black casings + tunnel_other_casing: "#000a00", + tunnel_minor_casing: "#000a00", + tunnel_link_casing: "#000a00", + tunnel_major_casing: "#000a00", + tunnel_highway_casing: "#000a00", + tunnel_other: "#001a08", + tunnel_minor: "#001a08", + tunnel_link: "#002a0a", + tunnel_major: "#003a10", + tunnel_highway: "#004415", + + // Pier & buildings - very dark green + pier: "#002a0a", + buildings: "#001a08", + + // Roads & casings - green spectrum by brightness + minor_service_casing: "#000a00", + minor_casing: "#000a00", + link_casing: "#000a00", + major_casing_late: "#000a00", + highway_casing_late: "#000a00", + other: "#002a0a", + minor_service: "#002a0a", + minor_a: "#003a10", + minor_b: "#002a0a", + link: "#004415", + major_casing_early: "#000a00", + major: "#006622", + highway_casing_early: "#000a00", + highway: "#008830", + railway: "#001a08", + boundaries: "#004415", + + // Waterway label - dim green on black water + waterway_label: "#006622", + + // Bridges - same green spectrum + bridges_other_casing: "#000c03", + bridges_minor_casing: "#000a00", + bridges_link_casing: "#000a00", + bridges_major_casing: "#000a00", + bridges_highway_casing: "#000a00", + bridges_other: "#002a0a", + bridges_minor: "#003a10", + bridges_link: "#004415", + bridges_major: "#006622", + bridges_highway: "#008830", + + // Labels - phosphor green with BLACK halos + roads_label_minor: "#005520", + roads_label_minor_halo: "#000a00", + roads_label_major: "#006622", + roads_label_major_halo: "#000a00", + ocean_label: "#006622", + peak_label: "#006622", + subplace_label: "#005520", + subplace_label_halo: "#000a00", + city_label: "#00cc44", + city_label_halo: "#000a00", + state_label: "#004415", + state_label_halo: "#000a00", + country_label: "#006622", + address_label: "#005520", + address_label_halo: "#000a00", + + // POI icon colors - ALL green spectrum, differentiated by brightness + pois: { + blue: "#006622", + green: "#00aa33", + lapis: "#005520", + pink: "#008830", + red: "#00cc44", + slategray: "#004415", + tangerine: "#00aa33", + turquoise: "#006622", + }, + + // Landcover fill colors - very dark green + landcover: { + grassland: "rgba(0, 21, 8, 1)", + barren: "rgba(0, 16, 5, 1)", + urban_area: "rgba(0, 12, 3, 1)", + farmland: "rgba(0, 18, 6, 1)", + glacier: "rgba(0, 14, 4, 1)", + scrub: "rgba(0, 18, 8, 1)", + forest: "rgba(0, 26, 10, 1)", + }, +} + +/** + * UI CSS custom properties - phosphor green terminal + */ +const tacticalUI = { + "--font-sans": "'Inter', system-ui, -apple-system, sans-serif", + "--font-mono": "'JetBrains Mono', ui-monospace, monospace", + "--font-heading": "'Inter', system-ui, -apple-system, sans-serif", + "--bg-base": "#000a00", + "--bg-raised": "#001200", + "--bg-overlay": "#001a05", + "--bg-input": "#000c02", + "--bg-inset": "#000800", + "--bg-muted": "#001505", + "--text-primary": "#00cc44", + "--text-secondary": "#008830", + "--text-tertiary": "#005520", + "--text-inverse": "#000a00", + "--border": "#002a0a", + "--border-subtle": "#001a08", + "--accent": "#00cc44", + "--accent-hover": "#00dd55", + "--accent-muted": "#002a0a", + "--tan": "#00aa33", + "--tan-muted": "#001a08", + "--pin-origin": "#00cc44", + "--pin-destination": "#00aa33", + "--pin-intermediate": "#008830", + "--pin-stroke": "#000a00", + "--status-success": "#00aa33", + "--status-warning": "#88aa00", + "--status-danger": "#cc4400", + "--success": "#00aa33", + "--warning": "#88aa00", + "--warning-muted": "#1a1a00", + "--route-line": "#00cc44", + "--shadow": "0 2px 8px rgba(0, 0, 0, 0.8)", + "--shadow-lg": "0 4px 16px rgba(0, 0, 0, 0.9)", +} + +/** + * Overlay configuration - monochrome green + */ +const tacticalOverlay = { + hillshade: { + exaggeration: 0.3, + illuminationDirection: 315, + shadowColor: "#000000", + highlightColor: "#001a08", + }, + traffic: { + opacity: 0.4, + }, + contours: { + opacityMod: 0.8, + minorColor: "#003311", + minorOpacity: 0.5, + minorWidth: { z11: 0.5, z14: 1.0 }, + intermediateColor: "#004415", + intermediateOpacity: 0.6, + intermediateWidth: { z8: 0.8, z14: 1.2 }, + indexColor: "#005520", + indexOpacity: 0.8, + indexWidth: { z4: 1.2, z14: 1.8 }, + labelColor: "#006622", + labelHaloColor: "#000a00", + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 10, + labelFont: ["Noto Sans Regular"], + }, + contoursTest: { + minorColor: "#003311", + intermediateColor: "#004415", + indexColor: "#005520", + labelColor: "#006622", + }, + contoursTest10ft: { + minorColor: "#002a0a", + intermediateColor: "#003a10", + indexColor: "#004415", + labelColor: "#005520", + }, + publicLands: { + opacityMod: 0.4, + fillWA: "#001a08", + fillNPS: "#001508", + fillUSFS: "#001a08", + fillBLM: "#001206", + fillFWS: "#001508", + fillSTAT: "#001a08", + fillLOC: "#001206", + fillDefault: "#001005", + fillOpacityWA: 0.20, + fillOpacityNPS: 0.20, + fillOpacityUSFS: 0.18, + fillOpacityBLM: 0.15, + fillOpacitySTAT: 0.18, + fillOpacityLOC: 0.15, + fillOpacityDefault: 0.10, + outlineWA: "#002a0a", + outlineNPS: "#002a0a", + outlineUSFS: "#002a0a", + outlineBLM: "#001a08", + outlineFWS: "#002a0a", + outlineSTAT: "#002a0a", + outlineLOC: "#001a08", + outlineDefault: "#001a08", + outlineOpacityNPS: 0.5, + outlineOpacityUSFS: 0.4, + outlineOpacityDefault: 0.3, + outlineWidth: { z4: 0.3, z8: 0.6, z12: 0.9 }, + labelColor: "#006622", + labelHaloColor: "#000a00", + labelHaloWidth: 1.5, + labelOpacity: 0.7, + labelSize: { z10: 10, z14: 12 }, + labelFont: ["Noto Sans Regular"], + }, + usfsTrails: { + roadsColor: "#004415", + roadsOpacity: 0.8, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + trailsMotorized: "#008830", + trailsBicycle: "#006622", + trailsHiker: "#005520", + trailsDefault: "#004415", + trailsOpacity: 0.8, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + roadsLabelColor: "#006622", + roadsLabelHaloColor: "#000a00", + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.8, + roadsLabelSize: 11, + trailsLabelColor: "#006622", + trailsLabelHaloColor: "#000a00", + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.8, + trailsLabelSize: 11, + labelFont: ["Noto Sans Regular"], + hitWidth: 14, + }, + blmTrails: { + color4wdHigh: "#008830", + color4wdLow: "#006622", + colorAtv: "#008830", + colorMotoSingle: "#006622", + color2wdLow: "#005520", + colorNonMech: "#005520", + colorDefault: "#004415", + colorSnow: "#006622", + lineOpacity: 0.8, + lineOpacityOther: 0.7, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + labelColor: "#006622", + labelHaloColor: "#000a00", + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 11, + labelFont: ["Noto Sans Regular"], + hitWidth: 14, + }, +} + +const tacticalSatellite = { + opacity: 0.5, + brightnessMin: 0.0, + brightnessMax: 0.15, + contrast: 0.0, + saturation: -1.0, + hueRotate: 120, +} + +const tacticalTheme = { + id: "tactical", + name: "Tactical", + dark: true, + swatch: ["#000a00", "#00cc44", "#005520"], + fontImports: [], + colors: tacticalColors, + satellite: tacticalSatellite, + overlay: tacticalOverlay, + ui: tacticalUI, +} + +export default tacticalTheme