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

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

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

- {man.instruction} -

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

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

-

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

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

- {man.instruction} -

-
-
- ))} - - )} -
- )} -
- ) -} +import { + MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, + MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, + GitMerge, CornerRightDown, CornerRightUp, Navigation +} from 'lucide-react' +import { useStore } from '../store' + +function formatTime(seconds) { + if (seconds < 60) return `${Math.round(seconds)}s` + if (seconds < 3600) return `${Math.round(seconds / 60)} min` + const h = Math.floor(seconds / 3600) + const m = Math.round((seconds % 3600) / 60) + return m > 0 ? `${h}h ${m}m` : `${h}h` +} + +function formatDist(miles) { + if (miles < 0.1) return `${Math.round(miles * 5280)} ft` + return `${miles.toFixed(1)} mi` +} + +function ManeuverIcon({ type }) { + const size = 16 + const props = { size, strokeWidth: 1.5 } + switch (type) { + case 0: return + case 1: return + case 2: return + case 3: return + case 4: case 5: return + case 6: return + case 7: return + case 8: return + case 9: return + case 10: case 11: case 12: return + case 15: case 16: return + case 24: return + case 25: return + case 26: return + default: return + } +} + +export default function ManeuverList({ onManeuverClick }) { + const route = useStore((s) => s.route) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + + if (routeLoading) { + return ( +
+
+ + Calculating route... + +
+ ) + } + + if (routeError) { + return ( +
+ {routeError} +
+ ) + } + + if (!route || !route.legs) return null + + const totalTime = route.summary?.time || 0 + const totalDist = route.summary?.length || 0 + + const allManeuvers = [] + let timeRemaining = totalTime + + for (let legIdx = 0; legIdx < route.legs.length; legIdx++) { + const leg = route.legs[legIdx] + for (const man of leg.maneuvers || []) { + allManeuvers.push({ ...man, _legIndex: legIdx, timeRemaining }) + timeRemaining -= man.time || 0 + } + } + + return ( +
+ {/* Route summary */} +
+ + {formatDist(totalDist)} + + + {formatTime(totalTime)} + +
+ + {/* Maneuver steps */} +
+ {allManeuvers.map((man, i) => ( + + ))} +
+
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 5c185c9..f9120d3 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2,19 +2,17 @@ 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, namedTheme } from 'protomaps-themes-base' -import { getTheme, getThemeSprite, getOverlayConfig } from '../themes/registry' +import { layers } from 'protomaps-themes-base' +import { getTheme, getThemeColors, getThemeSprite, getOverlayConfig } from '../themes/registry' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' -import { fetchReverse, requestOffroute } from '../api' +import { fetchReverse } from '../api' import { getConfig, hasFeature } from '../config' -import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus } from 'lucide-react' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' -import mlcontour from '@acalcutt/maplibre-contour-pmtiles' -let demSourceInstance = null /** Check if current theme is dark based on registry */ function isCurrentThemeDark() { @@ -25,12 +23,7 @@ function isCurrentThemeDark() { const ROUTE_SOURCE = 'route-source' const BOUNDARY_SOURCE = 'boundary-source' const BOUNDARY_LAYER = 'boundary-layer' -const STATE_BOUNDARIES_LAYER = 'state-boundaries-z4-z7' const ROUTE_LAYER_PREFIX = 'route-layer-' -const OFFROUTE_SOURCE = 'offroute-source' -const OFFROUTE_WILDERNESS_LAYER = 'offroute-wilderness' -const OFFROUTE_NETWORK_LAYER = 'offroute-network' -const OFFROUTE_MARKERS_LAYER = 'offroute-markers' const HILLSHADE_SOURCE = 'hillshade-dem' const HILLSHADE_LAYER = 'hillshade-layer' const TRAFFIC_SOURCE = 'traffic-tiles' @@ -39,9 +32,21 @@ const PUBLIC_LANDS_SOURCE = 'public-lands-tiles' const PUBLIC_LANDS_FILL = 'public-lands-fill' const PUBLIC_LANDS_LINE = 'public-lands-line' const PUBLIC_LANDS_LABEL = 'public-lands-label' -const CONTOUR_SOURCE = 'contour-source' -const CONTOUR_LINE = 'contour-lines' -const CONTOUR_LABEL = 'contour-labels' +const CONTOUR_SOURCE = 'contour-tiles' +const CONTOUR_MINOR = 'contour-minor' +const CONTOUR_INTERMEDIATE = 'contour-intermediate' +const CONTOUR_INDEX = 'contour-index' +const CONTOUR_LABEL = 'contour-label' +const CONTOUR_TEST_SOURCE = 'contour-test-tiles' +const CONTOUR_TEST_MINOR = 'contour-test-minor' +const CONTOUR_TEST_INTERMEDIATE = 'contour-test-intermediate' +const CONTOUR_TEST_INDEX = 'contour-test-index' +const CONTOUR_TEST_LABEL = 'contour-test-label' +const CONTOUR_TEST_10FT_SOURCE = 'contour-test-10ft-tiles' +const CONTOUR_TEST_10FT_MINOR = 'contour-test-10ft-minor' +const CONTOUR_TEST_10FT_INTERMEDIATE = 'contour-test-10ft-intermediate' +const CONTOUR_TEST_10FT_INDEX = 'contour-test-10ft-index' +const CONTOUR_TEST_10FT_LABEL = 'contour-test-10ft-label' const MEASURE_SOURCE = 'measure-source' const MEASURE_LINE_LAYER = 'measure-line-layer' const MEASURE_POINT_LAYER = 'measure-point-layer' @@ -60,8 +65,6 @@ 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 @@ -259,41 +262,14 @@ function applyBaseLabelStyling(map) { 'text-halo-width': 1.8, } }) - - // Adjust label zoom ranges for proper hierarchy: - // - Countries: z1-z4 (fade out as states appear) - // - States/provinces: z4-z7 (appear as countries fade, fade as cities dominate) - // - Cities: unchanged (natural min_zoom in tile data) - try { - if (map.getLayer('places_country')) { - map.setLayerZoomRange('places_country', 1, 5) - } - if (map.getLayer('places_region')) { - map.setLayerZoomRange('places_region', 4, 8) - // FIX: The protomaps theme uses name:short which doesn't exist in tiles - // Use coalesce to fall back to ref (e.g., "CA") then name (e.g., "California") - map.setLayoutProperty('places_region', 'text-field', [ - 'coalesce', - ['get', 'name'], - ['get', 'ref'], - ['get', 'name:short'] - ]) - } - } catch (e) { - // Ignore if layers don't exist - } } /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() - const tileUrl = config?.tileset?.url || '/tiles/planet/planet-20260420.pmtiles' + const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles' const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM' - // Use namedTheme directly for built-in themes, custom colors for others - const theme = getTheme(themeName) - const colors = theme.colors || namedTheme(themeName) - return { version: 8, glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf', @@ -305,7 +281,7 @@ function buildStyle(themeName) { attribution, }, }, - layers: layers('protomaps', colors, { lang: 'en' }), + layers: layers('protomaps', getThemeColors(themeName), { lang: 'en' }), } } @@ -528,8 +504,6 @@ function addPublicLands(map, themeId) { type: 'symbol', source: PUBLIC_LANDS_SOURCE, 'source-layer': 'public_lands', - // Exclude PAD-US sub-polygons whose unit_nm is "Unknown " — USGS source artifact, not real label. - filter: ['!', ['==', ['slice', ['coalesce', ['get', 'name'], ''], 0, 8], 'Unknown ']], minzoom: 10, layout: { 'text-field': ['get', 'name'], @@ -559,101 +533,309 @@ function removePublicLands(map) { if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE) } -/** Add topographic contours via maplibre-contour */ +/** Add topographic contour vector tile overlay */ function addContours(map, themeId) { - console.log("[CONTOUR] addContours called, source exists:", !!map?.getSource(CONTOUR_SOURCE), "demSource:", !!demSourceInstance) - if (!map || !demSourceInstance || map.getSource(CONTOUR_SOURCE)) return + if (!map || map.getSource(CONTOUR_SOURCE)) return + + const c = getOverlayConfig(themeId, 'contours') - 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", - tiles: [demSourceInstance.contourProtocolUrl({ - multiplier: 3.28084, - thresholds: contourThresholds, - })], - maxzoom: 16, + type: 'vector', + url: 'pmtiles:///tiles/contours-na.pmtiles', }) - console.log("[CONTOUR] protocol URL:", demSourceInstance.contourProtocolUrl({ - multiplier: 3.28084, - thresholds: contourThresholds, - })) - console.log("[CONTOUR] source added:", !!map.getSource(CONTOUR_SOURCE)) + + // Insert below first symbol layer (above hillshade, below labels) let beforeId = undefined for (const layer of map.getStyle().layers) { - if (layer.type === "symbol") { beforeId = layer.id; break } + if (layer.type === 'symbol') { + beforeId = layer.id + break + } } - // Line layer with theme-aware colors - // maplibre-contour level: 0 = minor, 1 = index (major) - const opacityMod = c.opacityMod ?? 1 + // Minor contours (40ft) — visible z11+ map.addLayer({ - id: CONTOUR_LINE, type: "line", source: CONTOUR_SOURCE, - "source-layer": "contours", + id: CONTOUR_MINOR, + type: 'line', + source: CONTOUR_SOURCE, + 'source-layer': 'contours', + minzoom: 11, + filter: ['==', ['get', 'tier'], 'minor'], paint: { - "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], - ], + 'line-color': c.minorColor, + 'line-opacity': c.minorOpacity * c.opacityMod, + 'line-width': ['interpolate', ['linear'], ['zoom'], 11, c.minorWidth.z11, 14, c.minorWidth.z14], }, }, beforeId) - // Label layer for index contours (level > 0) + // Intermediate contours (200ft) — visible z8+ map.addLayer({ - id: CONTOUR_LABEL, type: "symbol", source: CONTOUR_SOURCE, - "source-layer": "contours", - filter: [">", ["get", "level"], 0], + 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'], layout: { - "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, + '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 ?? 1.5, - "text-opacity": (c.labelOpacity ?? 0.85) * opacityMod, + 'text-color': c.labelColor, + 'text-halo-color': c.labelHaloColor, + 'text-halo-width': c.labelHaloWidth, + 'text-opacity': c.labelOpacity, }, }) - console.log("[CONTOUR] layers added:", !!map.getLayer(CONTOUR_LINE), !!map.getLayer(CONTOUR_LABEL)) } /** Remove contour layers + source */ function removeContours(map) { if (!map) return if (map.getLayer(CONTOUR_LABEL)) map.removeLayer(CONTOUR_LABEL) - if (map.getLayer(CONTOUR_LINE)) map.removeLayer(CONTOUR_LINE) + if (map.getLayer(CONTOUR_INDEX)) map.removeLayer(CONTOUR_INDEX) + if (map.getLayer(CONTOUR_INTERMEDIATE)) map.removeLayer(CONTOUR_INTERMEDIATE) + if (map.getLayer(CONTOUR_MINOR)) map.removeLayer(CONTOUR_MINOR) if (map.getSource(CONTOUR_SOURCE)) map.removeSource(CONTOUR_SOURCE) } +/** Add TEST topographic contour overlay (blue color scheme) */ +function addContoursTest(map, themeId) { + if (!map || map.getSource(CONTOUR_TEST_SOURCE)) return + + const c = getOverlayConfig(themeId, 'contoursTest') + + map.addSource(CONTOUR_TEST_SOURCE, { + type: "vector", + url: "pmtiles:///tiles/contours-test.pmtiles", + }) + + let beforeId = undefined + for (const layer of map.getStyle().layers) { + if (layer.type === "symbol") { + beforeId = layer.id + break + } + } + + // Minor contours (40ft) — blue scheme + map.addLayer({ + id: CONTOUR_TEST_MINOR, + type: "line", + source: CONTOUR_TEST_SOURCE, + "source-layer": "contours", + minzoom: 11, + filter: ["==", ["get", "tier"], "minor"], + paint: { + "line-color": c.minorColor, + "line-opacity": c.minorOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14], + }, + }, beforeId) + + // Intermediate contours (200ft) + map.addLayer({ + id: CONTOUR_TEST_INTERMEDIATE, + type: "line", + source: CONTOUR_TEST_SOURCE, + "source-layer": "contours", + minzoom: 8, + filter: ["==", ["get", "tier"], "intermediate"], + paint: { + "line-color": c.intermediateColor, + "line-opacity": c.intermediateOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], + }, + }, beforeId) + + // Index contours (1000ft) + map.addLayer({ + id: CONTOUR_TEST_INDEX, + type: "line", + source: CONTOUR_TEST_SOURCE, + "source-layer": "contours", + minzoom: 4, + filter: ["==", ["get", "tier"], "index"], + paint: { + "line-color": c.indexColor, + "line-opacity": c.indexOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14], + }, + }, beforeId) + + // Labels + map.addLayer({ + id: CONTOUR_TEST_LABEL, + type: "symbol", + source: CONTOUR_TEST_SOURCE, + "source-layer": "contours", + minzoom: 12, + filter: ["==", ["get", "tier"], "index"], + layout: { + "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], ""], + "text-size": c.labelSize, + "text-font": c.labelFont, + "symbol-placement": "line", + "text-anchor": "center", + "symbol-spacing": 400, + "text-max-angle": 30, + "text-allow-overlap": false, + }, + paint: { + "text-color": c.labelColor, + "text-halo-color": c.labelHaloColor, + "text-halo-width": c.labelHaloWidth, + "text-opacity": c.labelOpacity, + }, + }) +} + +/** Remove TEST contour layers + source */ +function removeContoursTest(map) { + if (!map) return + if (map.getLayer(CONTOUR_TEST_LABEL)) map.removeLayer(CONTOUR_TEST_LABEL) + if (map.getLayer(CONTOUR_TEST_INDEX)) map.removeLayer(CONTOUR_TEST_INDEX) + if (map.getLayer(CONTOUR_TEST_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_INTERMEDIATE) + if (map.getLayer(CONTOUR_TEST_MINOR)) map.removeLayer(CONTOUR_TEST_MINOR) + if (map.getSource(CONTOUR_TEST_SOURCE)) map.removeSource(CONTOUR_TEST_SOURCE) +} + +/** Add TEST 10ft topographic contour overlay (green color scheme) */ +function addContoursTest10ft(map, themeId) { + if (!map || map.getSource(CONTOUR_TEST_10FT_SOURCE)) return + + const c = getOverlayConfig(themeId, 'contoursTest10ft') + + map.addSource(CONTOUR_TEST_10FT_SOURCE, { + type: "vector", + url: "pmtiles:///tiles/contours-test-10ft.pmtiles", + }) + + let beforeId = undefined + for (const layer of map.getStyle().layers) { + if (layer.type === "symbol") { + beforeId = layer.id + break + } + } + + // Minor contours (10ft) — green scheme + map.addLayer({ + id: CONTOUR_TEST_10FT_MINOR, + type: "line", + source: CONTOUR_TEST_10FT_SOURCE, + "source-layer": "contours", + minzoom: 11, + filter: ["==", ["get", "tier"], "minor"], + paint: { + "line-color": c.minorColor, + "line-opacity": c.minorOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14], + }, + }, beforeId) + + // Intermediate contours (50ft) — green scheme + map.addLayer({ + id: CONTOUR_TEST_10FT_INTERMEDIATE, + type: "line", + source: CONTOUR_TEST_10FT_SOURCE, + "source-layer": "contours", + minzoom: 8, + filter: ["==", ["get", "tier"], "intermediate"], + paint: { + "line-color": c.intermediateColor, + "line-opacity": c.intermediateOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], + }, + }, beforeId) + + // Index contours (250ft) — darker green + map.addLayer({ + id: CONTOUR_TEST_10FT_INDEX, + type: "line", + source: CONTOUR_TEST_10FT_SOURCE, + "source-layer": "contours", + minzoom: 4, + filter: ["==", ["get", "tier"], "index"], + paint: { + "line-color": c.indexColor, + "line-opacity": c.indexOpacity * c.opacityMod, + "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14], + }, + }, beforeId) + + // Elevation labels on index contours (z12+) + map.addLayer({ + id: CONTOUR_TEST_10FT_LABEL, + type: "symbol", + source: CONTOUR_TEST_10FT_SOURCE, + "source-layer": "contours", + minzoom: 12, + filter: ["==", ["get", "tier"], "index"], + layout: { + "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], "'"], + "text-size": c.labelSize, + "text-font": c.labelFont, + "symbol-placement": "line", + "text-anchor": "center", + "symbol-spacing": 400, + "text-max-angle": 30, + "text-allow-overlap": false, + }, + paint: { + "text-color": c.labelColor, + "text-halo-color": c.labelHaloColor, + "text-halo-width": c.labelHaloWidth, + "text-opacity": c.labelOpacity, + }, + }) +} + +/** Remove test 10ft contour layers + source */ +function removeContoursTest10ft(map) { + if (!map) return + if (map.getLayer(CONTOUR_TEST_10FT_LABEL)) map.removeLayer(CONTOUR_TEST_10FT_LABEL) + if (map.getLayer(CONTOUR_TEST_10FT_INDEX)) map.removeLayer(CONTOUR_TEST_10FT_INDEX) + if (map.getLayer(CONTOUR_TEST_10FT_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_10FT_INTERMEDIATE) + if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR) + if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE) +} /** Add USFS trails and roads vector tile overlay */ function addUsfsTrails(map, themeId) { if (!map || map.getSource(USFS_SOURCE)) return @@ -1065,187 +1247,6 @@ function removeBlmTrails(map) { } -// ═══════════════════════════════════════════════════════════════════════════ -// 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' @@ -1287,147 +1288,13 @@ function addBoundaryLayer(map) { source: BOUNDARY_SOURCE, paint: { "line-color": accentColor, - "line-width": 2, - "line-opacity": 0.7, + "line-width": 2.5, + "line-opacity": 0.8, "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) @@ -1438,31 +1305,30 @@ const MapView = forwardRef(function MapView(_, ref) { const watchIdRef = useRef(null) const currentThemeRef = useRef('dark') // Track which overlay layers are currently active (for theme swap re-add) - const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, usfsTrails: false, blmTrails: false }) + const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false, usfsTrails: false, blmTrails: false }) // Flag to suppress map-click when a stop pin was clicked const pinClickedRef = useRef(false) const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState const hoveredFeatureRef = useRef(null) // for hover highlight const updateBoundaryRef = useRef(null) // boundary update function - const lastFlyTargetRef = useRef(null) // track last fly target to avoid re-flying on metadata updates // Refs for measurement state (accessible in click handlers) const measuringRef = useRef({ active: false, points: [] }) const measureLabelsRef = useRef([]) // HTML label elements + const stops = useStore((s) => s.stops) + const route = useStore((s) => s.route) const theme = useStore((s) => s.theme) const selectedPlace = useStore((s) => s.selectedPlace) const clickMarker = useStore((s) => s.clickMarker) const setClickMarker = useStore((s) => s.setClickMarker) const clearClickMarker = useStore((s) => s.clearClickMarker) + const gpsOrigin = useStore((s) => s.gpsOrigin) const geoPermission = useStore((s) => s.geoPermission) const setSheetState = useStore((s) => s.setSheetState) const setMapCenter = useStore((s) => s.setMapCenter) const pickingLocationFor = useStore((s) => s.pickingLocationFor) const setEditingContact = useStore((s) => s.setEditingContact) const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor) - const directionsMode = useStore((s) => s.directionsMode) - const activeDirectionsField = useStore((s) => s.activeDirectionsField) - const pickingRouteField = useStore((s) => s.pickingRouteField) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) @@ -1638,8 +1504,8 @@ const MapView = forwardRef(function MapView(_, ref) { type: "line", source: MEASURE_SOURCE, paint: { - "line-color": accentColor, - "line-width": 2, + "line-color": "#ff0000", + "line-width": 8, "line-dasharray": [8, 4], }, }) @@ -1681,7 +1547,7 @@ const MapView = forwardRef(function MapView(_, ref) { const radialWedges = [ { - id: "to-here", + id: "directions-to", label: "To here", icon: ArrowDownLeft, onSelect: () => { @@ -1690,48 +1556,29 @@ const MapView = forwardRef(function MapView(_, ref) { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + source: "radial_menu", + matchCode: null, } - const { routeStart, setRouteStart, setRouteEnd, computeRoute, setDirectionsMode, geoPermission, userLocation } = useStore.getState() - - setRouteEnd(place) - setDirectionsMode(true) - - if (routeStart) { - computeRoute() - } else if (geoPermission === "granted" && userLocation) { - // Use GPS as origin fallback - setRouteStart({ - lat: userLocation.lat, - lon: userLocation.lon, - name: "Your location", - source: "gps", - }) - computeRoute() - } - // If no origin and no GPS, directions panel opens and origin field auto-focuses + useStore.getState().startDirections(place) }, }, { - id: "from-here", + id: "directions-from", label: "From here", icon: ArrowUpRight, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) + const { clearStops, addStop } = useStore.getState() + clearStops() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + source: "radial_menu", + matchCode: null, } - const { clearRoute, setRouteStart, routeEnd, computeRoute, setDirectionsMode } = useStore.getState() - clearRoute() - clearRouteDisplay(mapInstance.current) - setRouteStart(place) - setDirectionsMode(true) - - if (routeEnd) { - computeRoute() - } - // If no destination, directions panel opens and destination field auto-focuses + addStop(place) + useStore.setState({ gpsOrigin: false }) }, }, { @@ -1740,33 +1587,25 @@ const MapView = forwardRef(function MapView(_, ref) { icon: Plus, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) - const { addIntermediateStop, computeRoute, routeStart, routeEnd } = useStore.getState() + const { stops, addStop, clearStops } = useStore.getState() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + source: "radial_menu", + matchCode: null, } - const success = addIntermediateStop(place) - if (success) { - // If we have both origin and destination, recalculate route - if (routeStart && routeEnd) { - computeRoute() - } + if (stops.length === 0) { + addStop(place) + useStore.setState({ gpsOrigin: false }) } else { - toast("Maximum 8 intermediate stops reached") + const success = addStop(place) + if (!success) { + toast("Maximum 10 stops reached") + } } }, }, - { - id: "clear-route", - label: "Clear", - icon: Trash2, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - useStore.getState().clearRoute() - clearRouteDisplay(mapInstance.current) - }, - }, { id: "save-place", label: "Save", @@ -1888,6 +1727,30 @@ const MapView = forwardRef(function MapView(_, ref) { removeContours(map) activeLayersRef.current.contours = false }, + addContoursTestLayer() { + const map = mapInstance.current + if (!map) return + addContoursTest(map, currentThemeRef.current) + activeLayersRef.current.contoursTest = true + }, + removeContoursTestLayer() { + const map = mapInstance.current + if (!map) return + removeContoursTest(map) + activeLayersRef.current.contoursTest = false + }, + addContoursTest10ftLayer() { + const map = mapInstance.current + if (!map) return + addContoursTest10ft(map, currentThemeRef.current) + activeLayersRef.current.contoursTest10ft = true + }, + removeContoursTest10ftLayer() { + const map = mapInstance.current + if (!map) return + removeContoursTest10ft(map) + activeLayersRef.current.contoursTest10ft = false + }, addUsfsTrailsLayer() { const map = mapInstance.current if (!map) return @@ -1913,34 +1776,6 @@ 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 @@ -1949,21 +1784,6 @@ const MapView = forwardRef(function MapView(_, ref) { maplibregl.addProtocol('pmtiles', protocol.tile) const config = getConfig() - - // Initialize DemSource for maplibre-contour (uses same PMTiles as hillshade) - if (!demSourceInstance) { - const hs = config?.tileset_hillshade - if (hs?.url) { - demSourceInstance = new mlcontour.DemSource({ - url: `pmtiles://${window.location.origin}${hs.url}`, - encoding: hs.encoding || 'terrarium', - maxzoom: hs.max_zoom || 12, - worker: true, - cacheSize: 100, - }) - demSourceInstance.setupMaplibre(maplibregl) - } - } const DEFAULT_CENTER = config?.defaults?.center ? [config.defaults.center[1], config.defaults.center[0]] // config is [lat,lon], MapLibre wants [lon,lat] : [-114.6066, 42.5736] @@ -2030,33 +1850,7 @@ const MapView = forwardRef(function MapView(_, ref) { return } - // Handle explicit pick-from-map mode for route inputs - const { pickingRouteField, setRouteStart, setRouteEnd, clearPickingRouteField } = useStore.getState() - if (pickingRouteField) { - const { lng, lat } = e.lngLat - map.getCanvas().style.cursor = '' - // Reverse geocode for name - fetchReverse(lat, lng).then((place) => { - const name = place?.name || lat.toFixed(5) + ", " + lng.toFixed(5) - const location = { lat, lon: lng, name, source: "map_click" } - if (pickingRouteField === "origin") { - setRouteStart(location) - } else if (pickingRouteField === "destination") { - setRouteEnd(location) - } - clearPickingRouteField() - }).catch(() => { - const name = lat.toFixed(5) + ", " + lng.toFixed(5) - const location = { lat, lon: lng, name, source: "map_click" } - if (pickingRouteField === "origin") { - setRouteStart(location) - } else if (pickingRouteField === "destination") { - setRouteEnd(location) - } - clearPickingRouteField() - }) - return - } + const store = useStore.getState() const marker = store.clickMarker @@ -2097,31 +1891,23 @@ const MapView = forwardRef(function MapView(_, ref) { }) } } else { - // Outside circle → clear current selection and fall through to select new + // Outside circle → deselect, no new selection store.clearClickMarker() store.clearSelectedPlace() // Clear boundary when deselecting if (updateBoundaryRef.current) updateBoundaryRef.current(null) setSelectedHighlight(map, null) - // Fall through to State A to select new feature at click point } - } - - // Select new feature at click point (State A or after clearing previous selection) - { - const store = useStore.getState() // refresh store state after potential clear - if (store.clickMarker) return // already handled above - + } else { + // State A: nothing selected → select if (window.innerWidth < 768) setSheetState('collapsed') const { lng, lat } = e.lngLat 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].filter(id => map.getLayer(id)) - const usfsFeatures = usfsLayers.length > 0 - ? map.queryRenderedFeatures(e.point, { layers: usfsLayers }) - : [] + const usfsLayers = [USFS_TRAILS_HIT, USFS_ROADS_HIT] + const usfsFeatures = map.queryRenderedFeatures(e.point, { layers: usfsLayers }) const usfsFeature = usfsFeatures.find(f => f.properties) if (usfsFeature && hasFeature('has_usfs_trails')) { const props = usfsFeature.properties @@ -2167,10 +1953,8 @@ const MapView = forwardRef(function MapView(_, ref) { } // Check for BLM routes click (show info popup) - const blmLayers = [BLM_ROUTES_HIT].filter(id => map.getLayer(id)) - const blmFeatures = blmLayers.length > 0 - ? map.queryRenderedFeatures(e.point, { layers: blmLayers }) - : [] + const blmLayers = [BLM_ROUTES_HIT] + const blmFeatures = map.queryRenderedFeatures(e.point, { layers: blmLayers }) const blmFeature = blmFeatures.find(f => f.properties) if (blmFeature && hasFeature("has_blm_trails")) { const props = blmFeature.properties @@ -2224,35 +2008,12 @@ const MapView = forwardRef(function MapView(_, ref) { const props = labelFeature.properties const geom = labelFeature.geometry - // CRITICAL: Always use CLICK coordinates for routing (lat, lng from e.lngLat) - // Feature coordinates are only for display/fetching details - let featureLat = lat // Click coordinate - used for routing - let featureLon = lng // Click coordinate - used for routing - let displayLat = lat // May be updated to feature coords for display - let displayLon = lng + // Get feature coordinates (Point geometry) + let featureLat = lat + let featureLon = lng if (geom && geom.type === 'Point' && geom.coordinates) { - // Store feature's canonical coords separately - NOT for routing - displayLon = geom.coordinates[0] - displayLat = geom.coordinates[1] - } - - // FIX A: For park-type features, also query polygon layers to get boundary geometry - const parkKinds = ['national_park', 'park', 'cemetery', 'protected_area', 'nature_reserve', 'forest', 'golf_course', 'wood', 'zoo', 'garden'] - let polygonGeometry = null - if (parkKinds.includes(props.kind)) { - // Query fill layers at the same point to find the polygon - const fillLayers = ['landuse_park', 'landuse_other'].filter(id => map.getLayer(id)) - if (fillLayers.length > 0) { - const fillFeatures = map.queryRenderedFeatures(e.point, { layers: fillLayers }) - // Find a polygon feature with matching name or at the same location - const matchingPolygon = fillFeatures.find(f => - f.properties?.name === props.name || - (f.geometry?.type === 'Polygon' || f.geometry?.type === 'MultiPolygon') - ) - if (matchingPolygon?.geometry) { - polygonGeometry = matchingPolygon.geometry - } - } + featureLon = geom.coordinates[0] + featureLat = geom.coordinates[1] } // Apply feature state highlight @@ -2273,12 +2034,6 @@ const MapView = forwardRef(function MapView(_, ref) { // For feature clicks, don't show pin marker store.clearClickMarker() - // If we found polygon geometry from the fill layer, use it as boundary directly - if (polygonGeometry && updateBoundaryRef.current) { - updateBoundaryRef.current(polygonGeometry) - } - - console.log('[TRACE-CLICK] Feature click setSelectedPlace:', { featureLat, featureLon, clickLat: lat, clickLng: lng, name: props.name }) store.setSelectedPlace({ lat: featureLat, lon: featureLon, @@ -2297,7 +2052,6 @@ const MapView = forwardRef(function MapView(_, ref) { kind: props.kind || null, kind_detail: props.kind_detail || null, elevation: props.elevation || null, - polygonGeometry: polygonGeometry || null, // Store polygon if found }, }) } else { @@ -2310,7 +2064,6 @@ const MapView = forwardRef(function MapView(_, ref) { circleRadiusPx: MARKER_RADIUS_PX, }) - console.log('[TRACE-CLICK] Reticle click setSelectedPlace:', { lat, lng }) store.setSelectedPlace({ lat, lon: lng, @@ -2361,17 +2114,6 @@ 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, { @@ -2388,9 +2130,6 @@ const MapView = forwardRef(function MapView(_, ref) { // Apply improved base label styling for readability applyBaseLabelStyling(map) - // FIX D: Add state/province boundary lines at z4-z7 - addStateBoundaries(map, currentThemeRef.current) - // Restore overlay layers from localStorage prefs try { const raw = localStorage.getItem('navi-layer-prefs') @@ -2400,9 +2139,7 @@ const MapView = forwardRef(function MapView(_, ref) { addHillshade(map, currentThemeRef.current) activeLayersRef.current.hillshade = true } - // 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) { + if (prefs.traffic && hasFeature('has_traffic_overlay')) { addTraffic(map, currentThemeRef.current) activeLayersRef.current.traffic = true } @@ -2439,11 +2176,39 @@ const MapView = forwardRef(function MapView(_, ref) { properties: {}, }) + console.log("BOUNDARY DEBUG:") + console.log(" source exists:", !!map.getSource(BOUNDARY_SOURCE)) + console.log(" line layer exists:", !!map.getLayer(BOUNDARY_LAYER)) + console.log(" fill layer exists:", !!map.getLayer(BOUNDARY_FILL_LAYER)) + console.log(" line-color:", map.getPaintProperty(BOUNDARY_LAYER, "line-color")) + console.log(" line-opacity:", map.getPaintProperty(BOUNDARY_LAYER, "line-opacity")) + console.log(" line-width:", map.getPaintProperty(BOUNDARY_LAYER, "line-width")) + console.log(" fill-color:", map.getPaintProperty(BOUNDARY_FILL_LAYER, "fill-color")) + console.log(" visibility:", map.getLayoutProperty(BOUNDARY_LAYER, "visibility")) + console.log(" feature count:", source._data?.features?.length || "unknown") + console.log(" geometry type:", boundaryGeometry.type) + console.log(" coord count:", boundaryGeometry.type === "Polygon" ? boundaryGeometry.coordinates[0]?.length : boundaryGeometry.coordinates?.length) + console.log(" first coord:", boundaryGeometry.type === "Polygon" ? boundaryGeometry.coordinates[0]?.[0] : boundaryGeometry.coordinates?.[0]?.[0]?.[0]) + console.log(" map center:", map.getCenter()) + console.log(" map zoom:", map.getZoom()) + + // Check if layer is actually in style + const styleLayers = map.getStyle().layers + const boundaryLayerInStyle = styleLayers.find(l => l.id === BOUNDARY_LAYER) + console.log(" layer in style.layers:", !!boundaryLayerInStyle) + if (boundaryLayerInStyle) { + console.log(" layer def:", JSON.stringify(boundaryLayerInStyle)) + } + + // Force repaint + map.triggerRepaint() + console.log(" triggered repaint") + // Zoom to fit boundary try { const coords = boundaryGeometry.type === 'Polygon' ? boundaryGeometry.coordinates[0] - : boundaryGeometry.coordinates.flat(2) + : boundaryGeometry.coordinates.flat(1) if (coords.length > 0) { let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity @@ -2453,18 +2218,11 @@ const MapView = forwardRef(function MapView(_, ref) { if (lat < minLat) minLat = lat if (lat > maxLat) maxLat = lat } - // Validate bounds before fitting - if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && - minLng < maxLng && minLat < maxLat) { - // FIX B: ALWAYS fitBounds when boundary exists - zoom in OR out - // The boundary defines what the user should see - const bounds = [[minLng, minLat], [maxLng, maxLat]] - map.fitBounds(bounds, { - padding: 50, - duration: 700, - maxZoom: 16, - }) - } + map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + padding: 50, + duration: 700, + maxZoom: 16, + }) } } catch (e) { console.warn('fitBounds error:', e) @@ -2474,12 +2232,6 @@ const MapView = forwardRef(function MapView(_, ref) { updateBoundaryRef.current = updateBoundaryFn useStore.getState().setUpdateBoundary(updateBoundaryFn) - // Register route display callbacks for store.computeRoute() - useStore.getState().setRouteDisplayCallbacks( - (routeGeojson) => updateRouteDisplay(map, routeGeojson), - () => clearRouteDisplay(map) - ) - // POI/label hover affordance — cursor pointer + highlight const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace'] @@ -2615,33 +2367,25 @@ const MapView = forwardRef(function MapView(_, ref) { // Apply improved base label styling for readability applyBaseLabelStyling(map) - // FIX D: Re-add state boundaries with new theme colors - addStateBoundaries(map, currentThemeRef.current) - // Re-add active overlay layers if (activeLayersRef.current.hillshade) addHillshade(map, currentThemeRef.current) if (activeLayersRef.current.traffic) addTraffic(map, currentThemeRef.current) if (activeLayersRef.current.publicLands) addPublicLands(map, currentThemeRef.current) if (activeLayersRef.current.contours) addContours(map, currentThemeRef.current) + if (activeLayersRef.current.contoursTest) addContoursTest(map, currentThemeRef.current) + if (activeLayersRef.current.contoursTest10ft) addContoursTest10ft(map, currentThemeRef.current) if (activeLayersRef.current.usfsTrails) addUsfsTrails(map, currentThemeRef.current) if (activeLayersRef.current.blmTrails) addBlmTrails(map, currentThemeRef.current) - // 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 - const currentRoute = useStore.getState().routeResult - if (currentRoute?.route) updateRouteDisplay(map, currentRoute.route) + map.jumpTo({ center, zoom, bearing, pitch }) + // Re-render route if exists + const currentRoute = useStore.getState().route + if (currentRoute) updateRoute(map, currentRoute) }) }, [theme]) @@ -2656,29 +2400,11 @@ const MapView = forwardRef(function MapView(_, ref) { previewMarkerRef.current = null } - if (!selectedPlace) { - lastFlyTargetRef.current = null - return - } + if (!selectedPlace) return - // Track place identity - only fly on NEW place selection, not metadata updates - const placeKey = `${selectedPlace.lat}-${selectedPlace.lon}-${selectedPlace.name}` - if (placeKey === lastFlyTargetRef.current) { - // Same place, skip flyTo (this is just a metadata update) - } else { - lastFlyTargetRef.current = placeKey - - // FIX B: Camera behavior depends on source and whether boundary exists - // - map_click / basemap_label: NO camera movement (boundary fitBounds handles it if exists) - // - search results: fly to center, but DON'T change zoom (user chose their zoom) - if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { - // Search result - fly to center without changing zoom - // Note: if this place has a boundary, the boundary fitBounds will zoom appropriately - const currentZoom = map.getZoom() - map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: currentZoom, duration: 800 }) - } - // For map_click and basemap_label: do nothing to camera - // The boundary fitBounds will handle zooming if a boundary is fetched + // Only fly to place if it came from search (not map-click which already centered) + if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { + map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) } // Different visual feedback based on mode @@ -2734,6 +2460,168 @@ const MapView = forwardRef(function MapView(_, ref) { return () => document.removeEventListener('keydown', handleKeyDown) }, [selectedPlace]) + // Update route polyline when route changes + useEffect(() => { + const map = mapInstance.current + if (!map) return + if (!map.isStyleLoaded()) { + const handler = () => updateRoute(map, route) + map.once('idle', handler) + return () => map.off('idle', handler) + } + updateRoute(map, route) + }, [route]) + + function updateRoute(map, routeData) { + if (!map) return + + // Remove old route layers + const style = map.getStyle() + if (style) { + for (const layer of style.layers) { + if (layer.id.startsWith(ROUTE_LAYER_PREFIX)) { + map.removeLayer(layer.id) + } + } + } + + if (!routeData || !routeData.legs) { + if (map.getSource(ROUTE_SOURCE)) { + map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] }) + } + return + } + + const features = [] + for (let i = 0; i < routeData.legs.length; i++) { + const leg = routeData.legs[i] + if (!leg.shape) continue + const coords = decodePolyline(leg.shape, 6) + features.push({ + type: 'Feature', + properties: { legIndex: i }, + geometry: { type: 'LineString', coordinates: coords }, + }) + } + + const source = map.getSource(ROUTE_SOURCE) + if (source) { + source.setData({ type: 'FeatureCollection', features }) + } else { + map.addSource(ROUTE_SOURCE, { + type: 'geojson', + data: { type: 'FeatureCollection', features }, + }) + } + + // Use CSS variable for route color (read computed value) + const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim() + + for (let i = 0; i < features.length; i++) { + const layerId = `${ROUTE_LAYER_PREFIX}${i}` + if (!map.getLayer(layerId)) { + map.addLayer({ + id: layerId, + type: 'line', + source: ROUTE_SOURCE, + filter: ['==', ['get', 'legIndex'], i], + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { + 'line-color': routeColor || '#7a9a6b', + 'line-width': 5, + 'line-opacity': 0.85, + }, + }) + } + } + + // Fit bounds to route + if (features.length > 0) { + const allCoords = features.flatMap((f) => f.geometry.coordinates) + const bounds = allCoords.reduce( + (b, c) => b.extend(c), + new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) + ) + // Single-panel: no floating detail + const leftPad = 420 // 360px panel + margin + map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) + } + } + + // Update stop markers when stops change + useEffect(() => { + const map = mapInstance.current + if (!map) return + + // Remove old markers + for (const m of markersRef.current) m.remove() + markersRef.current = [] + if (popupRef.current) { + popupRef.current.remove() + popupRef.current = null + } + + const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' + const indexOffset = hasGpsOrigin ? 1 : 0 + + stops.forEach((stop, i) => { + const displayIndex = i + indexOffset + const effectiveTotal = stops.length + indexOffset + + let pinClass = 'navi-pin navi-pin--intermediate' + if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin' + else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination' + + const label = String.fromCharCode(65 + Math.min(displayIndex, 25)) + + const el = document.createElement('div') + el.className = pinClass + el.textContent = label + + el.addEventListener('click', (e) => { + e.stopPropagation() + // Flag so the map-level click handler doesn't fire + pinClickedRef.current = true + if (popupRef.current) popupRef.current.remove() + const popup = new maplibregl.Popup({ offset: 20, closeButton: true }) + .setLngLat([stop.lon, stop.lat]) + .setHTML( + `
+ ${stop.name} +
+
` + ) + .addTo(map) + + popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => { + useStore.getState().removeStop(stop.id) + popup.remove() + }) + popupRef.current = popup + }) + + const marker = new maplibregl.Marker({ element: el }) + .setLngLat([stop.lon, stop.lat]) + .addTo(map) + + markersRef.current.push(marker) + }) + + // If stops but no route yet, fit to stops + if (stops.length > 0 && !route) { + if (stops.length === 1) { + map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 }) + } else { + const bounds = stops.reduce( + (b, s) => b.extend([s.lon, s.lat]), + new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]) + ) + map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 420, right: 60 } }) + } + } + }, [stops, route, gpsOrigin, geoPermission]) + + // ESC key handler for measurement mode useEffect(() => { const handleKeyDown = (e) => { @@ -2759,22 +2647,6 @@ const MapView = forwardRef(function MapView(_, ref) { } }, [pickingLocationFor]) - // Handle directions mode cursor - useEffect(() => { - const map = mapInstance.current - if (!map) return - if (directionsMode && activeDirectionsField) { - map.getCanvas().style.cursor = 'crosshair' - } else if (!measuringRef.current.active && !pickingLocationFor) { - map.getCanvas().style.cursor = '' - } - return () => { - if (map && !measuringRef.current.active && !pickingLocationFor) { - map.getCanvas().style.cursor = '' - } - } - }, [directionsMode, activeDirectionsField]) - // ESC key handler for location pick mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 6f0a0ac..2799a89 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,62 +1,53 @@ import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin, Target } from 'lucide-react' +import { LogIn, LogOut } from 'lucide-react' import ThemePicker from './ThemePicker' import { useStore, usePanelState } from '../store' import { hasFeature } from '../config' -import { 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 DirectionsPanel from './DirectionsPanel' -import PlaceDetail from './PlaceDetail' +import { requestOptimizedRoute } from '../api' -const TRAVEL_MODES = [ - { id: 'auto', label: 'Drive', Icon: Car }, - { id: 'foot', label: 'Foot', Icon: Footprints }, - { id: 'mtb', label: 'MTB', Icon: Bike }, - { id: 'atv', label: 'ATV', Icon: Car }, - { id: 'vehicle', label: '4x4', Icon: Car }, -] - -const BOUNDARY_MODES = [ - { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, - { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, - { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, -] - -export default function Panel({ onClearRoute }) { +export default function Panel({ onManeuverClick }) { const selectedPlace = useStore((s) => s.selectedPlace) + const pendingDestination = useStore((s) => s.pendingDestination) const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) - const routeStart = useStore((s) => s.routeStart) - const routeEnd = useStore((s) => s.routeEnd) - const routeMode = useStore((s) => s.routeMode) - const boundaryMode = useStore((s) => s.boundaryMode) - const routeResult = useStore((s) => s.routeResult) + const clearPendingDestination = useStore((s) => s.clearPendingDestination) + const stops = useStore((s) => s.stops) + const mode = useStore((s) => s.mode) + const route = useStore((s) => s.route) const routeLoading = useStore((s) => s.routeLoading) - const setRouteMode = useStore((s) => s.setRouteMode) - const setBoundaryMode = useStore((s) => s.setBoundaryMode) - const pickingRouteField = useStore((s) => s.pickingRouteField) - const setPickingRouteField = useStore((s) => s.setPickingRouteField) - const clearRoute = useStore((s) => s.clearRoute) + const routeError = useStore((s) => s.routeError) + const setStops = useStore((s) => s.setStops) + const setRoute = useStore((s) => s.setRoute) + const setRouteError = useStore((s) => s.setRouteError) + const setRouteLoading = useStore((s) => s.setRouteLoading) const sheetState = useStore((s) => s.sheetState) const setSheetState = useStore((s) => s.setSheetState) + const theme = useStore((s) => s.theme) + const themeOverride = useStore((s) => s.themeOverride) + const setThemeOverride = useStore((s) => s.setThemeOverride) + const gpsOrigin = useStore((s) => s.gpsOrigin) + const geoPermission = useStore((s) => s.geoPermission) const activeTab = useStore((s) => s.activeTab) const auth = useStore((s) => s.auth) const setActiveTab = useStore((s) => s.setActiveTab) - const directionsMode = useStore((s) => s.directionsMode) - const setDirectionsMode = useStore((s) => s.setDirectionsMode) const panelState = usePanelState() const [isMobile, setIsMobile] = useState(false) + const [optimizing, setOptimizing] = useState(false) const sheetRef = useRef(null) const dragStartY = useRef(0) const dragStartState = useRef('half') + // Show contacts tab only if feature enabled AND user is authenticated const showContacts = hasFeature('has_contacts') && auth.authenticated - const cfg = useConfig() + // Responsive detection useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) check() @@ -64,13 +55,61 @@ export default function Panel({ onClearRoute }) { return () => window.removeEventListener('resize', check) }, []) - // 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/' } + // Auth handlers + const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } + const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } + // Optimize stops + const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' + const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0) + + const handleOptimize = useCallback(async () => { + if (effectiveCount < 3 || optimizing) return + setOptimizing(true) + try { + const { userLocation } = useStore.getState() + let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon })) + if (hasGpsOrigin && userLocation) { + locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations] + } + const data = await requestOptimizedRoute(locations, mode) + if (data.trip) { + const wpOrder = hasGpsOrigin && userLocation + ? (data.trip.locations || []).slice(1) + : data.trip.locations + if (wpOrder && wpOrder.length === stops.length) { + const reordered = wpOrder.map((wp) => { + let closest = stops[0] + let minDist = Infinity + for (const s of stops) { + const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon) + if (d < minDist) { + minDist = d + closest = s + } + } + return closest + }) + const seen = new Set() + const unique = reordered.filter((s) => { + if (seen.has(s.id)) return false + seen.add(s.id) + return true + }) + if (unique.length === stops.length) { + setStops(unique) + } + } + setRoute(data.trip) + } + } catch (e) { + setRouteError(e.message) + } finally { + setOptimizing(false) + } + }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError]) + + // Mobile sheet drag handling const handleTouchStart = useCallback((e) => { dragStartY.current = e.touches[0].clientY dragStartState.current = sheetState @@ -88,30 +127,21 @@ export default function Panel({ onClearRoute }) { } }, [setSheetState]) - const handleClearRoute = () => { - clearRoute() - onClearRoute?.() - } + const showOptimize = effectiveCount >= 3 + // Determine what to show based on panel state const showPreviewCard = panelState.startsWith('PREVIEW') - const hasRoutePoints = routeStart || routeEnd - const showRouteSection = hasRoutePoints || routeResult || routeLoading - const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination + const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED' + const showEmptyState = panelState === 'IDLE' && !pendingDestination - // Show side panel place card when building route (either mode) and place is selected - const showSidePlaceCard = (directionsMode || showRouteSection) && selectedPlace - - const routesContent = directionsMode ? ( - // Directions mode: just the directions panel, place card is shown in side panel - { - setDirectionsMode(false) - onClearRoute?.() - }} /> - ) : ( + // Routes tab content - now state-driven + const routesContent = ( <> - {showPreviewCard && selectedPlace && !showRouteSection && ( + {/* Preview card when place is selected */} + {showPreviewCard && selectedPlace && (
)} + {/* Route section with stops */} {showRouteSection && ( + <> +
+ +
+ +
+ + {showOptimize && ( + + )} + {pendingDestination && stops.length === 0 && ( + + )} +
+ + )} + + {/* Maneuvers when route is calculated */} + {showManeuvers && (route || routeLoading || routeError) && (
-
- - Route - - -
- -
-
- - - {routeStart?.name || 'Click pin to pick start'} - - -
-
- - - {routeEnd?.name || 'Click pin to pick destination'} - - -
-
- -
- {TRAVEL_MODES.map((m) => { - const active = routeMode === m.id - return ( - - ) - })} -
- - {routeMode !== 'auto' && ( -
- {BOUNDARY_MODES.map((m) => { - const active = boundaryMode === m.id - return ( - - ) - })} -
- )} - - +
)} + {/* Empty state */} {showEmptyState && (

Search or tap the map to explore

@@ -229,13 +203,13 @@ export default function Panel({ onClearRoute }) { {showContacts && (
) - // Side panel for place card during directions mode (desktop only) - const sidePlaceCardPanel = showSidePlaceCard && !isMobile && ( -
-
- - {selectedPlace?.name || 'Place Info'} - - -
- {/* Use PlaceCard in compact preview mode */} - -
- ) - - // Mobile overlay for place card during directions mode - const mobilePlaceCardOverlay = showSidePlaceCard && isMobile && ( -
-
- - {selectedPlace?.name || 'Place Info'} - - -
-
- -
-
- ) - + // Desktop: side panel (now 360px to accommodate PlaceCard) if (!isMobile) { return ( - <> -
- {header} - {content} -
- {sidePlaceCardPanel} - +
+ {header} + {content} +
) } + // Mobile: bottom sheet const sheetHeights = { collapsed: 'h-12', half: 'h-[45vh]', @@ -370,12 +280,13 @@ export default function Panel({ onClearRoute }) { return (
+ {/* Drag handle */}
{sheetState !== 'collapsed' && ( -
+
{header} {content} - {mobilePlaceCardOverlay}
)}
diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 84ecb6b..8fab953 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -352,9 +352,6 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl if (placeLat == null || placeLon == null) return // Skip for dropped pins - they get reverse geocoded by MapView if (place?.source === 'map_click') return - // Don't reverse geocode if we already identified the entity from a label click - // The basemap label provides name, kind, wikidata - reverse geocode would return wrong entity - if (place?.source === 'basemap_label' && place?.raw?.kind) return const controller = new AbortController() fetchReverse(placeLat, placeLon).then((result) => { @@ -362,6 +359,8 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl if (result?.raw?.osm_type && result?.raw?.osm_id) { const current = useStore.getState().selectedPlace if (current && current.lat === placeLat && current.lon === placeLon) { + // Skip if OSM data already set (e.g., by wikidata path with osm_relation_id) + if (current.raw?.osm_type && current.raw?.osm_id) return // Merge OSM data into raw, preserving existing data useStore.getState().setSelectedPlace({ ...current, @@ -396,7 +395,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl }, [osmType, osmId, placeLat, placeLon]) useEffect(() => { - if (osmType && osmId) return + // Always run wikidata path when wikidataId is present if (!wikidataId) return const controller = new AbortController() fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => { @@ -408,13 +407,13 @@ 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) { + // Set OSM data from wikidata response so Effect 3 can fetch wiki_summary + 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 } + raw: { ...current.raw, osm_type: "R", osm_id: data.osm_relation_id } }) } } @@ -430,7 +429,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl } }) return () => controller.abort() - }, [wikidataId, osmType, osmId, placeLat, placeLon]) + }, [wikidataId, placeLat, placeLon]) useEffect(() => { if (variant !== "preview" || !userLocation || placeLat == null || placeLon == null) { setDriveTime(null); return } @@ -476,7 +475,6 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon) const handleDirections = () => { - console.log('[TRACE-DIRECTIONS] PlaceCard handleDirections, place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) // No toast - empty origin slot is the visual prompt startDirections(place) } diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx index 1215a08..2e47bd9 100644 --- a/src/components/SearchBar.jsx +++ b/src/components/SearchBar.jsx @@ -6,30 +6,6 @@ import { buildAddress } from '../utils/place' import { searchGeocode } from '../api' import { hasFeature } from '../config' - -/** Parse coordinate input like "42.35, -114.30" or "42.35 -114.30" */ -function parseCoordinates(input) { - if (!input) return null - const trimmed = input.trim() - - // Pattern: lat, lon or lat lon (with optional comma) - // Supports: "42.35, -114.30", "42.35 -114.30", "42.35,-114.30" - const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ - const match = trimmed.match(pattern) - - if (!match) return null - - const lat = parseFloat(match[1]) - const lon = parseFloat(match[2]) - - // Validate ranges - if (isNaN(lat) || isNaN(lon)) return null - if (lat < -90 || lat > 90) return null - if (lon < -180 || lon > 180) return null - - return { lat, lon } -} - /** Get category icon based on result type/source */ function CategoryIcon({ result }) { const type = result.type || '' @@ -95,25 +71,6 @@ const SearchBar = forwardRef(function SearchBar(_, ref) { return } - // Check for coordinate input first - const coords = parseCoordinates(q) - if (coords) { - const coordResult = { - lat: coords.lat, - lon: coords.lon, - name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), - address: "Coordinates", - type: "coordinates", - source: "coordinates", - match_code: null, - raw: {}, - } - setResults([coordResult]) - setAutocompleteOpen(true) - setSearchLoading(false) - return - } - // Prepend matching contacts let contactResults = [] if (hasFeature('has_contacts') && contacts.length > 0) { diff --git a/src/components/ThemePicker.jsx b/src/components/ThemePicker.jsx index 15084a9..b9f7792 100644 --- a/src/components/ThemePicker.jsx +++ b/src/components/ThemePicker.jsx @@ -1,176 +1,167 @@ -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) => ( - - ))} -
-
- )} -
- ) -} +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) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/src/config.js b/src/config.js index b5da749..274edba 100644 --- a/src/config.js +++ b/src/config.js @@ -10,7 +10,7 @@ const FALLBACK_CONFIG = { profile: 'home', region_name: 'North America', tileset: { - url: '/tiles/planet/current.pmtiles', + url: '/tiles/na.pmtiles', bounds: [-168, 14, -52, 72], max_zoom: 15, attribution: 'Protomaps © OSM', @@ -21,10 +21,6 @@ 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, @@ -34,6 +30,8 @@ const FALLBACK_CONFIG = { has_landclass: false, has_public_lands_layer: false, has_contours: true, + has_contours_test: true, + has_contours_test_10ft: false, has_address_book_write: false, has_usfs_trails: false, has_blm_trails: false, diff --git a/src/index.css b/src/index.css index 2673a26..6a2bd4c 100644 --- a/src/index.css +++ b/src/index.css @@ -236,117 +236,46 @@ body { opacity: 1; } -/* ═══ BOTTOM-RIGHT MAP CONTROLS ═══ */ -.map-controls-br { - position: absolute; - bottom: 80px; - right: 10px; - z-index: 10; - display: flex; - flex-direction: column; - gap: 8px; -} - -.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: 10px; - color: var(--text-secondary); - cursor: pointer; - box-shadow: var(--shadow); - 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; + position: absolute; + bottom: 32px; + right: 10px; + z-index: 10; } .layer-control-btn { - width: 44px; - height: 44px; + width: 36px; + height: 36px; display: flex; align-items: center; justify-content: center; background: var(--bg-raised); border: 1px solid var(--border); - border-radius: 10px; + border-radius: 8px; color: var(--text-secondary); cursor: pointer; box-shadow: var(--shadow); - transition: color 0.15s, border-color 0.15s, background 0.15s; + transition: color 0.1s, border-color 0.1s; } .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: 0; - right: 52px; - min-width: 180px; + bottom: 44px; + right: 0; + min-width: 160px; background: var(--bg-raised); border: 1px solid var(--border); - border-radius: 10px; + border-radius: 8px; 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); @@ -438,28 +367,27 @@ body { /* ═══ LOCATE BUTTON ═══ */ .locate-btn { - width: 44px; - height: 44px; + position: absolute; + bottom: 80px; + right: 10px; + z-index: 10; + width: 36px; + height: 36px; display: flex; align-items: center; justify-content: center; background: var(--bg-raised); border: 1px solid var(--border); - border-radius: 10px; + border-radius: 8px; color: var(--text-secondary); cursor: pointer; box-shadow: var(--shadow); - transition: color 0.15s, border-color 0.15s, background 0.15s; + transition: color 0.1s, border-color 0.1s; } .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) ═══ */ @@ -478,15 +406,16 @@ body { overflow-x: hidden; } - .map-controls-br { - bottom: 70px; - right: 8px; + .layer-control { + bottom: auto; + top: 120px; + right: 10px; } - .layer-control-popover { - right: 52px; - max-height: 60vh; - overflow-y: auto; + .locate-btn { + bottom: auto; + top: 166px; + right: 10px; } .stop-remove-btn { @@ -572,14 +501,3 @@ 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 069be9f..9b4aa30 100644 --- a/src/store.js +++ b/src/store.js @@ -1,314 +1,150 @@ -import { create } from "zustand" -import { requestOffroute } from "./api" - -export const useStore = create((set, get) => ({ - // ── Search state ── - query: "", - results: [], - searchLoading: false, - abortController: null, - - setQuery: (query) => set({ query }), - setResults: (results) => set({ results }), - setSearchLoading: (loading) => set({ searchLoading: loading }), - setAbortController: (ctrl) => set({ abortController: ctrl }), - - // ── Geolocation ── - userLocation: null, // { lat, lon } - geoPermission: "prompt", // "prompt" | "granted" | "denied" - - setUserLocation: (loc) => set({ userLocation: loc }), - setGeoPermission: (p) => set({ geoPermission: p }), - - // ── Map viewport (for search bias) ── - mapCenter: null, // { lat, lon, zoom } - setMapCenter: (center) => set({ mapCenter: center }), - - // ── Unified Route State ── - // routeStart = origin (source of truth) - // routeEnd = destination (source of truth) - // stops[] = ONLY intermediate waypoints (not origin/destination) - routeStart: null, // { lat, lon, name } - routeEnd: null, // { lat, lon, name } - stops: [], // Intermediate waypoints only: [{ id, lat, lon, name }, ...] - routeMode: "auto", // foot | mtb | atv | vehicle - boundaryMode: "strict", // strict | pragmatic | emergency - routeResult: null, // Response from /api/offroute - routeLoading: false, - routeError: null, - - // Map display callback - set by MapView - _updateRouteDisplay: null, - _clearRouteDisplay: null, - setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }), - - setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), - setRouteEnd: (place) => set({ routeEnd: place }), - setRouteResult: (result) => set({ routeResult: result, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, routeResult: null }), - - // Mode/boundary setters that trigger recalculation - setRouteMode: (mode) => { - set({ routeMode: mode }) - get().computeRoute() - }, - setBoundaryMode: (mode) => { - set({ boundaryMode: mode }) - get().computeRoute() - }, - - clearRoute: () => { - const { _clearRouteDisplay } = get() - if (_clearRouteDisplay) _clearRouteDisplay() - set({ - routeStart: null, - routeEnd: null, - stops: [], - routeResult: null, - routeError: null, - }) - }, - - // ── INTERMEDIATE STOPS MANAGEMENT ── - // stops[] contains ONLY intermediate waypoints, not origin/destination - - // Add intermediate stop - can be called with or without place - // With place: creates pre-filled stop (from radial menu) - // Without place: creates empty placeholder (from Add Stop button) - addIntermediateStop: (place) => { - const { stops } = get() - if (stops.length >= 8) return false // Max 8 intermediate stops - const newStop = { - id: crypto.randomUUID(), - lat: place?.lat ?? null, - lon: place?.lon ?? null, - name: place?.name ?? "", - } - set({ stops: [...stops, newStop] }) - return true - }, - - updateStop: (id, place) => { - const { stops } = get() - const newStops = stops.map((s) => - s.id === id ? { ...s, lat: place.lat, lon: place.lon, name: place.name } : s - ) - set({ stops: newStops }) - // Trigger route recalculation if all waypoints have coordinates - get().computeRoute() - }, - - removeStop: (id) => { - const { stops } = get() - const newStops = stops.filter((s) => s.id !== id) - set({ stops: newStops }) - // Recalculate route without this stop - get().computeRoute() - }, - - setStops: (stops) => set({ stops }), - - // ── UNIFIED ROUTING TRIGGER ── - // Handles both 2-point and multi-point routing - computeRoute: async () => { - const { routeStart, routeEnd, stops, routeMode, boundaryMode, _updateRouteDisplay } = get() - - // Need both endpoints to route - if (!routeStart || !routeEnd) return - - // Filter out incomplete stops (no coordinates yet) - const validStops = stops.filter((s) => s.lat != null && s.lon != null) - - // Build full waypoint list: [origin, ...intermediates, destination] - const waypoints = [ - routeStart, - ...validStops, - routeEnd, - ] - - console.log("[TRACE-ROUTE] computeRoute with waypoints:", waypoints.length, waypoints.map(w => w.name)) - - set({ routeLoading: true, routeError: null }) - - try { - if (waypoints.length === 2) { - // Simple 2-point routing - const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) - if (data.status === "ok" && data.route) { - set({ routeResult: data, routeError: null }) - if (_updateRouteDisplay) _updateRouteDisplay(data.route) - } else { - set({ routeError: data.message || data.error || "No route found", routeResult: null }) - } - } else { - // Multi-point routing: chain sequential 2-point routes and merge - const segments = [] - let totalDistanceKm = 0 - let totalEffortMinutes = 0 - let allFeatures = [] - - for (let i = 0; i < waypoints.length - 1; i++) { - const from = waypoints[i] - const to = waypoints[i + 1] - const segmentData = await requestOffroute(from, to, routeMode, boundaryMode) - - if (segmentData.status !== "ok" || !segmentData.route) { - throw new Error("No route found between " + (from.name || "waypoint") + " and " + (to.name || "waypoint")) - } - - segments.push(segmentData) - - // Accumulate totals - if (segmentData.summary) { - totalDistanceKm += segmentData.summary.total_distance_km || 0 - totalEffortMinutes += segmentData.summary.total_effort_minutes || 0 - } - - // Collect features - if (segmentData.route?.features) { - allFeatures.push(...segmentData.route.features) - } - } - - // Build merged result - const mergedResult = { - status: "ok", - summary: { - total_distance_km: totalDistanceKm, - total_effort_minutes: totalEffortMinutes, - waypoint_count: waypoints.length, - }, - route: { - type: "FeatureCollection", - features: allFeatures, - }, - } - - set({ routeResult: mergedResult, routeError: null }) - if (_updateRouteDisplay) _updateRouteDisplay(mergedResult.route) - } - } catch (e) { - set({ routeError: e.message, routeResult: null }) - } finally { - set({ routeLoading: false }) - } - }, - - // ── Legacy compatibility ── - gpsOrigin: true, - pendingDestination: null, - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - // Master startDirections - enters directions mode with destination pre-filled - startDirections: (place) => { - console.log("[TRACE-STORE] startDirections received place:", { lat: place?.lat, lon: place?.lon, name: place?.name }) - const { geoPermission, userLocation, clearRoute } = get() - clearRoute() - - const destination = { - lat: place.lat, - lon: place.lon, - name: place.name, - source: place.source, - matchCode: place.matchCode, - } - - let origin = null - if (geoPermission === "granted" && userLocation) { - origin = { - lat: userLocation.lat, - lon: userLocation.lon, - name: "Your location", - source: "gps", - } - } - - set({ - routeEnd: destination, - routeStart: origin, - directionsMode: true, - activeDirectionsField: origin ? null : "origin", - selectedPlace: null, - }) - }, - - // ── Place detail ── - selectedPlace: null, - clickMarker: null, - - setSelectedPlace: (place) => set({ selectedPlace: place }), - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - - // ── UI state ── - sheetState: "half", - panelOpen: true, - autocompleteOpen: false, - directionsMode: false, - activeDirectionsField: null, - pickingRouteField: null, - theme: "dark", - themeOverride: null, - viewMode: (typeof localStorage !== "undefined" && localStorage.getItem("navi-view-mode")) || "map", - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem("navi-view-mode", mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? "origin" : null }), - setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), - setPickingRouteField: (field) => set({ pickingRouteField: field }), - clearPickingRouteField: () => set({ pickingRouteField: null }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem("navi-theme-override", override) - } else { - localStorage.removeItem("navi-theme-override") - } - }, - - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: "routes", - editingContact: null, - pickingLocationFor: null, - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.routeResult - const hasRoutePoints = !!s.routeStart || !!s.routeEnd - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasRoutePoints) return "ROUTING" - return "IDLE" - }) -} +import { create } from 'zustand' + +export const useStore = create((set, get) => ({ + // ── Search state ── + query: '', + results: [], + searchLoading: false, + abortController: null, + + setQuery: (query) => set({ query }), + setResults: (results) => set({ results }), + setSearchLoading: (loading) => set({ searchLoading: loading }), + setAbortController: (ctrl) => set({ abortController: ctrl }), + + // ── Stop list ── + stops: [], + // Each stop: { id, lat, lon, name, source, matchCode, isOrigin } + + addStop: (stop) => { + const { stops } = get() + if (stops.length >= 10) return false + set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] }) + return true + }, + + removeStop: (id) => { + set({ stops: get().stops.filter((s) => s.id !== id) }) + }, + + reorderStops: (newStops) => set({ stops: newStops }), + + clearStops: () => set({ stops: [] }), + + setStops: (stops) => set({ stops }), + + // ── Geolocation ── + userLocation: null, // { lat, lon } + geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' + + setUserLocation: (loc) => set({ userLocation: loc }), + setGeoPermission: (p) => set({ geoPermission: p }), + + // ── Map viewport (for search bias) ── + mapCenter: null, // { lat, lon, zoom } + setMapCenter: (center) => set({ mapCenter: center }), + + // ── Mode ── + mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle' + setMode: (mode) => set({ mode }), + + // ── Route ── + route: null, // Valhalla response (trip object) + routeLoading: false, + routeError: null, + + setRoute: (route) => set({ route, routeError: null }), + setRouteLoading: (loading) => set({ routeLoading: loading }), + setRouteError: (err) => set({ routeError: err, route: null }), + clearRoute: () => set({ route: null, routeError: null }), + + // ── Place detail ── + selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? } + clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection + gpsOrigin: true, // whether GPS should be used as origin when available + pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) + + setSelectedPlace: (place) => set({ selectedPlace: place }), + + // Boundary rendering function - set by MapView, called by PlaceCard + updateBoundary: null, + setUpdateBoundary: (fn) => set({ updateBoundary: fn }), + clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), + setClickMarker: (marker) => set({ clickMarker: marker }), + clearClickMarker: () => set({ clickMarker: null }), + setGpsOrigin: (val) => set({ gpsOrigin: val }), + setPendingDestination: (place) => set({ pendingDestination: place }), + clearPendingDestination: () => set({ pendingDestination: null }), + + startDirections: (place) => { + const { geoPermission, stops, addStop, clearStops } = get() + if (geoPermission === 'granted') { + clearStops() + addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) + set({ gpsOrigin: true, selectedPlace: null }) + } else if (stops.length > 0) { + const origin = stops[0] + clearStops() + addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) + addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) + set({ selectedPlace: null }) + } else { + // GPS denied, no stops: set pendingDestination only; origin-picker will add both + set({ pendingDestination: place, selectedPlace: null }) + } + }, + + // ── UI state ── + sheetState: 'half', // 'collapsed' | 'half' | 'full' + panelOpen: true, + autocompleteOpen: false, + theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) + themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) + + 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" + }) +} diff --git a/src/themes/cyberpunk.js b/src/themes/cyberpunk.js index 8abaadb..86a8e92 100644 --- a/src/themes/cyberpunk.js +++ b/src/themes/cyberpunk.js @@ -1,403 +1,403 @@ -/** - * Cyberpunk Theme for Navi - * - * Inspired by Mapbox's "Terminal" cyberpunk style, Blade Runner, and Ghost in - * the Shell. A tactical display in a neon-lit command center. Near-black base - * with deep blue-purple undertones. Roads glow in hot magenta and electric cyan. - * Water is inky dark. Vegetation is barely there — dark teal hints. Labels are - * cool white with colored halos. - * - * The whole thing should feel like you're navigating Night City. - * - * CUSTOM FONTS: - * - Heading: "Orbitron" — geometric, futuristic display font - * - Body: "Share Tech Mono" — monospaced terminal feel for entire UI - */ - -// ═══════════════════════════════════════════════════════════════════════════ -// PALETTE -// ═══════════════════════════════════════════════════════════════════════════ -// -// base: #0a0a14 ← near-black with blue-purple undertone -// surface: #10101e ← panels, cards -// surfaceAlt: #161628 ← secondary surfaces, hover states -// border: #1e1e3a ← subtle purple edges -// text: #d0d0e8 ← cool white text -// textSecondary: #8888aa ← lavender-gray -// textMuted: #5a5a7a ← dark purple-gray -// textInverse: #0a0a14 ← text on neon backgrounds -// accent: #ff2d6b ← hot pink/magenta — primary actions -// accentHover: #ff4d8b ← lighter magenta -// accentAlt: #00f0ff ← electric cyan — secondary accent -// success: #00ff88 ← neon green -// warning: #ffaa00 ← amber -// danger: #ff3333 ← neon red -// water: #06061a ← deep dark blue-black -// waterLabel: #3a6a8a ← muted blue for water labels -// vegetation: #0a1a12 ← barely-there dark teal-green -// forest: #0e1e14 ← slightly deeper -// road: #1a1a3a ← ghost purple minor roads -// roadSecondary: #2a2a5a -// roadPrimary: #8833aa ← purple for primary -// roadMotorway: #ff2d6b ← hot magenta for motorways -// roadCasing: #0a0a14 ← dark casing -// building: #141428 ← dark purple-gray buildings -// contour: #1e1e3e ← dark lines, just visible -// contourLabel: #5a5a7a -// -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Map flavor colors - protomaps-themes-base schema - * All 73 flat keys + pois + landcover nested objects - */ -const cyberpunkColors = { - // Background & earth - background: '#08080f', - earth: '#0a0a14', - - // Land use areas - dark with slight purple undertones - park_a: '#0a1a14', - park_b: '#0e1e18', - hospital: '#1a1020', - industrial: '#0e0e1a', - school: '#14101e', - wood_a: '#0a1a12', - wood_b: '#0e1e14', - pedestrian: '#0c0c18', - scrub_a: '#0a1410', - scrub_b: '#0c1812', - glacier: '#101020', - sand: '#12101a', - beach: '#14121c', - aerodrome: '#0a0a16', - runway: '#1a1a30', - water: '#06061a', - zoo: '#0c1614', - military: '#100a14', - - // Tunnels - dark purple casings - tunnel_other_casing: '#0a0a14', - tunnel_minor_casing: '#0a0a14', - tunnel_link_casing: '#0a0a14', - tunnel_major_casing: '#0a0a14', - tunnel_highway_casing: '#0a0a14', - tunnel_other: '#161628', - tunnel_minor: '#161628', - tunnel_link: '#2a2050', - tunnel_major: '#4a2870', - tunnel_highway: '#801848', - - // Pier & buildings - pier: '#1a1a30', - buildings: '#141428', - - // Roads & casings - glowing neon progression - minor_service_casing: '#0a0a14', - minor_casing: '#0a0a14', - link_casing: '#0a0a14', - major_casing_late: '#0a0a14', - highway_casing_late: '#0a0a14', - other: '#1a1a3a', - minor_service: '#1a1a3a', - minor_a: '#2a2a5a', - minor_b: '#1a1a3a', - link: '#5a3888', - major_casing_early: '#0a0a14', - major: '#8833aa', - highway_casing_early: '#0a0a14', - highway: '#ff2d6b', - railway: '#2a2050', - boundaries: '#4a4a6a', - - // Waterway label - waterway_label: '#3a6a8a', - - // Bridges - same neon colors - bridges_other_casing: '#0c0c18', - bridges_minor_casing: '#0a0a14', - bridges_link_casing: '#0a0a14', - bridges_major_casing: '#0a0a14', - bridges_highway_casing: '#0a0a14', - bridges_other: '#1a1a3a', - bridges_minor: '#2a2a5a', - bridges_link: '#5a3888', - bridges_major: '#8833aa', - bridges_highway: '#ff2d6b', - - // Labels - cool white with DARK halos - roads_label_minor: '#8888aa', - roads_label_minor_halo: '#0a0a14', - roads_label_major: '#a0a0c0', - roads_label_major_halo: '#0a0a14', - ocean_label: '#3a6a8a', - peak_label: '#8888aa', - subplace_label: '#8888aa', - subplace_label_halo: '#0a0a14', - city_label: '#d0d0e8', - city_label_halo: '#0a0a14', - state_label: '#5a5a7a', - state_label_halo: '#0a0a14', - country_label: '#7a7a9a', - address_label: '#8888aa', - address_label_halo: '#0a0a14', - - // POI icon colors - neon palette - pois: { - blue: '#00a0ff', - green: '#00ff88', - lapis: '#6060ff', - pink: '#ff2d6b', - red: '#ff3333', - slategray: '#8888aa', - tangerine: '#ffaa00', - turquoise: '#00f0ff', - }, - - // Landcover fill colors - very dark, barely visible - landcover: { - grassland: 'rgba(10, 26, 18, 1)', - barren: 'rgba(18, 16, 26, 1)', - urban_area: 'rgba(14, 14, 26, 1)', - farmland: 'rgba(12, 24, 16, 1)', - glacier: 'rgba(16, 16, 32, 1)', - scrub: 'rgba(12, 20, 16, 1)', - forest: 'rgba(14, 30, 20, 1)', - }, -} - -/** - * UI CSS custom properties - neon command center aesthetic - * Dark translucent panels with magenta/cyan accents - */ -const cyberpunkUI = { - // Fonts - monospace terminal feel - '--font-sans': "'Share Tech Mono', monospace", - '--font-mono': "'Share Tech Mono', monospace", - '--font-heading': "'Orbitron', sans-serif", - // Backgrounds - dark with blue-purple undertone - '--bg-base': '#0a0a14', - '--bg-raised': '#10101e', - '--bg-overlay': '#161628', - '--bg-input': '#0c0c18', - '--bg-inset': '#08080f', - '--bg-muted': '#12121e', - // Text - cool white spectrum - '--text-primary': '#d0d0e8', - '--text-secondary': '#8888aa', - '--text-tertiary': '#5a5a7a', - '--text-inverse': '#0a0a14', - // Borders - subtle purple edges - '--border': '#1e1e3a', - '--border-subtle': '#141428', - // Accent - hot magenta - '--accent': '#ff2d6b', - '--accent-hover': '#ff4d8b', - '--accent-muted': '#3a1828', - // Tan becomes cyan in this theme - '--tan': '#00f0ff', - '--tan-muted': '#0a2830', - // Pins - neon colors - '--pin-origin': '#ff2d6b', - '--pin-destination': '#00f0ff', - '--pin-intermediate': '#8833aa', - '--pin-stroke': '#0a0a14', - // Status - neon signals - '--status-success': '#00ff88', - '--status-warning': '#ffaa00', - '--status-danger': '#ff3333', - '--success': '#00ff88', - '--warning': '#ffaa00', - '--warning-muted': '#2a2010', - // Route - cyan for contrast with magenta UI - '--route-line': '#00f0ff', - // Shadows - subtle magenta glow - '--shadow': '0 2px 8px rgba(255, 45, 107, 0.25)', - '--shadow-lg': '0 4px 16px rgba(255, 45, 107, 0.35)', -} - -/** - * Overlay configuration - subtle, muted for dark theme - */ -const cyberpunkOverlay = { - // Hillshade - dramatic shadows - hillshade: { - exaggeration: 0.6, - illuminationDirection: 315, - shadowColor: '#000000', - highlightColor: '#2a2a4a', - }, - - // Contours - very subtle dark purple-gray - contours: { - 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.85, - labelSize: 10, - labelFont: ["Noto Sans Regular"], - }, - - // Contours Test - cyan variant - contoursTest: { - minorColor: '#1a3a4a', - intermediateColor: '#2a4a5a', - indexColor: '#3a5a6a', - labelColor: '#5a8a9a', - }, - - // Contours Test 10ft - purple variant - contoursTest10ft: { - minorColor: '#2a1a4a', - intermediateColor: '#3a2a5a', - indexColor: '#4a3a6a', - labelColor: '#7a6a9a', - }, - - // Public Lands - very muted fills - publicLands: { - opacityMod: 0.5, - // Fill colors - dark teal/purple tints - fillWA: '#1a2a20', - fillNPS: '#0a2a1a', - fillUSFS: '#102820', - fillBLM: '#1a2828', - fillFWS: '#0a2a2a', - fillSTAT: '#102028', - fillLOC: '#182028', - fillDefault: '#1a1a2a', - // Fill opacities - very low - fillOpacityWA: 0.25, - fillOpacityNPS: 0.25, - fillOpacityUSFS: 0.20, - fillOpacityBLM: 0.15, - fillOpacitySTAT: 0.20, - fillOpacityLOC: 0.15, - fillOpacityDefault: 0.10, - // Outline colors - subtle - outlineWA: '#2a3a30', - outlineNPS: '#1a3a2a', - outlineUSFS: '#203830', - outlineBLM: '#2a3838', - outlineFWS: '#1a3a3a', - outlineSTAT: '#203038', - outlineLOC: '#283038', - outlineDefault: '#2a2a3a', - // Outline opacities - outlineOpacityNPS: 0.5, - outlineOpacityUSFS: 0.4, - outlineOpacityDefault: 0.3, - // Outline width - outlineWidth: { z4: 0.3, z8: 0.6, z12: 1.0 }, - // Labels - muted teal - labelColor: '#5a8a8a', - labelHaloColor: '#0a0a14', - labelHaloWidth: 1.5, - labelOpacity: 0.7, - labelSize: { z10: 10, z14: 12 }, - labelFont: ['Noto Sans Regular'], - }, - - // USFS Trails - purple/magenta/cyan family instead of earthy browns - usfsTrails: { - // Roads - purple - roadsColor: '#8833aa', - roadsOpacity: 0.85, - roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, - // Trails - neon colors by use type - trailsMotorized: '#ff2d6b', - trailsBicycle: '#ffaa00', - trailsHiker: '#00ff88', - trailsDefault: '#8833aa', - trailsOpacity: 0.85, - trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - trailsDash: [2, 1.5], - // Road labels - roadsLabelColor: '#a080c0', - roadsLabelHaloColor: '#0a0a14', - roadsLabelHaloWidth: 1.5, - roadsLabelOpacity: 0.85, - roadsLabelSize: 11, - // Trail labels - trailsLabelColor: '#a080c0', - trailsLabelHaloColor: '#0a0a14', - trailsLabelHaloWidth: 1.5, - trailsLabelOpacity: 0.85, - trailsLabelSize: 11, - labelFont: ['Noto Sans Regular'], - // Hit layer - hitWidth: 14, - }, - - // BLM Trails - purple/cyan/magenta family - blmTrails: { - // Route colors - neon family - color4wdHigh: '#ff2d6b', - color4wdLow: '#cc2288', - colorAtv: '#ff3333', - colorMotoSingle: '#aa44cc', - color2wdLow: '#8833aa', - colorNonMech: '#00ff88', - colorDefault: '#6644aa', - colorSnow: '#00f0ff', - lineOpacity: 0.85, - lineOpacityOther: 0.75, - 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: '#a080c0', - labelHaloColor: '#0a0a14', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: 11, - labelFont: ['Noto Sans Regular'], - // Hit layer - hitWidth: 14, - }, -} - -/** - * Satellite adjustments - dark, desaturated, purple-shifted - */ -const cyberpunkSatellite = { - opacity: 0.8, - brightnessMin: 0.0, - brightnessMax: 0.30, - contrast: 0.15, - saturation: -0.6, - hueRotate: 280, -} - -/** - * Cyberpunk theme configuration - */ -const cyberpunkTheme = { - id: 'cyberpunk', - name: 'Cyberpunk', - dark: true, - swatch: ['#0a0a14', '#ff2d6b', '#00f0ff'], - fontImports: [ - 'https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700&display=swap', - 'https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap', - ], - colors: cyberpunkColors, - satellite: cyberpunkSatellite, - overlay: cyberpunkOverlay, - ui: cyberpunkUI, -} - -export default cyberpunkTheme +/** + * Cyberpunk Theme for Navi + * + * Inspired by Mapbox's "Terminal" cyberpunk style, Blade Runner, and Ghost in + * the Shell. A tactical display in a neon-lit command center. Near-black base + * with deep blue-purple undertones. Roads glow in hot magenta and electric cyan. + * Water is inky dark. Vegetation is barely there — dark teal hints. Labels are + * cool white with colored halos. + * + * The whole thing should feel like you're navigating Night City. + * + * CUSTOM FONTS: + * - Heading: "Orbitron" — geometric, futuristic display font + * - Body: "Share Tech Mono" — monospaced terminal feel for entire UI + */ + +// ═══════════════════════════════════════════════════════════════════════════ +// PALETTE +// ═══════════════════════════════════════════════════════════════════════════ +// +// base: #0a0a14 ← near-black with blue-purple undertone +// surface: #10101e ← panels, cards +// surfaceAlt: #161628 ← secondary surfaces, hover states +// border: #1e1e3a ← subtle purple edges +// text: #d0d0e8 ← cool white text +// textSecondary: #8888aa ← lavender-gray +// textMuted: #5a5a7a ← dark purple-gray +// textInverse: #0a0a14 ← text on neon backgrounds +// accent: #ff2d6b ← hot pink/magenta — primary actions +// accentHover: #ff4d8b ← lighter magenta +// accentAlt: #00f0ff ← electric cyan — secondary accent +// success: #00ff88 ← neon green +// warning: #ffaa00 ← amber +// danger: #ff3333 ← neon red +// water: #06061a ← deep dark blue-black +// waterLabel: #3a6a8a ← muted blue for water labels +// vegetation: #0a1a12 ← barely-there dark teal-green +// forest: #0e1e14 ← slightly deeper +// road: #1a1a3a ← ghost purple minor roads +// roadSecondary: #2a2a5a +// roadPrimary: #8833aa ← purple for primary +// roadMotorway: #ff2d6b ← hot magenta for motorways +// roadCasing: #0a0a14 ← dark casing +// building: #141428 ← dark purple-gray buildings +// contour: #1e1e3e ← dark lines, just visible +// contourLabel: #5a5a7a +// +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const cyberpunkColors = { + // Background & earth + background: '#08080f', + earth: '#0a0a14', + + // Land use areas - dark with slight purple undertones + park_a: '#0a1a14', + park_b: '#0e1e18', + hospital: '#1a1020', + industrial: '#0e0e1a', + school: '#14101e', + wood_a: '#0a1a12', + wood_b: '#0e1e14', + pedestrian: '#0c0c18', + scrub_a: '#0a1410', + scrub_b: '#0c1812', + glacier: '#101020', + sand: '#12101a', + beach: '#14121c', + aerodrome: '#0a0a16', + runway: '#1a1a30', + water: '#06061a', + zoo: '#0c1614', + military: '#100a14', + + // Tunnels - dark purple casings + tunnel_other_casing: '#0a0a14', + tunnel_minor_casing: '#0a0a14', + tunnel_link_casing: '#0a0a14', + tunnel_major_casing: '#0a0a14', + tunnel_highway_casing: '#0a0a14', + tunnel_other: '#161628', + tunnel_minor: '#161628', + tunnel_link: '#2a2050', + tunnel_major: '#4a2870', + tunnel_highway: '#801848', + + // Pier & buildings + pier: '#1a1a30', + buildings: '#141428', + + // Roads & casings - glowing neon progression + minor_service_casing: '#0a0a14', + minor_casing: '#0a0a14', + link_casing: '#0a0a14', + major_casing_late: '#0a0a14', + highway_casing_late: '#0a0a14', + other: '#1a1a3a', + minor_service: '#1a1a3a', + minor_a: '#2a2a5a', + minor_b: '#1a1a3a', + link: '#5a3888', + major_casing_early: '#0a0a14', + major: '#8833aa', + highway_casing_early: '#0a0a14', + highway: '#ff2d6b', + railway: '#2a2050', + boundaries: '#4a4a6a', + + // Waterway label + waterway_label: '#3a6a8a', + + // Bridges - same neon colors + bridges_other_casing: '#0c0c18', + bridges_minor_casing: '#0a0a14', + bridges_link_casing: '#0a0a14', + bridges_major_casing: '#0a0a14', + bridges_highway_casing: '#0a0a14', + bridges_other: '#1a1a3a', + bridges_minor: '#2a2a5a', + bridges_link: '#5a3888', + bridges_major: '#8833aa', + bridges_highway: '#ff2d6b', + + // Labels - cool white with DARK halos + roads_label_minor: '#8888aa', + roads_label_minor_halo: '#0a0a14', + roads_label_major: '#a0a0c0', + roads_label_major_halo: '#0a0a14', + ocean_label: '#3a6a8a', + peak_label: '#8888aa', + subplace_label: '#8888aa', + subplace_label_halo: '#0a0a14', + city_label: '#d0d0e8', + city_label_halo: '#0a0a14', + state_label: '#5a5a7a', + state_label_halo: '#0a0a14', + country_label: '#7a7a9a', + address_label: '#8888aa', + address_label_halo: '#0a0a14', + + // POI icon colors - neon palette + pois: { + blue: '#00a0ff', + green: '#00ff88', + lapis: '#6060ff', + pink: '#ff2d6b', + red: '#ff3333', + slategray: '#8888aa', + tangerine: '#ffaa00', + turquoise: '#00f0ff', + }, + + // Landcover fill colors - very dark, barely visible + landcover: { + grassland: 'rgba(10, 26, 18, 1)', + barren: 'rgba(18, 16, 26, 1)', + urban_area: 'rgba(14, 14, 26, 1)', + farmland: 'rgba(12, 24, 16, 1)', + glacier: 'rgba(16, 16, 32, 1)', + scrub: 'rgba(12, 20, 16, 1)', + forest: 'rgba(14, 30, 20, 1)', + }, +} + +/** + * UI CSS custom properties - neon command center aesthetic + * Dark translucent panels with magenta/cyan accents + */ +const cyberpunkUI = { + // Fonts - monospace terminal feel + '--font-sans': "'Share Tech Mono', monospace", + '--font-mono': "'Share Tech Mono', monospace", + '--font-heading': "'Orbitron', sans-serif", + // Backgrounds - dark with blue-purple undertone + '--bg-base': '#0a0a14', + '--bg-raised': '#10101e', + '--bg-overlay': '#161628', + '--bg-input': '#0c0c18', + '--bg-inset': '#08080f', + '--bg-muted': '#12121e', + // Text - cool white spectrum + '--text-primary': '#d0d0e8', + '--text-secondary': '#8888aa', + '--text-tertiary': '#5a5a7a', + '--text-inverse': '#0a0a14', + // Borders - subtle purple edges + '--border': '#1e1e3a', + '--border-subtle': '#141428', + // Accent - hot magenta + '--accent': '#ff2d6b', + '--accent-hover': '#ff4d8b', + '--accent-muted': '#3a1828', + // Tan becomes cyan in this theme + '--tan': '#00f0ff', + '--tan-muted': '#0a2830', + // Pins - neon colors + '--pin-origin': '#ff2d6b', + '--pin-destination': '#00f0ff', + '--pin-intermediate': '#8833aa', + '--pin-stroke': '#0a0a14', + // Status - neon signals + '--status-success': '#00ff88', + '--status-warning': '#ffaa00', + '--status-danger': '#ff3333', + '--success': '#00ff88', + '--warning': '#ffaa00', + '--warning-muted': '#2a2010', + // Route - cyan for contrast with magenta UI + '--route-line': '#00f0ff', + // Shadows - subtle magenta glow + '--shadow': '0 2px 8px rgba(255, 45, 107, 0.25)', + '--shadow-lg': '0 4px 16px rgba(255, 45, 107, 0.35)', +} + +/** + * Overlay configuration - subtle, muted for dark theme + */ +const cyberpunkOverlay = { + // Hillshade - dramatic shadows + hillshade: { + exaggeration: 0.6, + illuminationDirection: 315, + shadowColor: '#000000', + highlightColor: '#2a2a4a', + }, + + // 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', + labelHaloWidth: 1.5, + labelOpacity: 0.6, + labelSize: 10, + labelFont: ['Noto Sans Regular'], + }, + + // Contours Test - cyan variant + contoursTest: { + minorColor: '#1a3a4a', + intermediateColor: '#2a4a5a', + indexColor: '#3a5a6a', + labelColor: '#5a8a9a', + }, + + // Contours Test 10ft - purple variant + contoursTest10ft: { + minorColor: '#2a1a4a', + intermediateColor: '#3a2a5a', + indexColor: '#4a3a6a', + labelColor: '#7a6a9a', + }, + + // Public Lands - very muted fills + publicLands: { + opacityMod: 0.5, + // Fill colors - dark teal/purple tints + fillWA: '#1a2a20', + fillNPS: '#0a2a1a', + fillUSFS: '#102820', + fillBLM: '#1a2828', + fillFWS: '#0a2a2a', + fillSTAT: '#102028', + fillLOC: '#182028', + fillDefault: '#1a1a2a', + // Fill opacities - very low + fillOpacityWA: 0.25, + fillOpacityNPS: 0.25, + fillOpacityUSFS: 0.20, + fillOpacityBLM: 0.15, + fillOpacitySTAT: 0.20, + fillOpacityLOC: 0.15, + fillOpacityDefault: 0.10, + // Outline colors - subtle + outlineWA: '#2a3a30', + outlineNPS: '#1a3a2a', + outlineUSFS: '#203830', + outlineBLM: '#2a3838', + outlineFWS: '#1a3a3a', + outlineSTAT: '#203038', + outlineLOC: '#283038', + outlineDefault: '#2a2a3a', + // Outline opacities + outlineOpacityNPS: 0.5, + outlineOpacityUSFS: 0.4, + outlineOpacityDefault: 0.3, + // Outline width + outlineWidth: { z4: 0.3, z8: 0.6, z12: 1.0 }, + // Labels - muted teal + labelColor: '#5a8a8a', + labelHaloColor: '#0a0a14', + labelHaloWidth: 1.5, + labelOpacity: 0.7, + labelSize: { z10: 10, z14: 12 }, + labelFont: ['Noto Sans Regular'], + }, + + // USFS Trails - purple/magenta/cyan family instead of earthy browns + usfsTrails: { + // Roads - purple + roadsColor: '#8833aa', + roadsOpacity: 0.85, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + // Trails - neon colors by use type + trailsMotorized: '#ff2d6b', + trailsBicycle: '#ffaa00', + trailsHiker: '#00ff88', + trailsDefault: '#8833aa', + trailsOpacity: 0.85, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + // Road labels + roadsLabelColor: '#a080c0', + roadsLabelHaloColor: '#0a0a14', + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.85, + roadsLabelSize: 11, + // Trail labels + trailsLabelColor: '#a080c0', + trailsLabelHaloColor: '#0a0a14', + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.85, + trailsLabelSize: 11, + labelFont: ['Noto Sans Regular'], + // Hit layer + hitWidth: 14, + }, + + // BLM Trails - purple/cyan/magenta family + blmTrails: { + // Route colors - neon family + color4wdHigh: '#ff2d6b', + color4wdLow: '#cc2288', + colorAtv: '#ff3333', + colorMotoSingle: '#aa44cc', + color2wdLow: '#8833aa', + colorNonMech: '#00ff88', + colorDefault: '#6644aa', + colorSnow: '#00f0ff', + lineOpacity: 0.85, + lineOpacityOther: 0.75, + 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: '#a080c0', + labelHaloColor: '#0a0a14', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: 11, + labelFont: ['Noto Sans Regular'], + // Hit layer + hitWidth: 14, + }, +} + +/** + * Satellite adjustments - dark, desaturated, purple-shifted + */ +const cyberpunkSatellite = { + opacity: 0.8, + brightnessMin: 0.0, + brightnessMax: 0.30, + contrast: 0.15, + saturation: -0.6, + hueRotate: 280, +} + +/** + * Cyberpunk theme configuration + */ +const cyberpunkTheme = { + id: 'cyberpunk', + name: 'Cyberpunk', + dark: true, + swatch: ['#0a0a14', '#ff2d6b', '#00f0ff'], + fontImports: [ + 'https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700&display=swap', + 'https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap', + ], + colors: cyberpunkColors, + satellite: cyberpunkSatellite, + overlay: cyberpunkOverlay, + ui: cyberpunkUI, +} + +export default cyberpunkTheme diff --git a/src/themes/nightops.js b/src/themes/nightops.js deleted file mode 100644 index ee3c82d..0000000 --- a/src/themes/nightops.js +++ /dev/null @@ -1,329 +0,0 @@ -/** - * 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 deleted file mode 100644 index 03bc22d..0000000 --- a/src/themes/parchment.js +++ /dev/null @@ -1,374 +0,0 @@ -/** - * 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 deleted file mode 100644 index 5ae8ca6..0000000 --- a/src/themes/ranger.js +++ /dev/null @@ -1,325 +0,0 @@ -/** - * 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 a65a490..cc48c8f 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -1,644 +1,652 @@ -/** - * Theme Registry for Navi - * - * Provides a centralized registry for map themes, supporting both built-in - * protomaps themes (light/dark) and custom themes with full flavor objects. - * - * Theme config structure: - * id: string - unique identifier (used in store, data-theme attr) - * name: string - display name for UI - * dark: boolean - true if dark theme (affects overlay styling, sprite fallback) - * colors: object|null - null for built-in themes, full flavor object for custom - * satellite: object|null - raster adjustments when satellite layer is present - * overlay: object - overlay layer styling configuration - * ui: object - CSS custom properties for UI elements - * swatch: string[3] - 3 hex colors for theme picker preview - * fontImports: string[] - URLs for font CSS imports (empty for system fonts) - */ - -import cleanTheme from './clean.js' +/** + * Theme Registry for Navi + * + * Provides a centralized registry for map themes, supporting both built-in + * protomaps themes (light/dark) and custom themes with full flavor objects. + * + * Theme config structure: + * id: string - unique identifier (used in store, data-theme attr) + * name: string - display name for UI + * dark: boolean - true if dark theme (affects overlay styling, sprite fallback) + * colors: object|null - null for built-in themes, full flavor object for custom + * satellite: object|null - raster adjustments when satellite layer is present + * overlay: object - overlay layer styling configuration + * ui: object - CSS custom properties for UI elements + * swatch: string[3] - 3 hex colors for theme picker preview + * 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 -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Dark theme UI configuration - * All CSS custom properties for dark theme UI - */ -const darkUI = { - // Fonts - '--font-sans': "'Inter', system-ui, -apple-system, sans-serif", - '--font-mono': "'JetBrains Mono', ui-monospace, monospace", + +// ═══════════════════════════════════════════════════════════════════════════ +// UI CSS CUSTOM PROPERTIES +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Dark theme UI configuration + * All CSS custom properties for dark theme UI + */ +const darkUI = { + // 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': '#1c1917', - '--bg-raised': '#252220', - '--bg-overlay': '#2e2a27', - '--bg-input': '#201d1a', - '--bg-inset': '#181614', - '--bg-muted': '#2a2725', - // Text - '--text-primary': '#dde3dc', - '--text-secondary': '#8f9a8e', - '--text-tertiary': '#5e6b5d', - '--text-inverse': '#1c1917', - // Borders - '--border': '#3a3530', - '--border-subtle': '#2a2624', - // Accent - '--accent': '#7a9a6b', - '--accent-hover': '#8fad7f', - '--accent-muted': '#3d4d36', - // Tan - '--tan': '#b8a88a', - '--tan-muted': '#4a4235', - // Pins - '--pin-origin': '#6b8f5e', - '--pin-destination': '#a67c52', - '--pin-intermediate': '#6b7268', - '--pin-stroke': '#1c1917', - // Status - '--status-success': '#6b8f5e', - '--status-warning': '#b89a4a', - '--status-danger': '#a65c52', - '--success': '#6b8f5e', - '--warning': '#b89a4a', - '--warning-muted': '#4a4235', - // Route - '--route-line': '#7a9a6b', - // Shadows - '--shadow': '0 2px 8px rgba(0, 0, 0, 0.4)', - '--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.5)', -} - -/** - * Light theme UI configuration - * All CSS custom properties for light theme UI - */ -const lightUI = { - // Fonts - '--font-sans': "'Inter', system-ui, -apple-system, sans-serif", - '--font-mono': "'JetBrains Mono', ui-monospace, monospace", + // Backgrounds + '--bg-base': '#1c1917', + '--bg-raised': '#252220', + '--bg-overlay': '#2e2a27', + '--bg-input': '#201d1a', + '--bg-inset': '#181614', + '--bg-muted': '#2a2725', + // Text + '--text-primary': '#dde3dc', + '--text-secondary': '#8f9a8e', + '--text-tertiary': '#5e6b5d', + '--text-inverse': '#1c1917', + // Borders + '--border': '#3a3530', + '--border-subtle': '#2a2624', + // Accent + '--accent': '#7a9a6b', + '--accent-hover': '#8fad7f', + '--accent-muted': '#3d4d36', + // Tan + '--tan': '#b8a88a', + '--tan-muted': '#4a4235', + // Pins + '--pin-origin': '#6b8f5e', + '--pin-destination': '#a67c52', + '--pin-intermediate': '#6b7268', + '--pin-stroke': '#1c1917', + // Status + '--status-success': '#6b8f5e', + '--status-warning': '#b89a4a', + '--status-danger': '#a65c52', + '--success': '#6b8f5e', + '--warning': '#b89a4a', + '--warning-muted': '#4a4235', + // Route + '--route-line': '#7a9a6b', + // Shadows + '--shadow': '0 2px 8px rgba(0, 0, 0, 0.4)', + '--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.5)', +} + +/** + * Light theme UI configuration + * All CSS custom properties for light theme UI + */ +const lightUI = { + // 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': '#ddd2b9', - '--bg-raised': '#e8dec8', - '--bg-overlay': '#e3d9c1', - '--bg-input': '#e8dec8', - '--bg-inset': '#d5cab2', - '--bg-muted': '#e0d6c0', - // Text - '--text-primary': '#1a1d1a', - '--text-secondary': '#4f5a49', - '--text-tertiary': '#7a8674', - '--text-inverse': '#f5f2ed', - // Borders - '--border': '#c4b89e', - '--border-subtle': '#d5cab2', - // Accent - '--accent': '#4a7040', - '--accent-hover': '#3d5e35', - '--accent-muted': '#dce8d6', - // Tan - '--tan': '#8a7556', - '--tan-muted': '#f0e8d8', - // Pins - '--pin-origin': '#4a7040', - '--pin-destination': '#8a5c35', - '--pin-intermediate': '#6b6960', - '--pin-stroke': '#1a1d1a', - // Status - '--status-success': '#4a7040', - '--status-warning': '#8a7040', - '--status-danger': '#8a4040', - '--success': '#4a7040', - '--warning': '#8a7040', - '--warning-muted': '#f0e8d8', - // Route - '--route-line': '#4a7040', - // Shadows - '--shadow': '0 2px 8px rgba(0, 0, 0, 0.08)', - '--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.12)', -} - -// ═══════════════════════════════════════════════════════════════════════════ -// OVERLAY CONFIGURATIONS -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Dark theme overlay configuration - * All hardcoded values from overlay add functions extracted here - */ -const darkOverlay = { - // ── Hillshade ───────────────────────────────────────────────────────────── - hillshade: { - exaggeration: 0.5, - illuminationDirection: 315, - shadowColor: '#000000', - highlightColor: '#ffffff', - }, - - // ── Traffic ─────────────────────────────────────────────────────────────── - traffic: { - opacity: 0.6, - }, - - // ── Contours (main, brown/tan scheme) ───────────────────────────────────── - contours: { - opacityMod: 0.8, - minorColor: '#8b6f47', - minorOpacity: 0.4, - minorWidth: { z11: 0.5, z14: 1.0 }, - intermediateColor: '#8b6f47', - intermediateOpacity: 0.7, - intermediateWidth: { z8: 0.8, z14: 1.2 }, - indexColor: '#6b4f2a', - indexOpacity: 0.9, - indexWidth: { z4: 1.2, z14: 1.8 }, - labelColor: '#c0b898', - labelHaloColor: '#1a1a1a', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: 10, - labelFont: ['Noto Sans Regular'], - }, - - // ── Contours Test (blue scheme) ─────────────────────────────────────────── - // Missing keys cascade from contours - contoursTest: { - minorColor: '#4a7c9b', - intermediateColor: '#4a7c9b', - indexColor: '#2a5a7c', - labelColor: '#98b8d0', - }, - - // ── Contours Test 10ft (green scheme) ───────────────────────────────────── - // Missing keys cascade from contours - contoursTest10ft: { - minorColor: '#3a7c4f', - intermediateColor: '#3a7c4f', - indexColor: '#2a5c3a', - labelColor: '#98c0a8', - }, - - // ── Public Lands (PAD-US) ───────────────────────────────────────────────── - publicLands: { - opacityMod: 0.7, - // Fill colors per category - fillWA: '#7c6b2f', - fillNPS: '#3d6b1f', - fillUSFS: '#5a7c2f', - fillBLM: '#c4a672', - fillFWS: '#4a7a5a', - fillSTAT: '#5a8c7c', - fillLOC: '#8ca694', - fillDefault: '#a0a0a0', - // Fill base opacities (multiplied by opacityMod) - fillOpacityWA: 0.30, - fillOpacityNPS: 0.30, - fillOpacityUSFS: 0.25, - fillOpacityBLM: 0.20, - fillOpacitySTAT: 0.25, - fillOpacityLOC: 0.20, - fillOpacityDefault: 0.15, - // Outline colors per category - outlineWA: '#5a4d20', - outlineNPS: '#2a4a15', - outlineUSFS: '#3d5520', - outlineBLM: '#8a7343', - outlineFWS: '#2d5a3a', - outlineSTAT: '#3d6055', - outlineLOC: '#5c6e66', - outlineDefault: '#707070', - // Outline opacities - outlineOpacityNPS: 0.7, - outlineOpacityUSFS: 0.6, - outlineOpacityDefault: 0.5, - // Outline width - outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, - // Labels - labelColor: '#c0c8b8', - labelHaloColor: '#1a1a1a', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: { z10: 10, z14: 13 }, - labelFont: ['Noto Sans Regular'], - }, - - // ── USFS Trails ─────────────────────────────────────────────────────────── - usfsTrails: { - // Roads - roadsColor: '#d0a060', - roadsOpacity: 0.9, - roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, - // Trails colors by use type - trailsMotorized: '#f08040', - trailsBicycle: '#e0b040', - trailsHiker: '#60c050', - trailsDefault: '#c0a060', - trailsOpacity: 0.9, - trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - trailsDash: [2, 1.5], - // Road labels - roadsLabelColor: '#d0c0a0', - roadsLabelHaloColor: '#1a1a1a', - roadsLabelHaloWidth: 1.5, - roadsLabelOpacity: 0.9, - roadsLabelSize: 11, - // Trail labels - trailsLabelColor: '#d0b090', - trailsLabelHaloColor: '#1a1a1a', - trailsLabelHaloWidth: 1.5, - trailsLabelOpacity: 0.9, - trailsLabelSize: 11, - labelFont: ['Noto Sans Regular'], - // Hit layer - hitWidth: 14, - }, - - // ── BLM Trails / Roads ──────────────────────────────────────────────────── - blmTrails: { - // Route colors by use class - color4wdHigh: '#f08040', - color4wdLow: '#e0b040', - colorAtv: '#e04040', - colorMotoSingle: '#b070c0', - color2wdLow: '#f0d070', - colorNonMech: '#60c050', - colorDefault: '#c0a060', - colorSnow: '#80b0e0', - lineOpacity: 0.9, - lineOpacityOther: 0.85, - lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - // Dash patterns by surface type - dashImproved: [4, 2], - dashAggregate: [1, 2], - dashSnow: [4, 2, 1, 2], - dashOther: [4, 2, 1, 2, 1, 2], - // Labels - labelColor: '#d0c0a0', - labelHaloColor: '#1a1a1a', - labelHaloWidth: 1.5, - labelOpacity: 0.9, - labelSize: 11, - labelFont: ['Noto Sans Regular'], - // Hit layer - hitWidth: 14, - }, -} - -/** - * Light theme overlay configuration - * All hardcoded values from overlay add functions extracted here - */ -const lightOverlay = { - // ── Hillshade ───────────────────────────────────────────────────────────── - hillshade: { - exaggeration: 0.5, - illuminationDirection: 315, - shadowColor: '#000000', - highlightColor: '#ffffff', - }, - - // ── Traffic ─────────────────────────────────────────────────────────────── - traffic: { - opacity: 0.6, - }, - - // ── Contours (main, brown/tan scheme) ───────────────────────────────────── - contours: { - opacityMod: 1.0, - minorColor: '#8b6f47', - minorOpacity: 0.4, - minorWidth: { z11: 0.5, z14: 1.0 }, - intermediateColor: '#8b6f47', - intermediateOpacity: 0.7, - intermediateWidth: { z8: 0.8, z14: 1.2 }, - indexColor: '#6b4f2a', - indexOpacity: 0.9, - indexWidth: { z4: 1.2, z14: 1.8 }, - labelColor: '#5a4020', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: 10, - labelFont: ['Noto Sans Regular'], - }, - - // ── Contours Test (blue scheme) ─────────────────────────────────────────── - // Missing keys cascade from contours - contoursTest: { - minorColor: '#4a7c9b', - intermediateColor: '#4a7c9b', - indexColor: '#2a5a7c', - labelColor: '#205080', - }, - - // ── Contours Test 10ft (green scheme) ───────────────────────────────────── - // Missing keys cascade from contours - contoursTest10ft: { - minorColor: '#3a7c4f', - intermediateColor: '#3a7c4f', - indexColor: '#2a5c3a', - labelColor: '#2a4030', - }, - - // ── Public Lands (PAD-US) ───────────────────────────────────────────────── - publicLands: { - opacityMod: 1.0, - // Fill colors per category - fillWA: '#7c6b2f', - fillNPS: '#3d6b1f', - fillUSFS: '#5a7c2f', - fillBLM: '#c4a672', - fillFWS: '#4a7a5a', - fillSTAT: '#5a8c7c', - fillLOC: '#8ca694', - fillDefault: '#a0a0a0', - // Fill base opacities (multiplied by opacityMod) - fillOpacityWA: 0.30, - fillOpacityNPS: 0.30, - fillOpacityUSFS: 0.25, - fillOpacityBLM: 0.20, - fillOpacitySTAT: 0.25, - fillOpacityLOC: 0.20, - fillOpacityDefault: 0.15, - // Outline colors per category - outlineWA: '#5a4d20', - outlineNPS: '#2a4a15', - outlineUSFS: '#3d5520', - outlineBLM: '#8a7343', - outlineFWS: '#2d5a3a', - outlineSTAT: '#3d6055', - outlineLOC: '#5c6e66', - outlineDefault: '#707070', - // Outline opacities - outlineOpacityNPS: 0.7, - outlineOpacityUSFS: 0.6, - outlineOpacityDefault: 0.5, - // Outline width - outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, - // Labels - labelColor: '#3a4a30', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: { z10: 10, z14: 13 }, - labelFont: ['Noto Sans Regular'], - }, - - // ── USFS Trails ─────────────────────────────────────────────────────────── - usfsTrails: { - // Roads - roadsColor: '#c09050', - roadsOpacity: 0.9, - roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, - // Trails colors by use type - trailsMotorized: '#e07030', - trailsBicycle: '#d0a030', - trailsHiker: '#50b040', - trailsDefault: '#b09050', - trailsOpacity: 0.9, - trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - trailsDash: [2, 1.5], - // Road labels - roadsLabelColor: '#6a5a40', - roadsLabelHaloColor: '#ffffff', - roadsLabelHaloWidth: 1.5, - roadsLabelOpacity: 0.9, - roadsLabelSize: 11, - // Trail labels - trailsLabelColor: '#5a4a30', - trailsLabelHaloColor: '#ffffff', - trailsLabelHaloWidth: 1.5, - trailsLabelOpacity: 0.9, - trailsLabelSize: 11, - labelFont: ['Noto Sans Regular'], - // Hit layer - hitWidth: 14, - }, - - // ── BLM Trails / Roads ──────────────────────────────────────────────────── - blmTrails: { - // Route colors by use class - color4wdHigh: '#e07030', - color4wdLow: '#d0a030', - colorAtv: '#d03030', - colorMotoSingle: '#a060b0', - color2wdLow: '#e0c060', - colorNonMech: '#50b040', - colorDefault: '#b09050', - colorSnow: '#6090c0', - lineOpacity: 0.9, - lineOpacityOther: 0.85, - lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - // Dash patterns by surface type - dashImproved: [4, 2], - dashAggregate: [1, 2], - dashSnow: [4, 2, 1, 2], - dashOther: [4, 2, 1, 2, 1, 2], - // Labels - labelColor: '#5a4a30', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.9, - labelSize: 11, - labelFont: ['Noto Sans Regular'], - // Hit layer - hitWidth: 14, - }, -} - -// ═══════════════════════════════════════════════════════════════════════════ -// THEME REGISTRY -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Theme registry - maps theme IDs to theme configurations - * - * Built-in themes (light/dark) use colors: null to signal that namedTheme() - * should be called at render time. Custom themes provide a full flavor object. - */ -const themes = { - light: { - id: 'light', - name: 'Light', - dark: false, - colors: null, // Use namedTheme('light') - satellite: null, - overlay: lightOverlay, - ui: lightUI, - swatch: ['#ddd2b9', '#4a7040', '#8a7556'], - fontImports: [], - }, - dark: { - id: 'dark', - name: 'Dark', - dark: true, - colors: null, // Use namedTheme('dark') - satellite: null, - overlay: darkOverlay, - ui: darkUI, - swatch: ['#1c1917', '#7a9a6b', '#b8a88a'], - fontImports: [], - }, - clean: { - ...cleanTheme, - swatch: ['#f5f5f5', '#1a73e8', '#34a853'], - fontImports: [], - }, + // Backgrounds + '--bg-base': '#ddd2b9', + '--bg-raised': '#e8dec8', + '--bg-overlay': '#e3d9c1', + '--bg-input': '#e8dec8', + '--bg-inset': '#d5cab2', + '--bg-muted': '#e0d6c0', + // Text + '--text-primary': '#1a1d1a', + '--text-secondary': '#4f5a49', + '--text-tertiary': '#7a8674', + '--text-inverse': '#f5f2ed', + // Borders + '--border': '#c4b89e', + '--border-subtle': '#d5cab2', + // Accent + '--accent': '#4a7040', + '--accent-hover': '#3d5e35', + '--accent-muted': '#dce8d6', + // Tan + '--tan': '#8a7556', + '--tan-muted': '#f0e8d8', + // Pins + '--pin-origin': '#4a7040', + '--pin-destination': '#8a5c35', + '--pin-intermediate': '#6b6960', + '--pin-stroke': '#1a1d1a', + // Status + '--status-success': '#4a7040', + '--status-warning': '#8a7040', + '--status-danger': '#8a4040', + '--success': '#4a7040', + '--warning': '#8a7040', + '--warning-muted': '#f0e8d8', + // Route + '--route-line': '#4a7040', + // Shadows + '--shadow': '0 2px 8px rgba(0, 0, 0, 0.08)', + '--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.12)', +} + +// ═══════════════════════════════════════════════════════════════════════════ +// OVERLAY CONFIGURATIONS +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Dark theme overlay configuration + * All hardcoded values from overlay add functions extracted here + */ +const darkOverlay = { + // ── Hillshade ───────────────────────────────────────────────────────────── + hillshade: { + exaggeration: 0.5, + illuminationDirection: 315, + shadowColor: '#000000', + highlightColor: '#ffffff', + }, + + // ── Traffic ─────────────────────────────────────────────────────────────── + traffic: { + opacity: 0.6, + }, + + // ── Contours (main, brown/tan scheme) ───────────────────────────────────── + contours: { + opacityMod: 0.8, + minorColor: '#8b6f47', + minorOpacity: 0.4, + minorWidth: { z11: 0.5, z14: 1.0 }, + intermediateColor: '#8b6f47', + intermediateOpacity: 0.7, + intermediateWidth: { z8: 0.8, z14: 1.2 }, + indexColor: '#6b4f2a', + indexOpacity: 0.9, + indexWidth: { z4: 1.2, z14: 1.8 }, + labelColor: '#c0b898', + labelHaloColor: '#1a1a1a', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: 10, + labelFont: ['Noto Sans Regular'], + }, + + // ── Contours Test (blue scheme) ─────────────────────────────────────────── + // Missing keys cascade from contours + contoursTest: { + minorColor: '#4a7c9b', + intermediateColor: '#4a7c9b', + indexColor: '#2a5a7c', + labelColor: '#98b8d0', + }, + + // ── Contours Test 10ft (green scheme) ───────────────────────────────────── + // Missing keys cascade from contours + contoursTest10ft: { + minorColor: '#3a7c4f', + intermediateColor: '#3a7c4f', + indexColor: '#2a5c3a', + labelColor: '#98c0a8', + }, + + // ── Public Lands (PAD-US) ───────────────────────────────────────────────── + publicLands: { + opacityMod: 0.7, + // Fill colors per category + fillWA: '#7c6b2f', + fillNPS: '#3d6b1f', + fillUSFS: '#5a7c2f', + fillBLM: '#c4a672', + fillFWS: '#4a7a5a', + fillSTAT: '#5a8c7c', + fillLOC: '#8ca694', + fillDefault: '#a0a0a0', + // Fill base opacities (multiplied by opacityMod) + fillOpacityWA: 0.30, + fillOpacityNPS: 0.30, + fillOpacityUSFS: 0.25, + fillOpacityBLM: 0.20, + fillOpacitySTAT: 0.25, + fillOpacityLOC: 0.20, + fillOpacityDefault: 0.15, + // Outline colors per category + outlineWA: '#5a4d20', + outlineNPS: '#2a4a15', + outlineUSFS: '#3d5520', + outlineBLM: '#8a7343', + outlineFWS: '#2d5a3a', + outlineSTAT: '#3d6055', + outlineLOC: '#5c6e66', + outlineDefault: '#707070', + // Outline opacities + outlineOpacityNPS: 0.7, + outlineOpacityUSFS: 0.6, + outlineOpacityDefault: 0.5, + // Outline width + outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, + // Labels + labelColor: '#c0c8b8', + labelHaloColor: '#1a1a1a', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: { z10: 10, z14: 13 }, + labelFont: ['Noto Sans Regular'], + }, + + // ── USFS Trails ─────────────────────────────────────────────────────────── + usfsTrails: { + // Roads + roadsColor: '#d0a060', + roadsOpacity: 0.9, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + // Trails colors by use type + trailsMotorized: '#f08040', + trailsBicycle: '#e0b040', + trailsHiker: '#60c050', + trailsDefault: '#c0a060', + trailsOpacity: 0.9, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + // Road labels + roadsLabelColor: '#d0c0a0', + roadsLabelHaloColor: '#1a1a1a', + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.9, + roadsLabelSize: 11, + // Trail labels + trailsLabelColor: '#d0b090', + trailsLabelHaloColor: '#1a1a1a', + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.9, + trailsLabelSize: 11, + labelFont: ['Noto Sans Regular'], + // Hit layer + hitWidth: 14, + }, + + // ── BLM Trails / Roads ──────────────────────────────────────────────────── + blmTrails: { + // Route colors by use class + color4wdHigh: '#f08040', + color4wdLow: '#e0b040', + colorAtv: '#e04040', + colorMotoSingle: '#b070c0', + color2wdLow: '#f0d070', + colorNonMech: '#60c050', + colorDefault: '#c0a060', + colorSnow: '#80b0e0', + lineOpacity: 0.9, + lineOpacityOther: 0.85, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + // Dash patterns by surface type + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + // Labels + labelColor: '#d0c0a0', + labelHaloColor: '#1a1a1a', + labelHaloWidth: 1.5, + labelOpacity: 0.9, + labelSize: 11, + labelFont: ['Noto Sans Regular'], + // Hit layer + hitWidth: 14, + }, +} + +/** + * Light theme overlay configuration + * All hardcoded values from overlay add functions extracted here + */ +const lightOverlay = { + // ── Hillshade ───────────────────────────────────────────────────────────── + hillshade: { + exaggeration: 0.5, + illuminationDirection: 315, + shadowColor: '#000000', + highlightColor: '#ffffff', + }, + + // ── Traffic ─────────────────────────────────────────────────────────────── + traffic: { + opacity: 0.6, + }, + + // ── Contours (main, brown/tan scheme) ───────────────────────────────────── + contours: { + opacityMod: 1.0, + minorColor: '#8b6f47', + minorOpacity: 0.4, + minorWidth: { z11: 0.5, z14: 1.0 }, + intermediateColor: '#8b6f47', + intermediateOpacity: 0.7, + intermediateWidth: { z8: 0.8, z14: 1.2 }, + indexColor: '#6b4f2a', + indexOpacity: 0.9, + indexWidth: { z4: 1.2, z14: 1.8 }, + labelColor: '#5a4020', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: 10, + labelFont: ['Noto Sans Regular'], + }, + + // ── Contours Test (blue scheme) ─────────────────────────────────────────── + // Missing keys cascade from contours + contoursTest: { + minorColor: '#4a7c9b', + intermediateColor: '#4a7c9b', + indexColor: '#2a5a7c', + labelColor: '#205080', + }, + + // ── Contours Test 10ft (green scheme) ───────────────────────────────────── + // Missing keys cascade from contours + contoursTest10ft: { + minorColor: '#3a7c4f', + intermediateColor: '#3a7c4f', + indexColor: '#2a5c3a', + labelColor: '#2a4030', + }, + + // ── Public Lands (PAD-US) ───────────────────────────────────────────────── + publicLands: { + opacityMod: 1.0, + // Fill colors per category + fillWA: '#7c6b2f', + fillNPS: '#3d6b1f', + fillUSFS: '#5a7c2f', + fillBLM: '#c4a672', + fillFWS: '#4a7a5a', + fillSTAT: '#5a8c7c', + fillLOC: '#8ca694', + fillDefault: '#a0a0a0', + // Fill base opacities (multiplied by opacityMod) + fillOpacityWA: 0.30, + fillOpacityNPS: 0.30, + fillOpacityUSFS: 0.25, + fillOpacityBLM: 0.20, + fillOpacitySTAT: 0.25, + fillOpacityLOC: 0.20, + fillOpacityDefault: 0.15, + // Outline colors per category + outlineWA: '#5a4d20', + outlineNPS: '#2a4a15', + outlineUSFS: '#3d5520', + outlineBLM: '#8a7343', + outlineFWS: '#2d5a3a', + outlineSTAT: '#3d6055', + outlineLOC: '#5c6e66', + outlineDefault: '#707070', + // Outline opacities + outlineOpacityNPS: 0.7, + outlineOpacityUSFS: 0.6, + outlineOpacityDefault: 0.5, + // Outline width + outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, + // Labels + labelColor: '#3a4a30', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: { z10: 10, z14: 13 }, + labelFont: ['Noto Sans Regular'], + }, + + // ── USFS Trails ─────────────────────────────────────────────────────────── + usfsTrails: { + // Roads + roadsColor: '#c09050', + roadsOpacity: 0.9, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + // Trails colors by use type + trailsMotorized: '#e07030', + trailsBicycle: '#d0a030', + trailsHiker: '#50b040', + trailsDefault: '#b09050', + trailsOpacity: 0.9, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + // Road labels + roadsLabelColor: '#6a5a40', + roadsLabelHaloColor: '#ffffff', + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.9, + roadsLabelSize: 11, + // Trail labels + trailsLabelColor: '#5a4a30', + trailsLabelHaloColor: '#ffffff', + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.9, + trailsLabelSize: 11, + labelFont: ['Noto Sans Regular'], + // Hit layer + hitWidth: 14, + }, + + // ── BLM Trails / Roads ──────────────────────────────────────────────────── + blmTrails: { + // Route colors by use class + color4wdHigh: '#e07030', + color4wdLow: '#d0a030', + colorAtv: '#d03030', + colorMotoSingle: '#a060b0', + color2wdLow: '#e0c060', + colorNonMech: '#50b040', + colorDefault: '#b09050', + colorSnow: '#6090c0', + lineOpacity: 0.9, + lineOpacityOther: 0.85, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + // Dash patterns by surface type + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + // Labels + labelColor: '#5a4a30', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.9, + labelSize: 11, + labelFont: ['Noto Sans Regular'], + // Hit layer + hitWidth: 14, + }, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// THEME REGISTRY +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Theme registry - maps theme IDs to theme configurations + * + * Built-in themes (light/dark) use colors: null to signal that namedTheme() + * should be called at render time. Custom themes provide a full flavor object. + */ +const themes = { + light: { + id: 'light', + name: 'Light', + dark: false, + colors: null, // Use namedTheme('light') + satellite: null, + overlay: lightOverlay, + ui: lightUI, + swatch: ['#ddd2b9', '#4a7040', '#8a7556'], + fontImports: [], + }, + dark: { + id: 'dark', + name: 'Dark', + dark: true, + colors: null, // Use namedTheme('dark') + satellite: null, + overlay: darkOverlay, + ui: darkUI, + swatch: ['#1c1917', '#7a9a6b', '#b8a88a'], + fontImports: [], + }, + clean: { + ...cleanTheme, + swatch: ['#f5f5f5', '#1a73e8', '#34a853'], + fontImports: [], + }, cyberpunk: cyberpunkTheme, - ranger: rangerTheme, - tactical: tacticalTheme, - nightops: nightopsTheme, - parchment: parchmentTheme, - // Custom themes go here. Example: - // 'midnight': { - // id: 'midnight', - // name: 'Midnight', - // dark: true, - // colors: { /* full flavor object matching dark-flavor-reference.json schema */ }, - // satellite: { opacity: 0.8, brightnessMin: 0.1 }, - // overlay: { /* partial overrides - missing keys fall back to dark overlay */ }, - // ui: { /* partial overrides - missing keys fall back to dark ui */ }, - // swatch: ['#0a0a12', '#6060ff', '#4040a0'], - // fontImports: ['https://fonts.googleapis.com/css2?family=Orbitron&display=swap'], - // }, -} - -// ═══════════════════════════════════════════════════════════════════════════ -// EXPORTED FUNCTIONS -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Get a theme configuration by ID - * @param {string} id - Theme ID - * @returns {object} Theme config, falls back to 'dark' if not found - */ -export function getTheme(id) { - return themes[id] || themes.dark -} - -/** - * Get the sprite URL for a theme - * Built-in themes use their own sprites. Custom themes fall back to - * dark or light sprite based on the theme's dark flag. - * @param {string} id - Theme ID - * @returns {string} Full sprite URL - */ -export function getThemeSprite(id) { - const theme = getTheme(id) - // Custom themes don't have matching sprites on CDN - fall back based on dark flag - const spriteTheme = theme.colors === null ? id : (theme.dark ? 'dark' : 'light') - return `https://protomaps.github.io/basemaps-assets/sprites/v4/${spriteTheme}` -} - -/** - * Get overlay configuration for a specific layer - * - * For contour variants (contoursTest, contoursTest10ft), missing keys cascade - * from the same theme's contours config. - * - * For custom themes, missing keys fall back to the appropriate built-in theme - * (dark or light based on theme.dark flag). - * - * @param {string} themeId - Theme ID - * @param {string} layerKey - Overlay layer key (hillshade, contours, publicLands, etc.) - * @returns {object} Merged overlay config for the layer - */ -export function getOverlayConfig(themeId, layerKey) { - const theme = getTheme(themeId) - const builtinTheme = theme.dark ? themes.dark : themes.light - const builtinOverlay = builtinTheme.overlay[layerKey] || {} - - // For contour variants, cascade from same theme's contours config - let baseConfig = builtinOverlay - if (layerKey === 'contoursTest' || layerKey === 'contoursTest10ft') { - const contoursBase = builtinTheme.overlay.contours || {} - baseConfig = { ...contoursBase, ...builtinOverlay } - } - - // If this is a custom theme with overlay overrides, merge them - if (theme.overlay && theme.overlay[layerKey]) { - // For contour variants in custom themes, also cascade from custom contours - if (layerKey === 'contoursTest' || layerKey === 'contoursTest10ft') { - const customContours = theme.overlay.contours || {} - return { ...baseConfig, ...customContours, ...theme.overlay[layerKey] } - } - return { ...baseConfig, ...theme.overlay[layerKey] } - } - - return baseConfig -} - -/** - * Apply theme UI CSS custom properties to the document - * - * Sets the data-theme attribute AND applies all CSS variables from the - * theme's ui object directly to document.documentElement.style. - * - * Also manages font imports: removes previously injected font tags - * and injects new ones for the current theme's fontImports array. - * - * For custom themes, missing ui keys fall back to the appropriate built-in - * theme (dark or light based on theme.dark flag). - * - * @param {object} theme - Theme config object (from getTheme()) - */ -export function applyThemeUI(theme) { - const root = document.documentElement - - // Set data-theme attribute for any CSS selectors that still reference it - root.setAttribute('data-theme', theme.id) - - // Get base UI config from appropriate built-in theme - const builtinTheme = theme.dark ? themes.dark : themes.light - const baseUI = builtinTheme.ui - - // Merge with any custom theme overrides - const ui = theme.ui ? { ...baseUI, ...theme.ui } : baseUI - - // Apply all UI variables directly to root element style - for (const [prop, value] of Object.entries(ui)) { - root.style.setProperty(prop, value) - } - - // Manage font imports - // Remove any previously injected theme font links - document.querySelectorAll('link[data-theme-font]').forEach(link => link.remove()) - - // Inject new font links for this theme - const fontImports = theme.fontImports || [] - for (const url of fontImports) { - const link = document.createElement('link') - link.rel = 'stylesheet' - link.href = url - link.setAttribute('data-theme-font', theme.id) - document.head.appendChild(link) - } -} - -/** - * Get list of available themes for UI display - * @returns {Array<{id: string, name: string, dark: boolean, swatch: string[]}>} - */ -export function themeList() { - return Object.values(themes).map(({ id, name, dark, swatch }) => ({ id, name, dark, swatch })) -} - -/** - * Check if a theme ID is valid/registered - * @param {string} id - Theme ID to check - * @returns {boolean} - */ -export function isValidTheme(id) { - return id in themes -} - -export default themes + // Custom themes go here. Example: + // 'midnight': { + // id: 'midnight', + // name: 'Midnight', + // dark: true, + // colors: { /* full flavor object matching dark-flavor-reference.json schema */ }, + // satellite: { opacity: 0.8, brightnessMin: 0.1 }, + // overlay: { /* partial overrides - missing keys fall back to dark overlay */ }, + // ui: { /* partial overrides - missing keys fall back to dark ui */ }, + // swatch: ['#0a0a12', '#6060ff', '#4040a0'], + // fontImports: ['https://fonts.googleapis.com/css2?family=Orbitron&display=swap'], + // }, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// EXPORTED FUNCTIONS +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Get a theme configuration by ID + * @param {string} id - Theme ID + * @returns {object} Theme config, falls back to 'dark' if not found + */ +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 + * dark or light sprite based on the theme's dark flag. + * @param {string} id - Theme ID + * @returns {string} Full sprite URL + */ +export function getThemeSprite(id) { + const theme = getTheme(id) + // Custom themes don't have matching sprites on CDN - fall back based on dark flag + const spriteTheme = theme.colors === null ? id : (theme.dark ? 'dark' : 'light') + return `https://protomaps.github.io/basemaps-assets/sprites/v4/${spriteTheme}` +} + +/** + * Get overlay configuration for a specific layer + * + * For contour variants (contoursTest, contoursTest10ft), missing keys cascade + * from the same theme's contours config. + * + * For custom themes, missing keys fall back to the appropriate built-in theme + * (dark or light based on theme.dark flag). + * + * @param {string} themeId - Theme ID + * @param {string} layerKey - Overlay layer key (hillshade, contours, publicLands, etc.) + * @returns {object} Merged overlay config for the layer + */ +export function getOverlayConfig(themeId, layerKey) { + const theme = getTheme(themeId) + const builtinTheme = theme.dark ? themes.dark : themes.light + const builtinOverlay = builtinTheme.overlay[layerKey] || {} + + // For contour variants, cascade from same theme's contours config + let baseConfig = builtinOverlay + if (layerKey === 'contoursTest' || layerKey === 'contoursTest10ft') { + const contoursBase = builtinTheme.overlay.contours || {} + baseConfig = { ...contoursBase, ...builtinOverlay } + } + + // If this is a custom theme with overlay overrides, merge them + if (theme.overlay && theme.overlay[layerKey]) { + // For contour variants in custom themes, also cascade from custom contours + if (layerKey === 'contoursTest' || layerKey === 'contoursTest10ft') { + const customContours = theme.overlay.contours || {} + return { ...baseConfig, ...customContours, ...theme.overlay[layerKey] } + } + return { ...baseConfig, ...theme.overlay[layerKey] } + } + + return baseConfig +} + +/** + * Apply theme UI CSS custom properties to the document + * + * Sets the data-theme attribute AND applies all CSS variables from the + * theme's ui object directly to document.documentElement.style. + * + * Also manages font imports: removes previously injected font tags + * and injects new ones for the current theme's fontImports array. + * + * For custom themes, missing ui keys fall back to the appropriate built-in + * theme (dark or light based on theme.dark flag). + * + * @param {object} theme - Theme config object (from getTheme()) + */ +export function applyThemeUI(theme) { + const root = document.documentElement + + // Set data-theme attribute for any CSS selectors that still reference it + root.setAttribute('data-theme', theme.id) + + // Get base UI config from appropriate built-in theme + const builtinTheme = theme.dark ? themes.dark : themes.light + const baseUI = builtinTheme.ui + + // Merge with any custom theme overrides + const ui = theme.ui ? { ...baseUI, ...theme.ui } : baseUI + + // Apply all UI variables directly to root element style + for (const [prop, value] of Object.entries(ui)) { + root.style.setProperty(prop, value) + } + + // Manage font imports + // Remove any previously injected theme font links + document.querySelectorAll('link[data-theme-font]').forEach(link => link.remove()) + + // Inject new font links for this theme + const fontImports = theme.fontImports || [] + for (const url of fontImports) { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = url + link.setAttribute('data-theme-font', theme.id) + document.head.appendChild(link) + } +} + +/** + * Get list of available themes for UI display + * @returns {Array<{id: string, name: string, dark: boolean, swatch: string[]}>} + */ +export function themeList() { + return Object.values(themes).map(({ id, name, dark, swatch }) => ({ id, name, dark, swatch })) +} + +/** + * Check if a theme ID is valid/registered + * @param {string} id - Theme ID to check + * @returns {boolean} + */ +export function isValidTheme(id) { + return id in themes +} + +export default themes diff --git a/src/themes/tactical.js b/src/themes/tactical.js deleted file mode 100644 index 9ac5ddf..0000000 --- a/src/themes/tactical.js +++ /dev/null @@ -1,328 +0,0 @@ -/** - * 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