From f0acea33a097a72c5589819281eef4045d5b8e6b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 16:17:26 +0000 Subject: [PATCH 01/56] feat(themes): consolidate UI CSS properties into theme registry - Add darkUI and lightUI objects with all 25 CSS custom properties - Add applyThemeUI() function to apply CSS vars via JavaScript - Update useTheme.js to call applyThemeUI() instead of setAttribute - Remove [data-theme="dark"] and [data-theme="light"] from index.css - Custom themes can now override individual UI properties with cascade - Update README.md to document the ui key and cascade behavior Co-Authored-By: Claude Opus 4.5 --- src/hooks/useTheme.js | 13 ++++-- src/index.css | 78 ++----------------------------- src/themes/README.md | 91 ++++++++++++++++++++++++++++++------ src/themes/registry.js | 102 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 93 deletions(-) diff --git a/src/hooks/useTheme.js b/src/hooks/useTheme.js index f09d835..fa374e5 100644 --- a/src/hooks/useTheme.js +++ b/src/hooks/useTheme.js @@ -1,12 +1,13 @@ import { useEffect } from 'react' import { useStore } from '../store' +import { getTheme, applyThemeUI } from '../themes/registry' /** * Initializes and manages the theme system. * Call once in App — it handles: * - Reading localStorage override on mount * - Listening to system prefers-color-scheme - * - Applying data-theme to + * - Applying theme UI via registry (CSS custom properties) * - Updating store.theme (resolved value) */ export function useTheme() { @@ -16,8 +17,11 @@ export function useTheme() { // Initialize override from localStorage on first mount useEffect(() => { const stored = localStorage.getItem('navi-theme-override') - if (stored === 'dark' || stored === 'light') { - useStore.getState().setThemeOverride(stored) + if (stored) { + const theme = getTheme(stored) + if (theme) { + useStore.getState().setThemeOverride(stored) + } } }, []) @@ -30,7 +34,8 @@ export function useTheme() { function apply() { const resolved = resolve() - document.documentElement.setAttribute('data-theme', resolved) + const theme = getTheme(resolved) + applyThemeUI(theme) setTheme(resolved) } diff --git a/src/index.css b/src/index.css index 84ab9b2..8368268 100644 --- a/src/index.css +++ b/src/index.css @@ -4,6 +4,10 @@ NAVI DESIGN TOKENS Warm grays, sage greens, khaki tans, deep blacks. No blue in UI chrome. + + NOTE: Color tokens (--bg-*, --text-*, --border, etc.) + are now applied via applyThemeUI() in src/themes/registry.js. + Each theme defines its own ui object with CSS custom properties. ═══════════════════════════════════════════════════════ */ :root { @@ -19,80 +23,6 @@ --text-lg: 1.125rem; /* 18px */ } -/* ═══ DARK MODE (default) ═══ */ -[data-theme="dark"] { - --bg-base: #1c1917; /* warm off-black (was #0f1210) */ - --bg-raised: #252220; /* raised surface (was #181d1a) */ - --bg-overlay: #2e2a27; /* overlay/dropdown (was #1e2522) */ - --bg-input: #201d1a; /* input fields (was #141a16) */ - - --text-primary: #dde3dc; - --text-secondary: #8f9a8e; - --text-tertiary: #5e6b5d; - --text-inverse: #1c1917; - - --border: #3a3530; /* warm brown-gray (was #2a3329) */ - --border-subtle: #2a2624; /* (was #1f261e) */ - - --accent: #7a9a6b; /* sage green — interactive states */ - --accent-hover: #8fad7f; - --accent-muted: #3d4d36; - - --tan: #b8a88a; /* khaki — secondary highlights */ - --tan-muted: #4a4235; - - --pin-origin: #6b8f5e; /* sage */ - --pin-destination: #a67c52; /* rust/tan */ - --pin-intermediate: #6b7268; /* warm gray */ - --pin-stroke: #1c1917; - - --status-success: #6b8f5e; - --status-warning: #b89a4a; - --status-danger: #a65c52; - - --route-line: #7a9a6b; - - --shadow: 0 2px 8px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5); -} - -/* ═══ LIGHT MODE ═══ */ -[data-theme="light"] { - --bg-base: #ddd2b9; /* warm khaki-tan (was #ece8e1) */ - --bg-raised: #e8dec8; /* raised surface (was #f5f2ec) */ - --bg-overlay: #e3d9c1; /* overlay/dropdown (was #f0ece5) */ - --bg-input: #e8dec8; /* input fields (was #f5f2ec) */ - - --text-primary: #1a1d1a; - --text-secondary: #4f5a49; /* darkened for WCAG AA on new base (was #5c6558) */ - --text-tertiary: #7a8674; /* darkened proportionally (was #8a9486) */ - --text-inverse: #f5f2ed; - - --border: #c4b89e; /* warmer border (was #d4cfc5) */ - --border-subtle: #d5cab2; /* warmer subtle border (was #e8e3db) */ - - --accent: #4a7040; - --accent-hover: #3d5e35; - --accent-muted: #dce8d6; - - --tan: #8a7556; - --tan-muted: #f0e8d8; - - --pin-origin: #4a7040; - --pin-destination: #8a5c35; - --pin-intermediate: #6b6960; - --pin-stroke: #1a1d1a; - - --status-success: #4a7040; - --status-warning: #8a7040; - --status-danger: #8a4040; - - --route-line: #4a7040; - - --shadow: 0 2px 8px rgba(0, 0, 0, 0.08); - --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12); -} - /* ═══ BASE STYLES ═══ */ html, body, #root { margin: 0; diff --git a/src/themes/README.md b/src/themes/README.md index e110d59..baf4aea 100644 --- a/src/themes/README.md +++ b/src/themes/README.md @@ -4,7 +4,7 @@ This directory contains the theme registry and reference files for creating cust ## Files -- **registry.js** - Theme registry with getTheme(), getThemeColors(), getThemeSprite(), themeList() +- **registry.js** - Theme registry with getTheme(), getThemeColors(), getThemeSprite(), getOverlayConfig(), applyThemeUI(), themeList() - **dark-flavor-reference.json** - Full namedTheme('dark') output for reference - **light-flavor-reference.json** - Full namedTheme('light') output for reference @@ -19,11 +19,11 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: ```javascript { // === FLAT COLOR KEYS (73 total) === - + // Background & earth "background": "#34373d", "earth": "#1f1f1f", - + // Land use areas "park_a": "#1c2421", "park_b": "#192a24", @@ -45,7 +45,7 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "military": "#242323", "pier": "#333333", "buildings": "#111111", - + // Tunnels "tunnel_other_casing": "#141414", "tunnel_minor_casing": "#141414", @@ -57,7 +57,7 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "tunnel_link": "#292929", "tunnel_major": "#292929", "tunnel_highway": "#292929", - + // Roads & casings "minor_service_casing": "#1f1f1f", "minor_casing": "#1f1f1f", @@ -75,7 +75,7 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "highway": "#474747", "railway": "#000000", "boundaries": "#5b6374", - + // Bridges "bridges_other_casing": "#2b2b2b", "bridges_minor_casing": "#1f1f1f", @@ -87,7 +87,7 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "bridges_link": "#3d3d3d", "bridges_major": "#3d3d3d", "bridges_highway": "#474747", - + // Labels "waterway_label": "#717784", "roads_label_minor": "#525252", @@ -105,9 +105,9 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "country_label": "#5c5c5c", "address_label": "#525252", "address_label_halo": "#1f1f1f", - + // === NESTED OBJECTS (REQUIRED) === - + // POI icon colors - all 8 keys required "pois": { "blue": "#4299BB", @@ -119,7 +119,7 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "tangerine": "#F19B6E", "turquoise": "#00C3D4" }, - + // Landcover fill colors - all 7 keys required "landcover": { "grassland": "rgba(30, 41, 31, 1)", @@ -140,11 +140,11 @@ Add custom themes to `registry.js`: ```javascript const themes = { // ... existing themes ... - + 'sepia': { id: 'sepia', name: 'Sepia', - dark: false, // Affects overlay styling and sprite fallback + dark: false, // Affects overlay styling, sprite fallback, and UI cascade colors: { // Full flavor object (all 73 flat keys + pois + landcover) }, @@ -157,14 +157,75 @@ const themes = { saturation: 0, hueRotate: 0, }, - overlay: null, // Reserved for future use + overlay: null, // Optional: custom overlay config, cascades from dark/light + ui: null, // Optional: custom UI CSS vars, cascades from dark/light }, } ``` +### UI Customization + +Each theme can define a `ui` object containing CSS custom properties for the application chrome. +Custom themes cascade from the base dark/light UI based on the `dark` flag. + +```javascript +// Full list of UI properties (25 total) +ui: { + '--bg-base': '#1c1917', + '--bg-raised': '#252220', + '--bg-overlay': '#2e2a27', + '--bg-input': '#201d1a', + '--text-primary': '#dde3dc', + '--text-secondary': '#8f9a8e', + '--text-tertiary': '#5e6b5d', + '--text-inverse': '#1c1917', + '--border': '#3a3530', + '--border-subtle': '#2a2624', + '--accent': '#7a9a6b', + '--accent-hover': '#8fad7f', + '--accent-muted': '#3d4d36', + '--tan': '#b8a88a', + '--tan-muted': '#4a4235', + '--pin-origin': '#6b8f5e', + '--pin-destination': '#a67c52', + '--pin-intermediate': '#6b7268', + '--pin-stroke': '#1c1917', + '--status-success': '#6b8f5e', + '--status-warning': '#b89a4a', + '--status-danger': '#a65c52', + '--route-line': '#7a9a6b', + '--shadow': '0 2px 8px rgba(0, 0, 0, 0.4)', + '--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.5)', +} +``` + +Custom themes only need to specify the properties they want to override: + +```javascript +'sepia': { + id: 'sepia', + name: 'Sepia', + dark: false, + colors: { /* ... */ }, + ui: { + // Only override what's different from the light theme + '--accent': '#8a7040', + '--accent-hover': '#6b5530', + '--tan': '#8a7556', + }, +} +``` + +### Overlay Customization + +Overlay styling (hillshade, traffic, contours, public lands, USFS trails, BLM trails) is also +configurable per-theme. See `darkOverlay` and `lightOverlay` in registry.js for the full +structure. Custom themes cascade from dark/light based on the `dark` flag. + ### Important Notes -1. **All keys are required** - protomaps-themes-base expects every key +1. **All color keys are required** - protomaps-themes-base expects every key 2. **Nested objects matter** - `pois` and `landcover` are objects, not flat keys 3. **Sprite fallback** - Custom themes fall back to dark/light sprite based on `dark` flag -4. **CSS vars separate** - Map flavor colors are separate from UI CSS custom properties +4. **Cascading configs** - overlay and ui configs cascade from dark/light if not specified +5. **CSS vars via JS** - UI CSS properties are applied via `applyThemeUI()`, not CSS selectors diff --git a/src/themes/registry.js b/src/themes/registry.js index 1ef2f04..95246f9 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -11,10 +11,79 @@ * 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 */ import { namedTheme } from 'protomaps-themes-base' +// ═══════════════════════════════════════════════════════════════════════════ +// UI CSS CUSTOM PROPERTIES +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Dark theme UI configuration + * All CSS custom properties from [data-theme="dark"] in index.css + */ +const darkUI = { + '--bg-base': '#1c1917', + '--bg-raised': '#252220', + '--bg-overlay': '#2e2a27', + '--bg-input': '#201d1a', + '--text-primary': '#dde3dc', + '--text-secondary': '#8f9a8e', + '--text-tertiary': '#5e6b5d', + '--text-inverse': '#1c1917', + '--border': '#3a3530', + '--border-subtle': '#2a2624', + '--accent': '#7a9a6b', + '--accent-hover': '#8fad7f', + '--accent-muted': '#3d4d36', + '--tan': '#b8a88a', + '--tan-muted': '#4a4235', + '--pin-origin': '#6b8f5e', + '--pin-destination': '#a67c52', + '--pin-intermediate': '#6b7268', + '--pin-stroke': '#1c1917', + '--status-success': '#6b8f5e', + '--status-warning': '#b89a4a', + '--status-danger': '#a65c52', + '--route-line': '#7a9a6b', + '--shadow': '0 2px 8px rgba(0, 0, 0, 0.4)', + '--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.5)', +} + +/** + * Light theme UI configuration + * All CSS custom properties from [data-theme="light"] in index.css + */ +const lightUI = { + '--bg-base': '#ddd2b9', + '--bg-raised': '#e8dec8', + '--bg-overlay': '#e3d9c1', + '--bg-input': '#e8dec8', + '--text-primary': '#1a1d1a', + '--text-secondary': '#4f5a49', + '--text-tertiary': '#7a8674', + '--text-inverse': '#f5f2ed', + '--border': '#c4b89e', + '--border-subtle': '#d5cab2', + '--accent': '#4a7040', + '--accent-hover': '#3d5e35', + '--accent-muted': '#dce8d6', + '--tan': '#8a7556', + '--tan-muted': '#f0e8d8', + '--pin-origin': '#4a7040', + '--pin-destination': '#8a5c35', + '--pin-intermediate': '#6b6960', + '--pin-stroke': '#1a1d1a', + '--status-success': '#4a7040', + '--status-warning': '#8a7040', + '--status-danger': '#8a4040', + '--route-line': '#4a7040', + '--shadow': '0 2px 8px rgba(0, 0, 0, 0.08)', + '--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.12)', +} + // ═══════════════════════════════════════════════════════════════════════════ // OVERLAY CONFIGURATIONS // ═══════════════════════════════════════════════════════════════════════════ @@ -361,6 +430,7 @@ const themes = { colors: null, // Use namedTheme('light') satellite: null, overlay: lightOverlay, + ui: lightUI, }, dark: { id: 'dark', @@ -369,6 +439,7 @@ const themes = { colors: null, // Use namedTheme('dark') satellite: null, overlay: darkOverlay, + ui: darkUI, }, // Custom themes go here. Example: // 'midnight': { @@ -378,6 +449,7 @@ const themes = { // 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 */ }, // }, } @@ -461,6 +533,36 @@ export function getOverlayConfig(themeId, 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. + * + * 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) + } +} + /** * Get list of available themes for UI display * @returns {Array<{id: string, name: string, dark: boolean}>} From 7ec87f0945a8727f03e47a629b85e5ecc03726c9 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 16:27:08 +0000 Subject: [PATCH 02/56] =?UTF-8?q?feat(themes):=20add=20Clean=20theme=20?= =?UTF-8?q?=E2=80=94=20Google=20Maps-inspired=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a plain, utilitarian theme focused on readability and wayfinding: - White/light gray land (#f5f5f5), soft pastel green parks (#c3ecb2) - Gentle blue water (#aadaff) with classic road hierarchy - White minor roads, yellow primary (#fbc02d), orange motorway (#f9a825) - Pure white UI panels with Google-standard gray text - All 73 protomaps flavor keys + pois + landcover objects - Full UI CSS custom properties using Google color palette - Overlay config for hillshade, contours, public lands, trails Update theme switcher to cycle through all available themes. Co-Authored-By: Claude Opus 4.5 --- src/components/Panel.jsx | 35 +++- src/themes/clean.js | 351 +++++++++++++++++++++++++++++++++++++++ src/themes/registry.js | 4 +- 3 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 src/themes/clean.js diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index e490324..cd6dd43 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,5 +1,6 @@ import { useRef, useCallback, useEffect, useState } from 'react' -import { Sun, Moon, LogIn, LogOut } from 'lucide-react' +import { Sun, Moon, Sparkles, LogIn, LogOut } from 'lucide-react' +import { themeList } from '../themes/registry' import { useStore, usePanelState } from '../store' import { hasFeature } from '../config' import SearchBar from './SearchBar' @@ -54,10 +55,30 @@ export default function Panel({ onManeuverClick }) { return () => window.removeEventListener('resize', check) }, []) - // Theme toggle + // Theme toggle - cycles through all available themes const toggleTheme = () => { - const next = theme === 'dark' ? 'light' : 'dark' - setThemeOverride(next) + const themes = themeList() + const currentIdx = themes.findIndex(t => t.id === theme) + const nextIdx = (currentIdx + 1) % themes.length + setThemeOverride(themes[nextIdx].id) + } + + // Get theme icon based on current theme + const getThemeIcon = () => { + switch (theme) { + case 'dark': return + case 'light': return + case 'clean': return + default: return + } + } + + // Get next theme name for tooltip + const getNextThemeName = () => { + const themes = themeList() + const currentIdx = themes.findIndex(t => t.id === theme) + const nextIdx = (currentIdx + 1) % themes.length + return themes[nextIdx].name } // Auth handlers @@ -257,10 +278,10 @@ export default function Panel({ onManeuverClick }) { onClick={toggleTheme} className="p-1.5 rounded" style={{ color: 'var(--text-secondary)' }} - aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`} - title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`} + aria-label={`Switch to ${getNextThemeName()} theme`} + title={`Switch to ${getNextThemeName()} theme`} > - {theme === 'dark' ? : } + {getThemeIcon()} diff --git a/src/themes/clean.js b/src/themes/clean.js new file mode 100644 index 0000000..3215ede --- /dev/null +++ b/src/themes/clean.js @@ -0,0 +1,351 @@ +/** + * Clean Theme for Navi + * + * A plain, familiar, Google Maps-inspired style focused on maximum usability. + * Clean, neutral, utilitarian. White/light gray land, soft pastel green parks, + * gentle blue water, classic gray→yellow→orange road hierarchy. No strong + * personality — everything serves readability and wayfinding. + * + * The theme equivalent of a rental car: nothing exciting, nothing wrong. + */ + +// ═══════════════════════════════════════════════════════════════════════════ +// PALETTE +// ═══════════════════════════════════════════════════════════════════════════ +// +// base: #f5f5f5 ← land, app background +// surface: #ffffff ← panels, cards, modals +// surfaceAlt: #f8f9fa ← secondary panels, hover states +// border: #dadce0 ← Google's standard border gray +// text: #202124 ← primary text (Google dark) +// textSecondary: #5f6368 ← secondary text +// textMuted: #9aa0a6 ← placeholders, hints +// accent: #1a73e8 ← Google blue — links, active states +// accentHover: #1557b0 ← darker blue hover +// success: #34a853 ← Google green +// warning: #fbbc04 ← Google yellow +// danger: #ea4335 ← Google red +// water: #aadaff ← soft sky blue (Google's water) +// waterDark: #73b3e8 ← water labels +// vegetation: #c3ecb2 ← pastel green parks +// forest: #a8dda0 ← slightly deeper green +// road: #ffffff ← minor roads — white +// roadPrimary: #fbc02d ← yellow +// roadMotorway: #f9a825 ← deeper yellow-orange +// roadCasing: #e0e0e0 ← light gray casing +// building: #e8e4de ← warm light gray +// contour: #c8b8a0 ← subtle warm brown +// +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const cleanColors = { + // Background & earth + background: '#e8e8e8', + earth: '#f5f5f5', + + // Land use areas + park_a: '#d4ecd0', + park_b: '#c3ecb2', + hospital: '#fde8e8', + industrial: '#ebeff1', + school: '#fff3e0', + wood_a: '#d8ecd4', + wood_b: '#a8dda0', + pedestrian: '#f0f0f0', + scrub_a: '#dcecd8', + scrub_b: '#c8e4c0', + glacier: '#f8fcff', + sand: '#f5f0e0', + beach: '#fef8e0', + aerodrome: '#eaecef', + runway: '#d0d0d0', + water: '#aadaff', + zoo: '#d8e8d8', + military: '#e8e8e8', + + // Tunnels + tunnel_other_casing: '#d8d8d8', + tunnel_minor_casing: '#d8d8d8', + tunnel_link_casing: '#d8d8d8', + tunnel_major_casing: '#d8d8d8', + tunnel_highway_casing: '#d8d8d8', + tunnel_other: '#e8e8e8', + tunnel_minor: '#e8e8e8', + tunnel_link: '#f0e0a0', + tunnel_major: '#f0e0a0', + tunnel_highway: '#f0d080', + + // Pier & buildings + pier: '#e0e0e0', + buildings: '#e8e4de', + + // Roads & casings + minor_service_casing: '#e0e0e0', + minor_casing: '#e0e0e0', + link_casing: '#d8c080', + major_casing_late: '#d8c080', + highway_casing_late: '#d8a860', + other: '#f0f0f0', + minor_service: '#ffffff', + minor_a: '#ffffff', + minor_b: '#ffffff', + link: '#fbc02d', + major_casing_early: '#d8c080', + major: '#fbc02d', + highway_casing_early: '#d8a860', + highway: '#f9a825', + railway: '#a0a0a0', + boundaries: '#c0c0c0', + + // Waterway label + waterway_label: '#73b3e8', + + // Bridges + bridges_other_casing: '#d0d0d0', + bridges_minor_casing: '#d0d0d0', + bridges_link_casing: '#d8c080', + bridges_major_casing: '#d8c080', + bridges_highway_casing: '#d8a860', + bridges_other: '#f0f0f0', + bridges_minor: '#ffffff', + bridges_link: '#fbc02d', + bridges_major: '#fbc02d', + bridges_highway: '#f9a825', + + // Labels + roads_label_minor: '#5f6368', + roads_label_minor_halo: '#ffffff', + roads_label_major: '#5f6368', + roads_label_major_halo: '#ffffff', + ocean_label: '#73b3e8', + peak_label: '#5f6368', + subplace_label: '#5f6368', + subplace_label_halo: '#ffffff', + city_label: '#202124', + city_label_halo: '#ffffff', + state_label: '#9aa0a6', + state_label_halo: '#ffffff', + country_label: '#5f6368', + address_label: '#5f6368', + address_label_halo: '#ffffff', + + // POI icon colors + pois: { + blue: '#1a73e8', + green: '#34a853', + lapis: '#4285f4', + pink: '#e91e63', + red: '#ea4335', + slategray: '#5f6368', + tangerine: '#f9a825', + turquoise: '#00bcd4', + }, + + // Landcover fill colors + landcover: { + grassland: 'rgba(200, 232, 192, 1)', + barren: 'rgba(240, 235, 220, 1)', + urban_area: 'rgba(235, 235, 235, 1)', + farmland: 'rgba(216, 240, 210, 1)', + glacier: 'rgba(250, 252, 255, 1)', + scrub: 'rgba(220, 236, 216, 1)', + forest: 'rgba(180, 224, 176, 1)', + }, +} + +/** + * UI CSS custom properties - app chrome styling + * Clean Google-inspired white panels with standard gray text + */ +const cleanUI = { + '--bg-base': '#f5f5f5', + '--bg-raised': '#ffffff', + '--bg-overlay': '#ffffff', + '--bg-input': '#ffffff', + '--text-primary': '#202124', + '--text-secondary': '#5f6368', + '--text-tertiary': '#9aa0a6', + '--text-inverse': '#ffffff', + '--border': '#dadce0', + '--border-subtle': '#e8eaed', + '--accent': '#1a73e8', + '--accent-hover': '#1557b0', + '--accent-muted': '#e8f0fe', + '--tan': '#f9a825', + '--tan-muted': '#fef7e0', + '--pin-origin': '#34a853', + '--pin-destination': '#ea4335', + '--pin-intermediate': '#5f6368', + '--pin-stroke': '#ffffff', + '--status-success': '#34a853', + '--status-warning': '#fbbc04', + '--status-danger': '#ea4335', + '--route-line': '#1a73e8', + '--shadow': '0 1px 3px rgba(60, 64, 67, 0.15), 0 1px 2px rgba(60, 64, 67, 0.1)', + '--shadow-lg': '0 2px 6px rgba(60, 64, 67, 0.2), 0 1px 3px rgba(60, 64, 67, 0.15)', +} + +/** + * Overlay configuration overrides + * Light shadow hillshade, warm brown contours, standard public lands + */ +const cleanOverlay = { + // Hillshade - light and natural + hillshade: { + exaggeration: 0.4, + illuminationDirection: 315, + shadowColor: '#000000', + highlightColor: '#ffffff', + }, + + // Contours - warm brown, subtle + contours: { + opacityMod: 0.9, + minorColor: '#c8b8a0', + minorOpacity: 0.35, + minorWidth: { z11: 0.5, z14: 0.8 }, + intermediateColor: '#c8b8a0', + intermediateOpacity: 0.55, + intermediateWidth: { z8: 0.7, z14: 1.0 }, + indexColor: '#a89878', + indexOpacity: 0.75, + indexWidth: { z4: 1.0, z14: 1.5 }, + labelColor: '#8a7a60', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 10, + labelFont: ['Noto Sans Regular'], + }, + + // Contours Test - blue variant + contoursTest: { + minorColor: '#5a9ab8', + intermediateColor: '#5a9ab8', + indexColor: '#3a7a98', + labelColor: '#3a6a88', + }, + + // Contours Test 10ft - green variant + contoursTest10ft: { + minorColor: '#4a9a5f', + intermediateColor: '#4a9a5f', + indexColor: '#2a7a4a', + labelColor: '#2a5a40', + }, + + // Public Lands - standard green tints with dark labels + publicLands: { + opacityMod: 0.9, + // Fill colors per category + fillWA: '#8a7a40', + fillNPS: '#4a8030', + fillUSFS: '#6a9040', + fillBLM: '#d4b880', + fillFWS: '#5a9068', + fillSTAT: '#6aa088', + fillLOC: '#9ab8a8', + fillDefault: '#b0b0b0', + // Fill opacities + fillOpacityWA: 0.25, + fillOpacityNPS: 0.25, + fillOpacityUSFS: 0.20, + fillOpacityBLM: 0.18, + fillOpacitySTAT: 0.22, + fillOpacityLOC: 0.18, + fillOpacityDefault: 0.12, + // Outline colors + outlineWA: '#6a5a28', + outlineNPS: '#2a5018', + outlineUSFS: '#4a6828', + outlineBLM: '#9a8050', + outlineFWS: '#3a6848', + outlineSTAT: '#4a7060', + outlineLOC: '#6a8070', + outlineDefault: '#808080', + // Outline opacities + outlineOpacityNPS: 0.65, + outlineOpacityUSFS: 0.55, + outlineOpacityDefault: 0.45, + // Outline width + outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, + // Labels - dark for readability + labelColor: '#2a3a28', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: { z10: 10, z14: 13 }, + labelFont: ['Noto Sans Regular'], + }, + + // USFS Trails - standard trail colors + usfsTrails: { + roadsColor: '#c09050', + roadsOpacity: 0.85, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + trailsMotorized: '#e07030', + trailsBicycle: '#d0a030', + trailsHiker: '#50b040', + trailsDefault: '#b09050', + trailsOpacity: 0.85, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + roadsLabelColor: '#5a4a30', + roadsLabelHaloColor: '#ffffff', + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.85, + roadsLabelSize: 11, + trailsLabelColor: '#4a3a28', + trailsLabelHaloColor: '#ffffff', + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.85, + trailsLabelSize: 11, + labelFont: ['Noto Sans Regular'], + hitWidth: 14, + }, + + // BLM Trails - standard route colors + blmTrails: { + color4wdHigh: '#e07030', + color4wdLow: '#d0a030', + colorAtv: '#d03030', + colorMotoSingle: '#a060b0', + color2wdLow: '#e0c060', + colorNonMech: '#50b040', + colorDefault: '#b09050', + colorSnow: '#6090c0', + lineOpacity: 0.85, + lineOpacityOther: 0.80, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + labelColor: '#4a3a28', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: 11, + labelFont: ['Noto Sans Regular'], + hitWidth: 14, + }, +} + +/** + * Clean theme configuration + */ +const cleanTheme = { + id: 'clean', + name: 'Clean', + dark: false, + colors: cleanColors, + satellite: null, // No adjustments — default clear view + overlay: cleanOverlay, + ui: cleanUI, +} + +export default cleanTheme diff --git a/src/themes/registry.js b/src/themes/registry.js index 95246f9..dfc419d 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -14,7 +14,8 @@ * ui: object - CSS custom properties for UI elements */ -import { namedTheme } from 'protomaps-themes-base' +import { namedTheme } from 'protomaps-themes-base' +import cleanTheme from './clean.js' // ═══════════════════════════════════════════════════════════════════════════ // UI CSS CUSTOM PROPERTIES @@ -441,6 +442,7 @@ const themes = { overlay: darkOverlay, ui: darkUI, }, + clean: cleanTheme, // Custom themes go here. Example: // 'midnight': { // id: 'midnight', From a7fd4e4e8cb413f5803a19054ea12a17c61a606f Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 17:42:51 +0000 Subject: [PATCH 03/56] feat(themes): add theme picker with swatch previews and per-theme fonts PART 1: Add missing CSS variables to all ui objects - Add --bg-inset, --bg-muted for component backgrounds - Add --success, --warning as aliases for --status-success/warning - Add --warning-muted for warning background states - Each theme now has 32 CSS variables PART 2: Per-theme font support - Move --font-sans and --font-mono from :root to ui objects - Add fontImports array to theme config (for future custom fonts) - applyThemeUI() now manages tags for font imports - Existing themes use empty fontImports (system fonts already loaded) PART 3: Swatch preview colors - Add swatch array (3 hex colors) to each theme for visual preview - light: warm tan, sage green, khaki - dark: dark brown, sage green, tan - clean: light gray, Google blue, Google green - themeList() now returns swatch in result shape PART 4: Theme picker UI - New ThemePicker component replaces icon toggle in header - Palette icon trigger opens popover below - Shows all themes as circular swatches (conic gradient) - Active theme has accent ring indicator - Click swatch to apply theme, closes popover - Click outside or Escape closes popover - Styled with current theme CSS variables Co-Authored-By: Claude Opus 4.5 --- src/components/Panel.jsx | 40 +------- src/components/ThemePicker.jsx | 167 +++++++++++++++++++++++++++++++++ src/index.css | 5 +- src/themes/clean.js | 17 ++++ src/themes/registry.js | 87 ++++++++++++++--- 5 files changed, 263 insertions(+), 53 deletions(-) create mode 100644 src/components/ThemePicker.jsx diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index cd6dd43..2799a89 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,6 +1,6 @@ import { useRef, useCallback, useEffect, useState } from 'react' -import { Sun, Moon, Sparkles, LogIn, LogOut } from 'lucide-react' -import { themeList } from '../themes/registry' +import { LogIn, LogOut } from 'lucide-react' +import ThemePicker from './ThemePicker' import { useStore, usePanelState } from '../store' import { hasFeature } from '../config' import SearchBar from './SearchBar' @@ -55,32 +55,6 @@ export default function Panel({ onManeuverClick }) { return () => window.removeEventListener('resize', check) }, []) - // Theme toggle - cycles through all available themes - const toggleTheme = () => { - const themes = themeList() - const currentIdx = themes.findIndex(t => t.id === theme) - const nextIdx = (currentIdx + 1) % themes.length - setThemeOverride(themes[nextIdx].id) - } - - // Get theme icon based on current theme - const getThemeIcon = () => { - switch (theme) { - case 'dark': return - case 'light': return - case 'clean': return - default: return - } - } - - // Get next theme name for tooltip - const getNextThemeName = () => { - const themes = themeList() - const currentIdx = themes.findIndex(t => t.id === theme) - const nextIdx = (currentIdx + 1) % themes.length - return themes[nextIdx].name - } - // 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/' } @@ -274,15 +248,7 @@ export default function Panel({ onManeuverClick }) { ) )} - + ) diff --git a/src/components/ThemePicker.jsx b/src/components/ThemePicker.jsx new file mode 100644 index 0000000..b9f7792 --- /dev/null +++ b/src/components/ThemePicker.jsx @@ -0,0 +1,167 @@ +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/index.css b/src/index.css index 8368268..6a2bd4c 100644 --- a/src/index.css +++ b/src/index.css @@ -11,11 +11,8 @@ ═══════════════════════════════════════════════════════ */ :root { - /* ── Typography ── */ - --font-sans: 'Inter', system-ui, -apple-system, sans-serif; - --font-mono: 'JetBrains Mono', ui-monospace, monospace; - /* ── Type scale ── */ + /* Font families (--font-sans, --font-mono) are now in theme ui objects */ --text-xs: 0.6875rem; /* 11px */ --text-sm: 0.8125rem; /* 13px */ --text-base: 0.875rem; /* 14px */ diff --git a/src/themes/clean.js b/src/themes/clean.js index 3215ede..2a7057e 100644 --- a/src/themes/clean.js +++ b/src/themes/clean.js @@ -162,29 +162,46 @@ const cleanColors = { * Clean Google-inspired white panels with standard gray text */ const cleanUI = { + // Fonts + '--font-sans': "'Inter', system-ui, -apple-system, sans-serif", + '--font-mono': "'JetBrains Mono', ui-monospace, monospace", + // Backgrounds '--bg-base': '#f5f5f5', '--bg-raised': '#ffffff', '--bg-overlay': '#ffffff', '--bg-input': '#ffffff', + '--bg-inset': '#f0f0f0', + '--bg-muted': '#f8f9fa', + // Text '--text-primary': '#202124', '--text-secondary': '#5f6368', '--text-tertiary': '#9aa0a6', '--text-inverse': '#ffffff', + // Borders '--border': '#dadce0', '--border-subtle': '#e8eaed', + // Accent '--accent': '#1a73e8', '--accent-hover': '#1557b0', '--accent-muted': '#e8f0fe', + // Tan '--tan': '#f9a825', '--tan-muted': '#fef7e0', + // Pins '--pin-origin': '#34a853', '--pin-destination': '#ea4335', '--pin-intermediate': '#5f6368', '--pin-stroke': '#ffffff', + // Status '--status-success': '#34a853', '--status-warning': '#fbbc04', '--status-danger': '#ea4335', + '--success': '#34a853', + '--warning': '#fbbc04', + '--warning-muted': '#fef7e0', + // Route '--route-line': '#1a73e8', + // Shadows '--shadow': '0 1px 3px rgba(60, 64, 67, 0.15), 0 1px 2px rgba(60, 64, 67, 0.1)', '--shadow-lg': '0 2px 6px rgba(60, 64, 67, 0.2), 0 1px 3px rgba(60, 64, 67, 0.15)', } diff --git a/src/themes/registry.js b/src/themes/registry.js index dfc419d..58627cf 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -5,16 +5,18 @@ * 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 + * 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 + * 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 { namedTheme } from 'protomaps-themes-base' import cleanTheme from './clean.js' // ═══════════════════════════════════════════════════════════════════════════ @@ -23,64 +25,98 @@ import cleanTheme from './clean.js' /** * Dark theme UI configuration - * All CSS custom properties from [data-theme="dark"] in index.css + * 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", + // 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 from [data-theme="light"] in index.css + * 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': '#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)', } @@ -432,6 +468,8 @@ const themes = { satellite: null, overlay: lightOverlay, ui: lightUI, + swatch: ['#ddd2b9', '#4a7040', '#8a7556'], + fontImports: [], }, dark: { id: 'dark', @@ -441,8 +479,14 @@ const themes = { satellite: null, overlay: darkOverlay, ui: darkUI, + swatch: ['#1c1917', '#7a9a6b', '#b8a88a'], + fontImports: [], + }, + clean: { + ...cleanTheme, + swatch: ['#f5f5f5', '#1a73e8', '#34a853'], + fontImports: [], }, - clean: cleanTheme, // Custom themes go here. Example: // 'midnight': { // id: 'midnight', @@ -452,6 +496,8 @@ const themes = { // 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'], // }, } @@ -541,6 +587,9 @@ export function getOverlayConfig(themeId, layerKey) { * 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). * @@ -563,14 +612,28 @@ export function applyThemeUI(theme) { 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}>} + * @returns {Array<{id: string, name: string, dark: boolean, swatch: string[]}>} */ export function themeList() { - return Object.values(themes).map(({ id, name, dark }) => ({ id, name, dark })) + return Object.values(themes).map(({ id, name, dark, swatch }) => ({ id, name, dark, swatch })) } /** From 6fb3ee4f49e21ab0d0bbf50af22c3429448b7d87 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 18:06:43 +0000 Subject: [PATCH 04/56] feat(themes): add Cyberpunk theme with custom fonts Add a dark, neon-soaked, dystopian command center theme: Map colors: - Near-black base (#0a0a14) with blue-purple undertones - Magenta motorways (#ff2d6b), purple primary roads (#8833aa) - Inky dark water (#06061a), barely-visible dark teal vegetation - Cool white labels with dark halos for readability - Neon POI icons (cyan, magenta, green) UI styling: - Dark translucent panels with magenta accent borders - Custom fonts: Orbitron (headings), Share Tech Mono (body) - Entire UI renders in monospace terminal aesthetic - Magenta shadows, cyan route lines, neon status colors - Pin markers in magenta (origin) and cyan (destination) Overlay adjustments: - Dramatic hillshade with darker shadows - Very subtle contours (opacityMod: 0.5) - USFS/BLM trails in purple/magenta/cyan family - Public lands with muted teal fills Satellite adjustments: - Darkened (brightnessMax: 0.30), desaturated, purple-shifted Also adds --font-heading CSS variable to all themes for future custom heading font support. Co-Authored-By: Claude Opus 4.5 --- src/themes/clean.js | 1 + src/themes/cyberpunk.js | 403 ++++++++++++++++++++++++++++++++++++++++ src/themes/registry.js | 4 + 3 files changed, 408 insertions(+) create mode 100644 src/themes/cyberpunk.js diff --git a/src/themes/clean.js b/src/themes/clean.js index 2a7057e..b13e52d 100644 --- a/src/themes/clean.js +++ b/src/themes/clean.js @@ -165,6 +165,7 @@ const cleanUI = { // Fonts '--font-sans': "'Inter', system-ui, -apple-system, sans-serif", '--font-mono': "'JetBrains Mono', ui-monospace, monospace", + '--font-heading': "'Inter', system-ui, -apple-system, sans-serif", // Backgrounds '--bg-base': '#f5f5f5', '--bg-raised': '#ffffff', diff --git a/src/themes/cyberpunk.js b/src/themes/cyberpunk.js new file mode 100644 index 0000000..86a8e92 --- /dev/null +++ b/src/themes/cyberpunk.js @@ -0,0 +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: 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/registry.js b/src/themes/registry.js index 58627cf..cc48c8f 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -18,6 +18,7 @@ import { namedTheme } from 'protomaps-themes-base' import cleanTheme from './clean.js' +import cyberpunkTheme from './cyberpunk.js' // ═══════════════════════════════════════════════════════════════════════════ // UI CSS CUSTOM PROPERTIES @@ -31,6 +32,7 @@ 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', @@ -80,6 +82,7 @@ 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', @@ -487,6 +490,7 @@ const themes = { swatch: ['#f5f5f5', '#1a73e8', '#34a853'], fontImports: [], }, + cyberpunk: cyberpunkTheme, // Custom themes go here. Example: // 'midnight': { // id: 'midnight', From 2e0a8854769f758108768493ad539222c413d1fd Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 18:21:20 +0000 Subject: [PATCH 05/56] fix(PlaceCard): prioritize wikidata path for boundary fetch When clicking basemap labels with wikidata IDs (cities, parks, etc), fetchReverse was returning the nearest POI instead of the clicked entity, blocking the wikidata fallback that returns correct boundaries. Changes: - Effect 1: Skip reverse geocode when wikidataId is present - Effect 3: Always use wikidata path when available, regardless of osmType/osmId presence This fixes missing dashed outline on area feature clicks. Co-Authored-By: Claude Opus 4.5 --- src/components/PlaceCard.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 40c4660..8443e1f 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -348,8 +348,9 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl // Reverse geocode to get OSM type/id if not present (e.g., basemap label clicks) useEffect(() => { if (!hasFeature('has_nominatim_details')) return - if (osmType && osmId) return + if (wikidataId) return // Prefer wikidata path for basemap features with wikidata if (placeLat == null || placeLon == null) return + if (osmType && osmId) return // Skip for dropped pins - they get reverse geocoded by MapView if (place?.source === 'map_click') return @@ -368,7 +369,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl } }) return () => controller.abort() - }, [placeLat, placeLon, osmType, osmId, place?.source]) + }, [wikidataId, placeLat, placeLon, osmType, osmId, place?.source]) useEffect(() => { @@ -393,7 +394,6 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl }, [osmType, osmId, placeLat, placeLon]) useEffect(() => { - if (osmType && osmId) return if (!wikidataId) return const controller = new AbortController() fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => { From fef10664c8230183eb7789e171637e9b54aba269 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 18:26:13 +0000 Subject: [PATCH 06/56] feat(themes): add theme-aware boundary highlight config Add highlight section to overlay config for all themes with theme-appropriate colors: - dark: muted olive-green (#7a9a6b) - light: forest green (#4a7040) - clean: Google blue (#1a73e8) - cyberpunk: electric cyan (#00f0ff) Update addBoundaryLayer() to read config from getOverlayConfig(themeId, "highlight") for consistent styling across all themes. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 38 +- src/themes/clean.js | 746 ++++++++++---------- src/themes/cyberpunk.js | 816 +++++++++++----------- src/themes/registry.js | 1316 ++++++++++++++++++------------------ 4 files changed, 1485 insertions(+), 1431 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index a49143e..5b8eb3c 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1247,10 +1247,10 @@ function removeBlmTrails(map) { } -/** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */ +/** Add boundary polygon layers using theme-aware highlight config */ const BOUNDARY_FILL_LAYER = 'boundary-fill-layer' -function addBoundaryLayer(map) { +function addBoundaryLayer(map, themeId) { if (!map || map.getLayer(BOUNDARY_LAYER)) return if (!map.getSource(BOUNDARY_SOURCE)) { map.addSource(BOUNDARY_SOURCE, { @@ -1258,7 +1258,14 @@ function addBoundaryLayer(map) { data: { type: "FeatureCollection", features: [] }, }) } - const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b" + // Get highlight config from theme overlay + const highlight = getOverlayConfig(themeId, "highlight") || {} + const lineColor = highlight.lineColor || "#7a9a6b" + const lineWidth = highlight.lineWidth || 2 + const lineDash = highlight.lineDash || [4, 4] + const lineOpacity = highlight.lineOpacity || 0.8 + const fillColor = highlight.fillColor || lineColor + const fillOpacity = highlight.fillOpacity || 0.08 // Find first symbol layer to insert boundary layers below labels const layers = map.getStyle().layers @@ -1276,8 +1283,8 @@ function addBoundaryLayer(map) { type: "fill", source: BOUNDARY_SOURCE, paint: { - "fill-color": accentColor, - "fill-opacity": 0.05, + "fill-color": fillColor, + "fill-opacity": fillOpacity, }, }, firstSymbolId) @@ -1287,10 +1294,10 @@ function addBoundaryLayer(map) { type: "line", source: BOUNDARY_SOURCE, paint: { - "line-color": accentColor, - "line-width": 2, - "line-opacity": 0.7, - "line-dasharray": [3, 2], + "line-color": lineColor, + "line-width": lineWidth, + "line-opacity": lineOpacity, + "line-dasharray": lineDash, }, }, firstSymbolId) } @@ -1498,7 +1505,14 @@ const MapView = forwardRef(function MapView(_, ref) { type: "geojson", data: { type: "FeatureCollection", features: [] }, }) - const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b" + // Get highlight config from theme overlay + const highlight = getOverlayConfig(themeId, "highlight") || {} + const lineColor = highlight.lineColor || "#7a9a6b" + const lineWidth = highlight.lineWidth || 2 + const lineDash = highlight.lineDash || [4, 4] + const lineOpacity = highlight.lineOpacity || 0.8 + const fillColor = highlight.fillColor || lineColor + const fillOpacity = highlight.fillOpacity || 0.08 map.addLayer({ id: MEASURE_LINE_LAYER, type: "line", @@ -2124,7 +2138,7 @@ const MapView = forwardRef(function MapView(_, ref) { // Boundary polygon layer for selected places if (!map.getLayer(BOUNDARY_LAYER)) { - addBoundaryLayer(map) + addBoundaryLayer(map, currentThemeRef.current) } // Apply improved base label styling for readability @@ -2333,7 +2347,7 @@ const MapView = forwardRef(function MapView(_, ref) { // Boundary polygon layer if (!map.getLayer(BOUNDARY_LAYER)) { - addBoundaryLayer(map) + addBoundaryLayer(map, currentThemeRef.current) } // Apply improved base label styling for readability diff --git a/src/themes/clean.js b/src/themes/clean.js index b13e52d..2ed3b87 100644 --- a/src/themes/clean.js +++ b/src/themes/clean.js @@ -1,369 +1,379 @@ -/** - * Clean Theme for Navi - * - * A plain, familiar, Google Maps-inspired style focused on maximum usability. - * Clean, neutral, utilitarian. White/light gray land, soft pastel green parks, - * gentle blue water, classic gray→yellow→orange road hierarchy. No strong - * personality — everything serves readability and wayfinding. - * - * The theme equivalent of a rental car: nothing exciting, nothing wrong. - */ - -// ═══════════════════════════════════════════════════════════════════════════ -// PALETTE -// ═══════════════════════════════════════════════════════════════════════════ -// -// base: #f5f5f5 ← land, app background -// surface: #ffffff ← panels, cards, modals -// surfaceAlt: #f8f9fa ← secondary panels, hover states -// border: #dadce0 ← Google's standard border gray -// text: #202124 ← primary text (Google dark) -// textSecondary: #5f6368 ← secondary text -// textMuted: #9aa0a6 ← placeholders, hints -// accent: #1a73e8 ← Google blue — links, active states -// accentHover: #1557b0 ← darker blue hover -// success: #34a853 ← Google green -// warning: #fbbc04 ← Google yellow -// danger: #ea4335 ← Google red -// water: #aadaff ← soft sky blue (Google's water) -// waterDark: #73b3e8 ← water labels -// vegetation: #c3ecb2 ← pastel green parks -// forest: #a8dda0 ← slightly deeper green -// road: #ffffff ← minor roads — white -// roadPrimary: #fbc02d ← yellow -// roadMotorway: #f9a825 ← deeper yellow-orange -// roadCasing: #e0e0e0 ← light gray casing -// building: #e8e4de ← warm light gray -// contour: #c8b8a0 ← subtle warm brown -// -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Map flavor colors - protomaps-themes-base schema - * All 73 flat keys + pois + landcover nested objects - */ -const cleanColors = { - // Background & earth - background: '#e8e8e8', - earth: '#f5f5f5', - - // Land use areas - park_a: '#d4ecd0', - park_b: '#c3ecb2', - hospital: '#fde8e8', - industrial: '#ebeff1', - school: '#fff3e0', - wood_a: '#d8ecd4', - wood_b: '#a8dda0', - pedestrian: '#f0f0f0', - scrub_a: '#dcecd8', - scrub_b: '#c8e4c0', - glacier: '#f8fcff', - sand: '#f5f0e0', - beach: '#fef8e0', - aerodrome: '#eaecef', - runway: '#d0d0d0', - water: '#aadaff', - zoo: '#d8e8d8', - military: '#e8e8e8', - - // Tunnels - tunnel_other_casing: '#d8d8d8', - tunnel_minor_casing: '#d8d8d8', - tunnel_link_casing: '#d8d8d8', - tunnel_major_casing: '#d8d8d8', - tunnel_highway_casing: '#d8d8d8', - tunnel_other: '#e8e8e8', - tunnel_minor: '#e8e8e8', - tunnel_link: '#f0e0a0', - tunnel_major: '#f0e0a0', - tunnel_highway: '#f0d080', - - // Pier & buildings - pier: '#e0e0e0', - buildings: '#e8e4de', - - // Roads & casings - minor_service_casing: '#e0e0e0', - minor_casing: '#e0e0e0', - link_casing: '#d8c080', - major_casing_late: '#d8c080', - highway_casing_late: '#d8a860', - other: '#f0f0f0', - minor_service: '#ffffff', - minor_a: '#ffffff', - minor_b: '#ffffff', - link: '#fbc02d', - major_casing_early: '#d8c080', - major: '#fbc02d', - highway_casing_early: '#d8a860', - highway: '#f9a825', - railway: '#a0a0a0', - boundaries: '#c0c0c0', - - // Waterway label - waterway_label: '#73b3e8', - - // Bridges - bridges_other_casing: '#d0d0d0', - bridges_minor_casing: '#d0d0d0', - bridges_link_casing: '#d8c080', - bridges_major_casing: '#d8c080', - bridges_highway_casing: '#d8a860', - bridges_other: '#f0f0f0', - bridges_minor: '#ffffff', - bridges_link: '#fbc02d', - bridges_major: '#fbc02d', - bridges_highway: '#f9a825', - - // Labels - roads_label_minor: '#5f6368', - roads_label_minor_halo: '#ffffff', - roads_label_major: '#5f6368', - roads_label_major_halo: '#ffffff', - ocean_label: '#73b3e8', - peak_label: '#5f6368', - subplace_label: '#5f6368', - subplace_label_halo: '#ffffff', - city_label: '#202124', - city_label_halo: '#ffffff', - state_label: '#9aa0a6', - state_label_halo: '#ffffff', - country_label: '#5f6368', - address_label: '#5f6368', - address_label_halo: '#ffffff', - - // POI icon colors - pois: { - blue: '#1a73e8', - green: '#34a853', - lapis: '#4285f4', - pink: '#e91e63', - red: '#ea4335', - slategray: '#5f6368', - tangerine: '#f9a825', - turquoise: '#00bcd4', - }, - - // Landcover fill colors - landcover: { - grassland: 'rgba(200, 232, 192, 1)', - barren: 'rgba(240, 235, 220, 1)', - urban_area: 'rgba(235, 235, 235, 1)', - farmland: 'rgba(216, 240, 210, 1)', - glacier: 'rgba(250, 252, 255, 1)', - scrub: 'rgba(220, 236, 216, 1)', - forest: 'rgba(180, 224, 176, 1)', - }, -} - -/** - * UI CSS custom properties - app chrome styling - * Clean Google-inspired white panels with standard gray text - */ -const cleanUI = { - // Fonts - '--font-sans': "'Inter', system-ui, -apple-system, sans-serif", - '--font-mono': "'JetBrains Mono', ui-monospace, monospace", +/** + * Clean Theme for Navi + * + * A plain, familiar, Google Maps-inspired style focused on maximum usability. + * Clean, neutral, utilitarian. White/light gray land, soft pastel green parks, + * gentle blue water, classic gray→yellow→orange road hierarchy. No strong + * personality — everything serves readability and wayfinding. + * + * The theme equivalent of a rental car: nothing exciting, nothing wrong. + */ + +// ═══════════════════════════════════════════════════════════════════════════ +// PALETTE +// ═══════════════════════════════════════════════════════════════════════════ +// +// base: #f5f5f5 ← land, app background +// surface: #ffffff ← panels, cards, modals +// surfaceAlt: #f8f9fa ← secondary panels, hover states +// border: #dadce0 ← Google's standard border gray +// text: #202124 ← primary text (Google dark) +// textSecondary: #5f6368 ← secondary text +// textMuted: #9aa0a6 ← placeholders, hints +// accent: #1a73e8 ← Google blue — links, active states +// accentHover: #1557b0 ← darker blue hover +// success: #34a853 ← Google green +// warning: #fbbc04 ← Google yellow +// danger: #ea4335 ← Google red +// water: #aadaff ← soft sky blue (Google's water) +// waterDark: #73b3e8 ← water labels +// vegetation: #c3ecb2 ← pastel green parks +// forest: #a8dda0 ← slightly deeper green +// road: #ffffff ← minor roads — white +// roadPrimary: #fbc02d ← yellow +// roadMotorway: #f9a825 ← deeper yellow-orange +// roadCasing: #e0e0e0 ← light gray casing +// building: #e8e4de ← warm light gray +// contour: #c8b8a0 ← subtle warm brown +// +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const cleanColors = { + // Background & earth + background: '#e8e8e8', + earth: '#f5f5f5', + + // Land use areas + park_a: '#d4ecd0', + park_b: '#c3ecb2', + hospital: '#fde8e8', + industrial: '#ebeff1', + school: '#fff3e0', + wood_a: '#d8ecd4', + wood_b: '#a8dda0', + pedestrian: '#f0f0f0', + scrub_a: '#dcecd8', + scrub_b: '#c8e4c0', + glacier: '#f8fcff', + sand: '#f5f0e0', + beach: '#fef8e0', + aerodrome: '#eaecef', + runway: '#d0d0d0', + water: '#aadaff', + zoo: '#d8e8d8', + military: '#e8e8e8', + + // Tunnels + tunnel_other_casing: '#d8d8d8', + tunnel_minor_casing: '#d8d8d8', + tunnel_link_casing: '#d8d8d8', + tunnel_major_casing: '#d8d8d8', + tunnel_highway_casing: '#d8d8d8', + tunnel_other: '#e8e8e8', + tunnel_minor: '#e8e8e8', + tunnel_link: '#f0e0a0', + tunnel_major: '#f0e0a0', + tunnel_highway: '#f0d080', + + // Pier & buildings + pier: '#e0e0e0', + buildings: '#e8e4de', + + // Roads & casings + minor_service_casing: '#e0e0e0', + minor_casing: '#e0e0e0', + link_casing: '#d8c080', + major_casing_late: '#d8c080', + highway_casing_late: '#d8a860', + other: '#f0f0f0', + minor_service: '#ffffff', + minor_a: '#ffffff', + minor_b: '#ffffff', + link: '#fbc02d', + major_casing_early: '#d8c080', + major: '#fbc02d', + highway_casing_early: '#d8a860', + highway: '#f9a825', + railway: '#a0a0a0', + boundaries: '#c0c0c0', + + // Waterway label + waterway_label: '#73b3e8', + + // Bridges + bridges_other_casing: '#d0d0d0', + bridges_minor_casing: '#d0d0d0', + bridges_link_casing: '#d8c080', + bridges_major_casing: '#d8c080', + bridges_highway_casing: '#d8a860', + bridges_other: '#f0f0f0', + bridges_minor: '#ffffff', + bridges_link: '#fbc02d', + bridges_major: '#fbc02d', + bridges_highway: '#f9a825', + + // Labels + roads_label_minor: '#5f6368', + roads_label_minor_halo: '#ffffff', + roads_label_major: '#5f6368', + roads_label_major_halo: '#ffffff', + ocean_label: '#73b3e8', + peak_label: '#5f6368', + subplace_label: '#5f6368', + subplace_label_halo: '#ffffff', + city_label: '#202124', + city_label_halo: '#ffffff', + state_label: '#9aa0a6', + state_label_halo: '#ffffff', + country_label: '#5f6368', + address_label: '#5f6368', + address_label_halo: '#ffffff', + + // POI icon colors + pois: { + blue: '#1a73e8', + green: '#34a853', + lapis: '#4285f4', + pink: '#e91e63', + red: '#ea4335', + slategray: '#5f6368', + tangerine: '#f9a825', + turquoise: '#00bcd4', + }, + + // Landcover fill colors + landcover: { + grassland: 'rgba(200, 232, 192, 1)', + barren: 'rgba(240, 235, 220, 1)', + urban_area: 'rgba(235, 235, 235, 1)', + farmland: 'rgba(216, 240, 210, 1)', + glacier: 'rgba(250, 252, 255, 1)', + scrub: 'rgba(220, 236, 216, 1)', + forest: 'rgba(180, 224, 176, 1)', + }, +} + +/** + * UI CSS custom properties - app chrome styling + * Clean Google-inspired white panels with standard gray text + */ +const cleanUI = { + // Fonts + '--font-sans': "'Inter', system-ui, -apple-system, sans-serif", + '--font-mono': "'JetBrains Mono', ui-monospace, monospace", '--font-heading': "'Inter', system-ui, -apple-system, sans-serif", - // Backgrounds - '--bg-base': '#f5f5f5', - '--bg-raised': '#ffffff', - '--bg-overlay': '#ffffff', - '--bg-input': '#ffffff', - '--bg-inset': '#f0f0f0', - '--bg-muted': '#f8f9fa', - // Text - '--text-primary': '#202124', - '--text-secondary': '#5f6368', - '--text-tertiary': '#9aa0a6', - '--text-inverse': '#ffffff', - // Borders - '--border': '#dadce0', - '--border-subtle': '#e8eaed', - // Accent - '--accent': '#1a73e8', - '--accent-hover': '#1557b0', - '--accent-muted': '#e8f0fe', - // Tan - '--tan': '#f9a825', - '--tan-muted': '#fef7e0', - // Pins - '--pin-origin': '#34a853', - '--pin-destination': '#ea4335', - '--pin-intermediate': '#5f6368', - '--pin-stroke': '#ffffff', - // Status - '--status-success': '#34a853', - '--status-warning': '#fbbc04', - '--status-danger': '#ea4335', - '--success': '#34a853', - '--warning': '#fbbc04', - '--warning-muted': '#fef7e0', - // Route - '--route-line': '#1a73e8', - // Shadows - '--shadow': '0 1px 3px rgba(60, 64, 67, 0.15), 0 1px 2px rgba(60, 64, 67, 0.1)', - '--shadow-lg': '0 2px 6px rgba(60, 64, 67, 0.2), 0 1px 3px rgba(60, 64, 67, 0.15)', -} - -/** - * Overlay configuration overrides - * Light shadow hillshade, warm brown contours, standard public lands - */ -const cleanOverlay = { - // Hillshade - light and natural - hillshade: { - exaggeration: 0.4, - illuminationDirection: 315, - shadowColor: '#000000', - highlightColor: '#ffffff', - }, - - // Contours - warm brown, subtle - contours: { - opacityMod: 0.9, - minorColor: '#c8b8a0', - minorOpacity: 0.35, - minorWidth: { z11: 0.5, z14: 0.8 }, - intermediateColor: '#c8b8a0', - intermediateOpacity: 0.55, - intermediateWidth: { z8: 0.7, z14: 1.0 }, - indexColor: '#a89878', - indexOpacity: 0.75, - indexWidth: { z4: 1.0, z14: 1.5 }, - labelColor: '#8a7a60', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.8, - labelSize: 10, - labelFont: ['Noto Sans Regular'], - }, - - // Contours Test - blue variant - contoursTest: { - minorColor: '#5a9ab8', - intermediateColor: '#5a9ab8', - indexColor: '#3a7a98', - labelColor: '#3a6a88', - }, - - // Contours Test 10ft - green variant - contoursTest10ft: { - minorColor: '#4a9a5f', - intermediateColor: '#4a9a5f', - indexColor: '#2a7a4a', - labelColor: '#2a5a40', - }, - - // Public Lands - standard green tints with dark labels - publicLands: { - opacityMod: 0.9, - // Fill colors per category - fillWA: '#8a7a40', - fillNPS: '#4a8030', - fillUSFS: '#6a9040', - fillBLM: '#d4b880', - fillFWS: '#5a9068', - fillSTAT: '#6aa088', - fillLOC: '#9ab8a8', - fillDefault: '#b0b0b0', - // Fill opacities - fillOpacityWA: 0.25, - fillOpacityNPS: 0.25, - fillOpacityUSFS: 0.20, - fillOpacityBLM: 0.18, - fillOpacitySTAT: 0.22, - fillOpacityLOC: 0.18, - fillOpacityDefault: 0.12, - // Outline colors - outlineWA: '#6a5a28', - outlineNPS: '#2a5018', - outlineUSFS: '#4a6828', - outlineBLM: '#9a8050', - outlineFWS: '#3a6848', - outlineSTAT: '#4a7060', - outlineLOC: '#6a8070', - outlineDefault: '#808080', - // Outline opacities - outlineOpacityNPS: 0.65, - outlineOpacityUSFS: 0.55, - outlineOpacityDefault: 0.45, - // Outline width - outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, - // Labels - dark for readability - labelColor: '#2a3a28', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: { z10: 10, z14: 13 }, - labelFont: ['Noto Sans Regular'], - }, - - // USFS Trails - standard trail colors - usfsTrails: { - roadsColor: '#c09050', - roadsOpacity: 0.85, - roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, - trailsMotorized: '#e07030', - trailsBicycle: '#d0a030', - trailsHiker: '#50b040', - trailsDefault: '#b09050', - trailsOpacity: 0.85, - trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - trailsDash: [2, 1.5], - roadsLabelColor: '#5a4a30', - roadsLabelHaloColor: '#ffffff', - roadsLabelHaloWidth: 1.5, - roadsLabelOpacity: 0.85, - roadsLabelSize: 11, - trailsLabelColor: '#4a3a28', - trailsLabelHaloColor: '#ffffff', - trailsLabelHaloWidth: 1.5, - trailsLabelOpacity: 0.85, - trailsLabelSize: 11, - labelFont: ['Noto Sans Regular'], - hitWidth: 14, - }, - - // BLM Trails - standard route colors - blmTrails: { - color4wdHigh: '#e07030', - color4wdLow: '#d0a030', - colorAtv: '#d03030', - colorMotoSingle: '#a060b0', - color2wdLow: '#e0c060', - colorNonMech: '#50b040', - colorDefault: '#b09050', - colorSnow: '#6090c0', - lineOpacity: 0.85, - lineOpacityOther: 0.80, - lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - dashImproved: [4, 2], - dashAggregate: [1, 2], - dashSnow: [4, 2, 1, 2], - dashOther: [4, 2, 1, 2, 1, 2], - labelColor: '#4a3a28', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: 11, - labelFont: ['Noto Sans Regular'], - hitWidth: 14, - }, -} - -/** - * Clean theme configuration - */ -const cleanTheme = { - id: 'clean', - name: 'Clean', - dark: false, - colors: cleanColors, - satellite: null, // No adjustments — default clear view - overlay: cleanOverlay, - ui: cleanUI, -} - -export default cleanTheme + // Backgrounds + '--bg-base': '#f5f5f5', + '--bg-raised': '#ffffff', + '--bg-overlay': '#ffffff', + '--bg-input': '#ffffff', + '--bg-inset': '#f0f0f0', + '--bg-muted': '#f8f9fa', + // Text + '--text-primary': '#202124', + '--text-secondary': '#5f6368', + '--text-tertiary': '#9aa0a6', + '--text-inverse': '#ffffff', + // Borders + '--border': '#dadce0', + '--border-subtle': '#e8eaed', + // Accent + '--accent': '#1a73e8', + '--accent-hover': '#1557b0', + '--accent-muted': '#e8f0fe', + // Tan + '--tan': '#f9a825', + '--tan-muted': '#fef7e0', + // Pins + '--pin-origin': '#34a853', + '--pin-destination': '#ea4335', + '--pin-intermediate': '#5f6368', + '--pin-stroke': '#ffffff', + // Status + '--status-success': '#34a853', + '--status-warning': '#fbbc04', + '--status-danger': '#ea4335', + '--success': '#34a853', + '--warning': '#fbbc04', + '--warning-muted': '#fef7e0', + // Route + '--route-line': '#1a73e8', + // Shadows + '--shadow': '0 1px 3px rgba(60, 64, 67, 0.15), 0 1px 2px rgba(60, 64, 67, 0.1)', + '--shadow-lg': '0 2px 6px rgba(60, 64, 67, 0.2), 0 1px 3px rgba(60, 64, 67, 0.15)', +} + +/** + * Overlay configuration overrides + * Light shadow hillshade, warm brown contours, standard public lands + */ +const cleanOverlay = { + // Hillshade - light and natural + hillshade: { + exaggeration: 0.4, + illuminationDirection: 315, + shadowColor: '#000000', + highlightColor: '#ffffff', + }, + + // Contours - warm brown, subtle + contours: { + opacityMod: 0.9, + minorColor: '#c8b8a0', + minorOpacity: 0.35, + minorWidth: { z11: 0.5, z14: 0.8 }, + intermediateColor: '#c8b8a0', + intermediateOpacity: 0.55, + intermediateWidth: { z8: 0.7, z14: 1.0 }, + indexColor: '#a89878', + indexOpacity: 0.75, + indexWidth: { z4: 1.0, z14: 1.5 }, + labelColor: '#8a7a60', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 10, + labelFont: ['Noto Sans Regular'], + }, + + // Contours Test - blue variant + contoursTest: { + minorColor: '#5a9ab8', + intermediateColor: '#5a9ab8', + indexColor: '#3a7a98', + labelColor: '#3a6a88', + }, + + // Contours Test 10ft - green variant + contoursTest10ft: { + minorColor: '#4a9a5f', + intermediateColor: '#4a9a5f', + indexColor: '#2a7a4a', + labelColor: '#2a5a40', + }, + + // Public Lands - standard green tints with dark labels + publicLands: { + opacityMod: 0.9, + // Fill colors per category + fillWA: '#8a7a40', + fillNPS: '#4a8030', + fillUSFS: '#6a9040', + fillBLM: '#d4b880', + fillFWS: '#5a9068', + fillSTAT: '#6aa088', + fillLOC: '#9ab8a8', + fillDefault: '#b0b0b0', + // Fill opacities + fillOpacityWA: 0.25, + fillOpacityNPS: 0.25, + fillOpacityUSFS: 0.20, + fillOpacityBLM: 0.18, + fillOpacitySTAT: 0.22, + fillOpacityLOC: 0.18, + fillOpacityDefault: 0.12, + // Outline colors + outlineWA: '#6a5a28', + outlineNPS: '#2a5018', + outlineUSFS: '#4a6828', + outlineBLM: '#9a8050', + outlineFWS: '#3a6848', + outlineSTAT: '#4a7060', + outlineLOC: '#6a8070', + outlineDefault: '#808080', + // Outline opacities + outlineOpacityNPS: 0.65, + outlineOpacityUSFS: 0.55, + outlineOpacityDefault: 0.45, + // Outline width + outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, + // Labels - dark for readability + labelColor: '#2a3a28', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: { z10: 10, z14: 13 }, + labelFont: ['Noto Sans Regular'], + }, + + // USFS Trails - standard trail colors + usfsTrails: { + roadsColor: '#c09050', + roadsOpacity: 0.85, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + trailsMotorized: '#e07030', + trailsBicycle: '#d0a030', + trailsHiker: '#50b040', + trailsDefault: '#b09050', + trailsOpacity: 0.85, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + roadsLabelColor: '#5a4a30', + roadsLabelHaloColor: '#ffffff', + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.85, + roadsLabelSize: 11, + trailsLabelColor: '#4a3a28', + trailsLabelHaloColor: '#ffffff', + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.85, + trailsLabelSize: 11, + labelFont: ['Noto Sans Regular'], + hitWidth: 14, + }, + + // BLM Trails - standard route colors + blmTrails: { + color4wdHigh: '#e07030', + color4wdLow: '#d0a030', + colorAtv: '#d03030', + colorMotoSingle: '#a060b0', + color2wdLow: '#e0c060', + colorNonMech: '#50b040', + colorDefault: '#b09050', + colorSnow: '#6090c0', + lineOpacity: 0.85, + lineOpacityOther: 0.80, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + labelColor: '#4a3a28', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: 11, + labelFont: ['Noto Sans Regular'], + hitWidth: 14, + }, + + // ── Highlight (boundary/selection) ──────────────────────────────────────── + highlight: { + lineColor: "#1a73e8", // Google blue for selection + lineWidth: 2, + lineDash: [4, 4], + lineOpacity: 0.7, + fillColor: "#1a73e8", + fillOpacity: 0.06, + }, +} + +/** + * Clean theme configuration + */ +const cleanTheme = { + id: 'clean', + name: 'Clean', + dark: false, + colors: cleanColors, + satellite: null, // No adjustments — default clear view + overlay: cleanOverlay, + ui: cleanUI, +} + +export default cleanTheme diff --git a/src/themes/cyberpunk.js b/src/themes/cyberpunk.js index 86a8e92..d5e09c4 100644 --- a/src/themes/cyberpunk.js +++ b/src/themes/cyberpunk.js @@ -1,403 +1,413 @@ -/** - * 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 +/** + * 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, + }, + + // ── Highlight (boundary/selection) ──────────────────────────────────────── + highlight: { + lineColor: "#00f0ff", // Electric cyan for selection + lineWidth: 2, + lineDash: [4, 4], + lineOpacity: 0.9, + fillColor: "#00f0ff", + fillOpacity: 0.1, + }, +} + +/** + * 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/registry.js b/src/themes/registry.js index cc48c8f..7f6b74c 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -1,652 +1,672 @@ -/** - * 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' +/** + * 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' - -// ═══════════════════════════════════════════════════════════════════════════ -// 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, + }, + + // ── Highlight (boundary/selection) ──────────────────────────────────────── + highlight: { + lineColor: "#7a9a6b", // Muted olive-green for dark backgrounds + lineWidth: 2, + lineDash: [4, 4], + lineOpacity: 0.8, + fillColor: "#7a9a6b", + fillOpacity: 0.08, + }, +} + +/** + * 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, + }, + + // ── Highlight (boundary/selection) ──────────────────────────────────────── + highlight: { + lineColor: "#4a7040", // Forest green for light backgrounds + lineWidth: 2, + lineDash: [4, 4], + lineOpacity: 0.7, + fillColor: "#4a7040", + fillOpacity: 0.06, + }, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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, - // 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 + // 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 From 40855e126174b62e9ec7362f2b9960b7efb31b59 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 18:34:36 +0000 Subject: [PATCH 07/56] Revert "feat(themes): add theme-aware boundary highlight config" This reverts commit fef10664c8230183eb7789e171637e9b54aba269. --- src/components/MapView.jsx | 38 +- src/themes/clean.js | 746 ++++++++++---------- src/themes/cyberpunk.js | 816 +++++++++++----------- src/themes/registry.js | 1316 ++++++++++++++++++------------------ 4 files changed, 1431 insertions(+), 1485 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 5b8eb3c..a49143e 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1247,10 +1247,10 @@ function removeBlmTrails(map) { } -/** Add boundary polygon layers using theme-aware highlight config */ +/** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */ const BOUNDARY_FILL_LAYER = 'boundary-fill-layer' -function addBoundaryLayer(map, themeId) { +function addBoundaryLayer(map) { if (!map || map.getLayer(BOUNDARY_LAYER)) return if (!map.getSource(BOUNDARY_SOURCE)) { map.addSource(BOUNDARY_SOURCE, { @@ -1258,14 +1258,7 @@ function addBoundaryLayer(map, themeId) { data: { type: "FeatureCollection", features: [] }, }) } - // Get highlight config from theme overlay - const highlight = getOverlayConfig(themeId, "highlight") || {} - const lineColor = highlight.lineColor || "#7a9a6b" - const lineWidth = highlight.lineWidth || 2 - const lineDash = highlight.lineDash || [4, 4] - const lineOpacity = highlight.lineOpacity || 0.8 - const fillColor = highlight.fillColor || lineColor - const fillOpacity = highlight.fillOpacity || 0.08 + const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b" // Find first symbol layer to insert boundary layers below labels const layers = map.getStyle().layers @@ -1283,8 +1276,8 @@ function addBoundaryLayer(map, themeId) { type: "fill", source: BOUNDARY_SOURCE, paint: { - "fill-color": fillColor, - "fill-opacity": fillOpacity, + "fill-color": accentColor, + "fill-opacity": 0.05, }, }, firstSymbolId) @@ -1294,10 +1287,10 @@ function addBoundaryLayer(map, themeId) { type: "line", source: BOUNDARY_SOURCE, paint: { - "line-color": lineColor, - "line-width": lineWidth, - "line-opacity": lineOpacity, - "line-dasharray": lineDash, + "line-color": accentColor, + "line-width": 2, + "line-opacity": 0.7, + "line-dasharray": [3, 2], }, }, firstSymbolId) } @@ -1505,14 +1498,7 @@ const MapView = forwardRef(function MapView(_, ref) { type: "geojson", data: { type: "FeatureCollection", features: [] }, }) - // Get highlight config from theme overlay - const highlight = getOverlayConfig(themeId, "highlight") || {} - const lineColor = highlight.lineColor || "#7a9a6b" - const lineWidth = highlight.lineWidth || 2 - const lineDash = highlight.lineDash || [4, 4] - const lineOpacity = highlight.lineOpacity || 0.8 - const fillColor = highlight.fillColor || lineColor - const fillOpacity = highlight.fillOpacity || 0.08 + const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b" map.addLayer({ id: MEASURE_LINE_LAYER, type: "line", @@ -2138,7 +2124,7 @@ const MapView = forwardRef(function MapView(_, ref) { // Boundary polygon layer for selected places if (!map.getLayer(BOUNDARY_LAYER)) { - addBoundaryLayer(map, currentThemeRef.current) + addBoundaryLayer(map) } // Apply improved base label styling for readability @@ -2347,7 +2333,7 @@ const MapView = forwardRef(function MapView(_, ref) { // Boundary polygon layer if (!map.getLayer(BOUNDARY_LAYER)) { - addBoundaryLayer(map, currentThemeRef.current) + addBoundaryLayer(map) } // Apply improved base label styling for readability diff --git a/src/themes/clean.js b/src/themes/clean.js index 2ed3b87..b13e52d 100644 --- a/src/themes/clean.js +++ b/src/themes/clean.js @@ -1,379 +1,369 @@ -/** - * Clean Theme for Navi - * - * A plain, familiar, Google Maps-inspired style focused on maximum usability. - * Clean, neutral, utilitarian. White/light gray land, soft pastel green parks, - * gentle blue water, classic gray→yellow→orange road hierarchy. No strong - * personality — everything serves readability and wayfinding. - * - * The theme equivalent of a rental car: nothing exciting, nothing wrong. - */ - -// ═══════════════════════════════════════════════════════════════════════════ -// PALETTE -// ═══════════════════════════════════════════════════════════════════════════ -// -// base: #f5f5f5 ← land, app background -// surface: #ffffff ← panels, cards, modals -// surfaceAlt: #f8f9fa ← secondary panels, hover states -// border: #dadce0 ← Google's standard border gray -// text: #202124 ← primary text (Google dark) -// textSecondary: #5f6368 ← secondary text -// textMuted: #9aa0a6 ← placeholders, hints -// accent: #1a73e8 ← Google blue — links, active states -// accentHover: #1557b0 ← darker blue hover -// success: #34a853 ← Google green -// warning: #fbbc04 ← Google yellow -// danger: #ea4335 ← Google red -// water: #aadaff ← soft sky blue (Google's water) -// waterDark: #73b3e8 ← water labels -// vegetation: #c3ecb2 ← pastel green parks -// forest: #a8dda0 ← slightly deeper green -// road: #ffffff ← minor roads — white -// roadPrimary: #fbc02d ← yellow -// roadMotorway: #f9a825 ← deeper yellow-orange -// roadCasing: #e0e0e0 ← light gray casing -// building: #e8e4de ← warm light gray -// contour: #c8b8a0 ← subtle warm brown -// -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Map flavor colors - protomaps-themes-base schema - * All 73 flat keys + pois + landcover nested objects - */ -const cleanColors = { - // Background & earth - background: '#e8e8e8', - earth: '#f5f5f5', - - // Land use areas - park_a: '#d4ecd0', - park_b: '#c3ecb2', - hospital: '#fde8e8', - industrial: '#ebeff1', - school: '#fff3e0', - wood_a: '#d8ecd4', - wood_b: '#a8dda0', - pedestrian: '#f0f0f0', - scrub_a: '#dcecd8', - scrub_b: '#c8e4c0', - glacier: '#f8fcff', - sand: '#f5f0e0', - beach: '#fef8e0', - aerodrome: '#eaecef', - runway: '#d0d0d0', - water: '#aadaff', - zoo: '#d8e8d8', - military: '#e8e8e8', - - // Tunnels - tunnel_other_casing: '#d8d8d8', - tunnel_minor_casing: '#d8d8d8', - tunnel_link_casing: '#d8d8d8', - tunnel_major_casing: '#d8d8d8', - tunnel_highway_casing: '#d8d8d8', - tunnel_other: '#e8e8e8', - tunnel_minor: '#e8e8e8', - tunnel_link: '#f0e0a0', - tunnel_major: '#f0e0a0', - tunnel_highway: '#f0d080', - - // Pier & buildings - pier: '#e0e0e0', - buildings: '#e8e4de', - - // Roads & casings - minor_service_casing: '#e0e0e0', - minor_casing: '#e0e0e0', - link_casing: '#d8c080', - major_casing_late: '#d8c080', - highway_casing_late: '#d8a860', - other: '#f0f0f0', - minor_service: '#ffffff', - minor_a: '#ffffff', - minor_b: '#ffffff', - link: '#fbc02d', - major_casing_early: '#d8c080', - major: '#fbc02d', - highway_casing_early: '#d8a860', - highway: '#f9a825', - railway: '#a0a0a0', - boundaries: '#c0c0c0', - - // Waterway label - waterway_label: '#73b3e8', - - // Bridges - bridges_other_casing: '#d0d0d0', - bridges_minor_casing: '#d0d0d0', - bridges_link_casing: '#d8c080', - bridges_major_casing: '#d8c080', - bridges_highway_casing: '#d8a860', - bridges_other: '#f0f0f0', - bridges_minor: '#ffffff', - bridges_link: '#fbc02d', - bridges_major: '#fbc02d', - bridges_highway: '#f9a825', - - // Labels - roads_label_minor: '#5f6368', - roads_label_minor_halo: '#ffffff', - roads_label_major: '#5f6368', - roads_label_major_halo: '#ffffff', - ocean_label: '#73b3e8', - peak_label: '#5f6368', - subplace_label: '#5f6368', - subplace_label_halo: '#ffffff', - city_label: '#202124', - city_label_halo: '#ffffff', - state_label: '#9aa0a6', - state_label_halo: '#ffffff', - country_label: '#5f6368', - address_label: '#5f6368', - address_label_halo: '#ffffff', - - // POI icon colors - pois: { - blue: '#1a73e8', - green: '#34a853', - lapis: '#4285f4', - pink: '#e91e63', - red: '#ea4335', - slategray: '#5f6368', - tangerine: '#f9a825', - turquoise: '#00bcd4', - }, - - // Landcover fill colors - landcover: { - grassland: 'rgba(200, 232, 192, 1)', - barren: 'rgba(240, 235, 220, 1)', - urban_area: 'rgba(235, 235, 235, 1)', - farmland: 'rgba(216, 240, 210, 1)', - glacier: 'rgba(250, 252, 255, 1)', - scrub: 'rgba(220, 236, 216, 1)', - forest: 'rgba(180, 224, 176, 1)', - }, -} - -/** - * UI CSS custom properties - app chrome styling - * Clean Google-inspired white panels with standard gray text - */ -const cleanUI = { - // Fonts - '--font-sans': "'Inter', system-ui, -apple-system, sans-serif", - '--font-mono': "'JetBrains Mono', ui-monospace, monospace", +/** + * Clean Theme for Navi + * + * A plain, familiar, Google Maps-inspired style focused on maximum usability. + * Clean, neutral, utilitarian. White/light gray land, soft pastel green parks, + * gentle blue water, classic gray→yellow→orange road hierarchy. No strong + * personality — everything serves readability and wayfinding. + * + * The theme equivalent of a rental car: nothing exciting, nothing wrong. + */ + +// ═══════════════════════════════════════════════════════════════════════════ +// PALETTE +// ═══════════════════════════════════════════════════════════════════════════ +// +// base: #f5f5f5 ← land, app background +// surface: #ffffff ← panels, cards, modals +// surfaceAlt: #f8f9fa ← secondary panels, hover states +// border: #dadce0 ← Google's standard border gray +// text: #202124 ← primary text (Google dark) +// textSecondary: #5f6368 ← secondary text +// textMuted: #9aa0a6 ← placeholders, hints +// accent: #1a73e8 ← Google blue — links, active states +// accentHover: #1557b0 ← darker blue hover +// success: #34a853 ← Google green +// warning: #fbbc04 ← Google yellow +// danger: #ea4335 ← Google red +// water: #aadaff ← soft sky blue (Google's water) +// waterDark: #73b3e8 ← water labels +// vegetation: #c3ecb2 ← pastel green parks +// forest: #a8dda0 ← slightly deeper green +// road: #ffffff ← minor roads — white +// roadPrimary: #fbc02d ← yellow +// roadMotorway: #f9a825 ← deeper yellow-orange +// roadCasing: #e0e0e0 ← light gray casing +// building: #e8e4de ← warm light gray +// contour: #c8b8a0 ← subtle warm brown +// +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const cleanColors = { + // Background & earth + background: '#e8e8e8', + earth: '#f5f5f5', + + // Land use areas + park_a: '#d4ecd0', + park_b: '#c3ecb2', + hospital: '#fde8e8', + industrial: '#ebeff1', + school: '#fff3e0', + wood_a: '#d8ecd4', + wood_b: '#a8dda0', + pedestrian: '#f0f0f0', + scrub_a: '#dcecd8', + scrub_b: '#c8e4c0', + glacier: '#f8fcff', + sand: '#f5f0e0', + beach: '#fef8e0', + aerodrome: '#eaecef', + runway: '#d0d0d0', + water: '#aadaff', + zoo: '#d8e8d8', + military: '#e8e8e8', + + // Tunnels + tunnel_other_casing: '#d8d8d8', + tunnel_minor_casing: '#d8d8d8', + tunnel_link_casing: '#d8d8d8', + tunnel_major_casing: '#d8d8d8', + tunnel_highway_casing: '#d8d8d8', + tunnel_other: '#e8e8e8', + tunnel_minor: '#e8e8e8', + tunnel_link: '#f0e0a0', + tunnel_major: '#f0e0a0', + tunnel_highway: '#f0d080', + + // Pier & buildings + pier: '#e0e0e0', + buildings: '#e8e4de', + + // Roads & casings + minor_service_casing: '#e0e0e0', + minor_casing: '#e0e0e0', + link_casing: '#d8c080', + major_casing_late: '#d8c080', + highway_casing_late: '#d8a860', + other: '#f0f0f0', + minor_service: '#ffffff', + minor_a: '#ffffff', + minor_b: '#ffffff', + link: '#fbc02d', + major_casing_early: '#d8c080', + major: '#fbc02d', + highway_casing_early: '#d8a860', + highway: '#f9a825', + railway: '#a0a0a0', + boundaries: '#c0c0c0', + + // Waterway label + waterway_label: '#73b3e8', + + // Bridges + bridges_other_casing: '#d0d0d0', + bridges_minor_casing: '#d0d0d0', + bridges_link_casing: '#d8c080', + bridges_major_casing: '#d8c080', + bridges_highway_casing: '#d8a860', + bridges_other: '#f0f0f0', + bridges_minor: '#ffffff', + bridges_link: '#fbc02d', + bridges_major: '#fbc02d', + bridges_highway: '#f9a825', + + // Labels + roads_label_minor: '#5f6368', + roads_label_minor_halo: '#ffffff', + roads_label_major: '#5f6368', + roads_label_major_halo: '#ffffff', + ocean_label: '#73b3e8', + peak_label: '#5f6368', + subplace_label: '#5f6368', + subplace_label_halo: '#ffffff', + city_label: '#202124', + city_label_halo: '#ffffff', + state_label: '#9aa0a6', + state_label_halo: '#ffffff', + country_label: '#5f6368', + address_label: '#5f6368', + address_label_halo: '#ffffff', + + // POI icon colors + pois: { + blue: '#1a73e8', + green: '#34a853', + lapis: '#4285f4', + pink: '#e91e63', + red: '#ea4335', + slategray: '#5f6368', + tangerine: '#f9a825', + turquoise: '#00bcd4', + }, + + // Landcover fill colors + landcover: { + grassland: 'rgba(200, 232, 192, 1)', + barren: 'rgba(240, 235, 220, 1)', + urban_area: 'rgba(235, 235, 235, 1)', + farmland: 'rgba(216, 240, 210, 1)', + glacier: 'rgba(250, 252, 255, 1)', + scrub: 'rgba(220, 236, 216, 1)', + forest: 'rgba(180, 224, 176, 1)', + }, +} + +/** + * UI CSS custom properties - app chrome styling + * Clean Google-inspired white panels with standard gray text + */ +const cleanUI = { + // Fonts + '--font-sans': "'Inter', system-ui, -apple-system, sans-serif", + '--font-mono': "'JetBrains Mono', ui-monospace, monospace", '--font-heading': "'Inter', system-ui, -apple-system, sans-serif", - // Backgrounds - '--bg-base': '#f5f5f5', - '--bg-raised': '#ffffff', - '--bg-overlay': '#ffffff', - '--bg-input': '#ffffff', - '--bg-inset': '#f0f0f0', - '--bg-muted': '#f8f9fa', - // Text - '--text-primary': '#202124', - '--text-secondary': '#5f6368', - '--text-tertiary': '#9aa0a6', - '--text-inverse': '#ffffff', - // Borders - '--border': '#dadce0', - '--border-subtle': '#e8eaed', - // Accent - '--accent': '#1a73e8', - '--accent-hover': '#1557b0', - '--accent-muted': '#e8f0fe', - // Tan - '--tan': '#f9a825', - '--tan-muted': '#fef7e0', - // Pins - '--pin-origin': '#34a853', - '--pin-destination': '#ea4335', - '--pin-intermediate': '#5f6368', - '--pin-stroke': '#ffffff', - // Status - '--status-success': '#34a853', - '--status-warning': '#fbbc04', - '--status-danger': '#ea4335', - '--success': '#34a853', - '--warning': '#fbbc04', - '--warning-muted': '#fef7e0', - // Route - '--route-line': '#1a73e8', - // Shadows - '--shadow': '0 1px 3px rgba(60, 64, 67, 0.15), 0 1px 2px rgba(60, 64, 67, 0.1)', - '--shadow-lg': '0 2px 6px rgba(60, 64, 67, 0.2), 0 1px 3px rgba(60, 64, 67, 0.15)', -} - -/** - * Overlay configuration overrides - * Light shadow hillshade, warm brown contours, standard public lands - */ -const cleanOverlay = { - // Hillshade - light and natural - hillshade: { - exaggeration: 0.4, - illuminationDirection: 315, - shadowColor: '#000000', - highlightColor: '#ffffff', - }, - - // Contours - warm brown, subtle - contours: { - opacityMod: 0.9, - minorColor: '#c8b8a0', - minorOpacity: 0.35, - minorWidth: { z11: 0.5, z14: 0.8 }, - intermediateColor: '#c8b8a0', - intermediateOpacity: 0.55, - intermediateWidth: { z8: 0.7, z14: 1.0 }, - indexColor: '#a89878', - indexOpacity: 0.75, - indexWidth: { z4: 1.0, z14: 1.5 }, - labelColor: '#8a7a60', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.8, - labelSize: 10, - labelFont: ['Noto Sans Regular'], - }, - - // Contours Test - blue variant - contoursTest: { - minorColor: '#5a9ab8', - intermediateColor: '#5a9ab8', - indexColor: '#3a7a98', - labelColor: '#3a6a88', - }, - - // Contours Test 10ft - green variant - contoursTest10ft: { - minorColor: '#4a9a5f', - intermediateColor: '#4a9a5f', - indexColor: '#2a7a4a', - labelColor: '#2a5a40', - }, - - // Public Lands - standard green tints with dark labels - publicLands: { - opacityMod: 0.9, - // Fill colors per category - fillWA: '#8a7a40', - fillNPS: '#4a8030', - fillUSFS: '#6a9040', - fillBLM: '#d4b880', - fillFWS: '#5a9068', - fillSTAT: '#6aa088', - fillLOC: '#9ab8a8', - fillDefault: '#b0b0b0', - // Fill opacities - fillOpacityWA: 0.25, - fillOpacityNPS: 0.25, - fillOpacityUSFS: 0.20, - fillOpacityBLM: 0.18, - fillOpacitySTAT: 0.22, - fillOpacityLOC: 0.18, - fillOpacityDefault: 0.12, - // Outline colors - outlineWA: '#6a5a28', - outlineNPS: '#2a5018', - outlineUSFS: '#4a6828', - outlineBLM: '#9a8050', - outlineFWS: '#3a6848', - outlineSTAT: '#4a7060', - outlineLOC: '#6a8070', - outlineDefault: '#808080', - // Outline opacities - outlineOpacityNPS: 0.65, - outlineOpacityUSFS: 0.55, - outlineOpacityDefault: 0.45, - // Outline width - outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, - // Labels - dark for readability - labelColor: '#2a3a28', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: { z10: 10, z14: 13 }, - labelFont: ['Noto Sans Regular'], - }, - - // USFS Trails - standard trail colors - usfsTrails: { - roadsColor: '#c09050', - roadsOpacity: 0.85, - roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, - trailsMotorized: '#e07030', - trailsBicycle: '#d0a030', - trailsHiker: '#50b040', - trailsDefault: '#b09050', - trailsOpacity: 0.85, - trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - trailsDash: [2, 1.5], - roadsLabelColor: '#5a4a30', - roadsLabelHaloColor: '#ffffff', - roadsLabelHaloWidth: 1.5, - roadsLabelOpacity: 0.85, - roadsLabelSize: 11, - trailsLabelColor: '#4a3a28', - trailsLabelHaloColor: '#ffffff', - trailsLabelHaloWidth: 1.5, - trailsLabelOpacity: 0.85, - trailsLabelSize: 11, - labelFont: ['Noto Sans Regular'], - hitWidth: 14, - }, - - // BLM Trails - standard route colors - blmTrails: { - color4wdHigh: '#e07030', - color4wdLow: '#d0a030', - colorAtv: '#d03030', - colorMotoSingle: '#a060b0', - color2wdLow: '#e0c060', - colorNonMech: '#50b040', - colorDefault: '#b09050', - colorSnow: '#6090c0', - lineOpacity: 0.85, - lineOpacityOther: 0.80, - lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, - dashImproved: [4, 2], - dashAggregate: [1, 2], - dashSnow: [4, 2, 1, 2], - dashOther: [4, 2, 1, 2, 1, 2], - labelColor: '#4a3a28', - labelHaloColor: '#ffffff', - labelHaloWidth: 1.5, - labelOpacity: 0.85, - labelSize: 11, - labelFont: ['Noto Sans Regular'], - hitWidth: 14, - }, - - // ── Highlight (boundary/selection) ──────────────────────────────────────── - highlight: { - lineColor: "#1a73e8", // Google blue for selection - lineWidth: 2, - lineDash: [4, 4], - lineOpacity: 0.7, - fillColor: "#1a73e8", - fillOpacity: 0.06, - }, -} - -/** - * Clean theme configuration - */ -const cleanTheme = { - id: 'clean', - name: 'Clean', - dark: false, - colors: cleanColors, - satellite: null, // No adjustments — default clear view - overlay: cleanOverlay, - ui: cleanUI, -} - -export default cleanTheme + // Backgrounds + '--bg-base': '#f5f5f5', + '--bg-raised': '#ffffff', + '--bg-overlay': '#ffffff', + '--bg-input': '#ffffff', + '--bg-inset': '#f0f0f0', + '--bg-muted': '#f8f9fa', + // Text + '--text-primary': '#202124', + '--text-secondary': '#5f6368', + '--text-tertiary': '#9aa0a6', + '--text-inverse': '#ffffff', + // Borders + '--border': '#dadce0', + '--border-subtle': '#e8eaed', + // Accent + '--accent': '#1a73e8', + '--accent-hover': '#1557b0', + '--accent-muted': '#e8f0fe', + // Tan + '--tan': '#f9a825', + '--tan-muted': '#fef7e0', + // Pins + '--pin-origin': '#34a853', + '--pin-destination': '#ea4335', + '--pin-intermediate': '#5f6368', + '--pin-stroke': '#ffffff', + // Status + '--status-success': '#34a853', + '--status-warning': '#fbbc04', + '--status-danger': '#ea4335', + '--success': '#34a853', + '--warning': '#fbbc04', + '--warning-muted': '#fef7e0', + // Route + '--route-line': '#1a73e8', + // Shadows + '--shadow': '0 1px 3px rgba(60, 64, 67, 0.15), 0 1px 2px rgba(60, 64, 67, 0.1)', + '--shadow-lg': '0 2px 6px rgba(60, 64, 67, 0.2), 0 1px 3px rgba(60, 64, 67, 0.15)', +} + +/** + * Overlay configuration overrides + * Light shadow hillshade, warm brown contours, standard public lands + */ +const cleanOverlay = { + // Hillshade - light and natural + hillshade: { + exaggeration: 0.4, + illuminationDirection: 315, + shadowColor: '#000000', + highlightColor: '#ffffff', + }, + + // Contours - warm brown, subtle + contours: { + opacityMod: 0.9, + minorColor: '#c8b8a0', + minorOpacity: 0.35, + minorWidth: { z11: 0.5, z14: 0.8 }, + intermediateColor: '#c8b8a0', + intermediateOpacity: 0.55, + intermediateWidth: { z8: 0.7, z14: 1.0 }, + indexColor: '#a89878', + indexOpacity: 0.75, + indexWidth: { z4: 1.0, z14: 1.5 }, + labelColor: '#8a7a60', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 10, + labelFont: ['Noto Sans Regular'], + }, + + // Contours Test - blue variant + contoursTest: { + minorColor: '#5a9ab8', + intermediateColor: '#5a9ab8', + indexColor: '#3a7a98', + labelColor: '#3a6a88', + }, + + // Contours Test 10ft - green variant + contoursTest10ft: { + minorColor: '#4a9a5f', + intermediateColor: '#4a9a5f', + indexColor: '#2a7a4a', + labelColor: '#2a5a40', + }, + + // Public Lands - standard green tints with dark labels + publicLands: { + opacityMod: 0.9, + // Fill colors per category + fillWA: '#8a7a40', + fillNPS: '#4a8030', + fillUSFS: '#6a9040', + fillBLM: '#d4b880', + fillFWS: '#5a9068', + fillSTAT: '#6aa088', + fillLOC: '#9ab8a8', + fillDefault: '#b0b0b0', + // Fill opacities + fillOpacityWA: 0.25, + fillOpacityNPS: 0.25, + fillOpacityUSFS: 0.20, + fillOpacityBLM: 0.18, + fillOpacitySTAT: 0.22, + fillOpacityLOC: 0.18, + fillOpacityDefault: 0.12, + // Outline colors + outlineWA: '#6a5a28', + outlineNPS: '#2a5018', + outlineUSFS: '#4a6828', + outlineBLM: '#9a8050', + outlineFWS: '#3a6848', + outlineSTAT: '#4a7060', + outlineLOC: '#6a8070', + outlineDefault: '#808080', + // Outline opacities + outlineOpacityNPS: 0.65, + outlineOpacityUSFS: 0.55, + outlineOpacityDefault: 0.45, + // Outline width + outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, + // Labels - dark for readability + labelColor: '#2a3a28', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: { z10: 10, z14: 13 }, + labelFont: ['Noto Sans Regular'], + }, + + // USFS Trails - standard trail colors + usfsTrails: { + roadsColor: '#c09050', + roadsOpacity: 0.85, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + trailsMotorized: '#e07030', + trailsBicycle: '#d0a030', + trailsHiker: '#50b040', + trailsDefault: '#b09050', + trailsOpacity: 0.85, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + roadsLabelColor: '#5a4a30', + roadsLabelHaloColor: '#ffffff', + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.85, + roadsLabelSize: 11, + trailsLabelColor: '#4a3a28', + trailsLabelHaloColor: '#ffffff', + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.85, + trailsLabelSize: 11, + labelFont: ['Noto Sans Regular'], + hitWidth: 14, + }, + + // BLM Trails - standard route colors + blmTrails: { + color4wdHigh: '#e07030', + color4wdLow: '#d0a030', + colorAtv: '#d03030', + colorMotoSingle: '#a060b0', + color2wdLow: '#e0c060', + colorNonMech: '#50b040', + colorDefault: '#b09050', + colorSnow: '#6090c0', + lineOpacity: 0.85, + lineOpacityOther: 0.80, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + labelColor: '#4a3a28', + labelHaloColor: '#ffffff', + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: 11, + labelFont: ['Noto Sans Regular'], + hitWidth: 14, + }, +} + +/** + * Clean theme configuration + */ +const cleanTheme = { + id: 'clean', + name: 'Clean', + dark: false, + colors: cleanColors, + satellite: null, // No adjustments — default clear view + overlay: cleanOverlay, + ui: cleanUI, +} + +export default cleanTheme diff --git a/src/themes/cyberpunk.js b/src/themes/cyberpunk.js index d5e09c4..86a8e92 100644 --- a/src/themes/cyberpunk.js +++ b/src/themes/cyberpunk.js @@ -1,413 +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: 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, - }, - - // ── Highlight (boundary/selection) ──────────────────────────────────────── - highlight: { - lineColor: "#00f0ff", // Electric cyan for selection - lineWidth: 2, - lineDash: [4, 4], - lineOpacity: 0.9, - fillColor: "#00f0ff", - fillOpacity: 0.1, - }, -} - -/** - * 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/registry.js b/src/themes/registry.js index 7f6b74c..cc48c8f 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -1,672 +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 { namedTheme } from 'protomaps-themes-base' -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' - -// ═══════════════════════════════════════════════════════════════════════════ -// 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, - }, - - // ── Highlight (boundary/selection) ──────────────────────────────────────── - highlight: { - lineColor: "#7a9a6b", // Muted olive-green for dark backgrounds - lineWidth: 2, - lineDash: [4, 4], - lineOpacity: 0.8, - fillColor: "#7a9a6b", - fillOpacity: 0.08, - }, -} - -/** - * 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, - }, - - // ── Highlight (boundary/selection) ──────────────────────────────────────── - highlight: { - lineColor: "#4a7040", // Forest green for light backgrounds - lineWidth: 2, - lineDash: [4, 4], - lineOpacity: 0.7, - fillColor: "#4a7040", - fillOpacity: 0.06, - }, -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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, - // 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 + // 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 From cf55055b622f5d5c8a169d6445eeb191af58cbcd Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 18:34:36 +0000 Subject: [PATCH 08/56] Revert "fix(PlaceCard): prioritize wikidata path for boundary fetch" This reverts commit 2e0a8854769f758108768493ad539222c413d1fd. --- src/components/PlaceCard.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 8443e1f..40c4660 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -348,9 +348,8 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl // Reverse geocode to get OSM type/id if not present (e.g., basemap label clicks) useEffect(() => { if (!hasFeature('has_nominatim_details')) return - if (wikidataId) return // Prefer wikidata path for basemap features with wikidata - if (placeLat == null || placeLon == null) return if (osmType && osmId) return + if (placeLat == null || placeLon == null) return // Skip for dropped pins - they get reverse geocoded by MapView if (place?.source === 'map_click') return @@ -369,7 +368,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl } }) return () => controller.abort() - }, [wikidataId, placeLat, placeLon, osmType, osmId, place?.source]) + }, [placeLat, placeLon, osmType, osmId, place?.source]) useEffect(() => { @@ -394,6 +393,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl }, [osmType, osmId, placeLat, placeLon]) useEffect(() => { + if (osmType && osmId) return if (!wikidataId) return const controller = new AbortController() fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => { From 09369011eeebb748485b895de8deb9759de02a49 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 21:59:04 +0000 Subject: [PATCH 09/56] fix: direct namedTheme import fixes GeoJSON rendering, guard optional layer queries Root cause: Vite's bundling of namedTheme through the registry re-export broke MapLibre's Web Worker, silently preventing all GeoJSON rendering (routes, boundaries, measure tool). Fixed by importing namedTheme directly from protomaps-themes-base in MapView.jsx. Also guards queryRenderedFeatures calls for optional overlay layers (USFS trails, BLM roads) that may not exist in the current style. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 22 +- src/themes/registry.js | 1280 ++++++++++++++++++------------------ 2 files changed, 647 insertions(+), 655 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index a49143e..37ec829 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2,8 +2,8 @@ import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from 're import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' -import { layers } from 'protomaps-themes-base' -import { getTheme, getThemeColors, getThemeSprite, getOverlayConfig } from '../themes/registry' +import { layers, namedTheme } from 'protomaps-themes-base' +import { getTheme, getThemeSprite, getOverlayConfig } from '../themes/registry' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' import { fetchReverse } from '../api' @@ -270,6 +270,10 @@ function buildStyle(themeName) { 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', @@ -281,7 +285,7 @@ function buildStyle(themeName) { attribution, }, }, - layers: layers('protomaps', getThemeColors(themeName), { lang: 'en' }), + layers: layers('protomaps', colors, { lang: 'en' }), } } @@ -1906,8 +1910,10 @@ const MapView = forwardRef(function MapView(_, ref) { const MARKER_RADIUS_PX = 14 // half of 28px preview marker // Check for USFS trails/roads click (show info popup) - const usfsLayers = [USFS_TRAILS_HIT, USFS_ROADS_HIT] - const usfsFeatures = map.queryRenderedFeatures(e.point, { layers: usfsLayers }) + const usfsLayers = [USFS_TRAILS_HIT, USFS_ROADS_HIT].filter(id => map.getLayer(id)) + const usfsFeatures = usfsLayers.length > 0 + ? map.queryRenderedFeatures(e.point, { layers: usfsLayers }) + : [] const usfsFeature = usfsFeatures.find(f => f.properties) if (usfsFeature && hasFeature('has_usfs_trails')) { const props = usfsFeature.properties @@ -1953,8 +1959,10 @@ const MapView = forwardRef(function MapView(_, ref) { } // Check for BLM routes click (show info popup) - const blmLayers = [BLM_ROUTES_HIT] - const blmFeatures = map.queryRenderedFeatures(e.point, { layers: blmLayers }) + const blmLayers = [BLM_ROUTES_HIT].filter(id => map.getLayer(id)) + const blmFeatures = blmLayers.length > 0 + ? map.queryRenderedFeatures(e.point, { layers: blmLayers }) + : [] const blmFeature = blmFeatures.find(f => f.properties) if (blmFeature && hasFeature("has_blm_trails")) { const props = blmFeature.properties diff --git a/src/themes/registry.js b/src/themes/registry.js index cc48c8f..c9879dd 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -1,652 +1,636 @@ -/** - * 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' +/** + * 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' import cyberpunkTheme from './cyberpunk.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, - // 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 + // 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 From 04e15bf3dc8f1e64cbed0995b5a199fd32a8a100 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 22:20:06 +0000 Subject: [PATCH 10/56] feat(themes): add Tactical theme - NVG-compatible mil-spec display Dark olive/charcoal base with sage green text and amber accents. Designed for night operations and eventual ATAK/iTAK integration: - Low contrast for night vision compatibility - Prominent olive-brown contours (topo-first design) - Subdued amber roads (preserves night vision better than blue/white) - Muted field-appropriate danger red - No bright blues or whites anywhere - Darkened/desaturated satellite mode Functional, not decorative. Co-Authored-By: Claude Opus 4.5 --- src/themes/registry.js | 2 + src/themes/tactical.js | 325 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 src/themes/tactical.js diff --git a/src/themes/registry.js b/src/themes/registry.js index c9879dd..b617285 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -18,6 +18,7 @@ import cleanTheme from './clean.js' import cyberpunkTheme from './cyberpunk.js' +import tacticalTheme from './tactical.js' // ═══════════════════════════════════════════════════════════════════════════ // UI CSS CUSTOM PROPERTIES @@ -490,6 +491,7 @@ const themes = { fontImports: [], }, cyberpunk: cyberpunkTheme, + tactical: tacticalTheme, // Custom themes go here. Example: // 'midnight': { // id: 'midnight', diff --git a/src/themes/tactical.js b/src/themes/tactical.js new file mode 100644 index 0000000..bdae37f --- /dev/null +++ b/src/themes/tactical.js @@ -0,0 +1,325 @@ +/** + * Tactical 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 tacticalColors = { + // 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 - tactical field display + */ +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": "#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 tacticalOverlay = { + 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 tacticalSatellite = { + opacity: 0.75, + brightnessMin: 0.0, + brightnessMax: 0.35, + contrast: 0.0, + saturation: -0.7, + hueRotate: 0, +} + +const tacticalTheme = { + id: "tactical", + name: "Tactical", + dark: true, + swatch: ["#0d110d", "#c89030", "#a0b090"], + fontImports: [], + colors: tacticalColors, + satellite: tacticalSatellite, + overlay: tacticalOverlay, + ui: tacticalUI, +} + +export default tacticalTheme From b25d6e575d0e53737cabd5eb370b680a192f746a Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 22:29:11 +0000 Subject: [PATCH 11/56] =?UTF-8?q?refactor(themes):=20rename=20Tactical=20?= =?UTF-8?q?=E2=86=92=20Ranger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure rename, no functional changes. Co-Authored-By: Claude Opus 4.5 --- src/themes/{tactical.js => ranger.js} | 28 +++++++++++++-------------- src/themes/registry.js | 4 ++-- 2 files changed, 16 insertions(+), 16 deletions(-) rename src/themes/{tactical.js => ranger.js} (92%) diff --git a/src/themes/tactical.js b/src/themes/ranger.js similarity index 92% rename from src/themes/tactical.js rename to src/themes/ranger.js index bdae37f..5ae8ca6 100644 --- a/src/themes/tactical.js +++ b/src/themes/ranger.js @@ -1,5 +1,5 @@ /** - * Tactical Theme for Navi + * 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 @@ -18,7 +18,7 @@ * Map flavor colors - protomaps-themes-base schema * All 73 flat keys + pois + landcover nested objects */ -const tacticalColors = { +const rangerColors = { // Background & earth - dark olive background: "#0a0e0a", earth: "#0d110d", @@ -134,9 +134,9 @@ const tacticalColors = { } /** - * UI CSS custom properties - tactical field display + * UI CSS custom properties - ranger field display */ -const tacticalUI = { +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", @@ -175,7 +175,7 @@ const tacticalUI = { /** * Overlay configuration - prominent contours, subdued everything else */ -const tacticalOverlay = { +const rangerOverlay = { hillshade: { exaggeration: 0.5, illuminationDirection: 315, @@ -301,7 +301,7 @@ const tacticalOverlay = { }, } -const tacticalSatellite = { +const rangerSatellite = { opacity: 0.75, brightnessMin: 0.0, brightnessMax: 0.35, @@ -310,16 +310,16 @@ const tacticalSatellite = { hueRotate: 0, } -const tacticalTheme = { - id: "tactical", - name: "Tactical", +const rangerTheme = { + id: "ranger", + name: "Ranger", dark: true, swatch: ["#0d110d", "#c89030", "#a0b090"], fontImports: [], - colors: tacticalColors, - satellite: tacticalSatellite, - overlay: tacticalOverlay, - ui: tacticalUI, + colors: rangerColors, + satellite: rangerSatellite, + overlay: rangerOverlay, + ui: rangerUI, } -export default tacticalTheme +export default rangerTheme diff --git a/src/themes/registry.js b/src/themes/registry.js index b617285..a3c1165 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -18,7 +18,7 @@ import cleanTheme from './clean.js' import cyberpunkTheme from './cyberpunk.js' -import tacticalTheme from './tactical.js' +import rangerTheme from './ranger.js' // ═══════════════════════════════════════════════════════════════════════════ // UI CSS CUSTOM PROPERTIES @@ -491,7 +491,7 @@ const themes = { fontImports: [], }, cyberpunk: cyberpunkTheme, - tactical: tacticalTheme, + ranger: rangerTheme, // Custom themes go here. Example: // 'midnight': { // id: 'midnight', From cc1b25e0b1a12a3cd019d1f1030a6efd1c1a7389 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 22:40:58 +0000 Subject: [PATCH 12/56] feat(themes): Add Tactical green phosphor theme Military display aesthetic - classic green phosphor monochrome like NVGs, submarine sonar screens, 1980s radar consoles, and green-screen terminals. Design rules: - ONLY green and black (exception: danger status uses orange-red) - All features differentiated by brightness, not hue - Water is pure black, not blue-tinted - Text is phosphor green (#00cc44) - Black halos on all labels Named "Tactical" for the recon/military working display identity. Co-Authored-By: Claude Opus 4.5 --- src/themes/registry.js | 2 + src/themes/tactical.js | 328 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 src/themes/tactical.js diff --git a/src/themes/registry.js b/src/themes/registry.js index a3c1165..924e6c1 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -19,6 +19,7 @@ import cleanTheme from './clean.js' import cyberpunkTheme from './cyberpunk.js' import rangerTheme from './ranger.js' +import tacticalTheme from './tactical.js' // ═══════════════════════════════════════════════════════════════════════════ // UI CSS CUSTOM PROPERTIES @@ -492,6 +493,7 @@ const themes = { }, cyberpunk: cyberpunkTheme, ranger: rangerTheme, + tactical: tacticalTheme, // Custom themes go here. Example: // 'midnight': { // id: 'midnight', diff --git a/src/themes/tactical.js b/src/themes/tactical.js new file mode 100644 index 0000000..9ac5ddf --- /dev/null +++ b/src/themes/tactical.js @@ -0,0 +1,328 @@ +/** + * Tactical Theme for Navi + * + * Green phosphor military display. The aesthetic of night vision goggles, + * submarine sonar screens, 1980s radar consoles, and classic green-screen + * terminals. Pure black background with ALL visual information in the green + * spectrum only. + * + * Named "Tactical" because this is the recon/military working display — + * matches the Echo6/RECON platform identity. + * + * Monochrome green rules: + * - ONLY green and black. No red, no blue, no amber, no white. + * - Text is green on black, not white on black. + * - Water is pure black — no blue tint. + * - The ONLY contrast axis is bright-green to dark-green to black. + * - The green is warm phosphor green (#00cc44), not cold cyan-green. + */ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const tacticalColors = { + // Background & earth - pure black with faint green + background: "#000800", + earth: "#000a00", + + // Land use areas - very dark green + park_a: "#001508", + park_b: "#001a0a", + hospital: "#001208", + industrial: "#001005", + school: "#001208", + wood_a: "#001508", + wood_b: "#001a0a", + pedestrian: "#000c03", + scrub_a: "#001206", + scrub_b: "#001508", + glacier: "#000e04", + sand: "#001005", + beach: "#001206", + aerodrome: "#000c03", + runway: "#002a0a", + water: "#000500", + zoo: "#001508", + military: "#001206", + + // Tunnels - black casings + tunnel_other_casing: "#000a00", + tunnel_minor_casing: "#000a00", + tunnel_link_casing: "#000a00", + tunnel_major_casing: "#000a00", + tunnel_highway_casing: "#000a00", + tunnel_other: "#001a08", + tunnel_minor: "#001a08", + tunnel_link: "#002a0a", + tunnel_major: "#003a10", + tunnel_highway: "#004415", + + // Pier & buildings - very dark green + pier: "#002a0a", + buildings: "#001a08", + + // Roads & casings - green spectrum by brightness + minor_service_casing: "#000a00", + minor_casing: "#000a00", + link_casing: "#000a00", + major_casing_late: "#000a00", + highway_casing_late: "#000a00", + other: "#002a0a", + minor_service: "#002a0a", + minor_a: "#003a10", + minor_b: "#002a0a", + link: "#004415", + major_casing_early: "#000a00", + major: "#006622", + highway_casing_early: "#000a00", + highway: "#008830", + railway: "#001a08", + boundaries: "#004415", + + // Waterway label - dim green on black water + waterway_label: "#006622", + + // Bridges - same green spectrum + bridges_other_casing: "#000c03", + bridges_minor_casing: "#000a00", + bridges_link_casing: "#000a00", + bridges_major_casing: "#000a00", + bridges_highway_casing: "#000a00", + bridges_other: "#002a0a", + bridges_minor: "#003a10", + bridges_link: "#004415", + bridges_major: "#006622", + bridges_highway: "#008830", + + // Labels - phosphor green with BLACK halos + roads_label_minor: "#005520", + roads_label_minor_halo: "#000a00", + roads_label_major: "#006622", + roads_label_major_halo: "#000a00", + ocean_label: "#006622", + peak_label: "#006622", + subplace_label: "#005520", + subplace_label_halo: "#000a00", + city_label: "#00cc44", + city_label_halo: "#000a00", + state_label: "#004415", + state_label_halo: "#000a00", + country_label: "#006622", + address_label: "#005520", + address_label_halo: "#000a00", + + // POI icon colors - ALL green spectrum, differentiated by brightness + pois: { + blue: "#006622", + green: "#00aa33", + lapis: "#005520", + pink: "#008830", + red: "#00cc44", + slategray: "#004415", + tangerine: "#00aa33", + turquoise: "#006622", + }, + + // Landcover fill colors - very dark green + landcover: { + grassland: "rgba(0, 21, 8, 1)", + barren: "rgba(0, 16, 5, 1)", + urban_area: "rgba(0, 12, 3, 1)", + farmland: "rgba(0, 18, 6, 1)", + glacier: "rgba(0, 14, 4, 1)", + scrub: "rgba(0, 18, 8, 1)", + forest: "rgba(0, 26, 10, 1)", + }, +} + +/** + * UI CSS custom properties - phosphor green terminal + */ +const tacticalUI = { + "--font-sans": "'Inter', system-ui, -apple-system, sans-serif", + "--font-mono": "'JetBrains Mono', ui-monospace, monospace", + "--font-heading": "'Inter', system-ui, -apple-system, sans-serif", + "--bg-base": "#000a00", + "--bg-raised": "#001200", + "--bg-overlay": "#001a05", + "--bg-input": "#000c02", + "--bg-inset": "#000800", + "--bg-muted": "#001505", + "--text-primary": "#00cc44", + "--text-secondary": "#008830", + "--text-tertiary": "#005520", + "--text-inverse": "#000a00", + "--border": "#002a0a", + "--border-subtle": "#001a08", + "--accent": "#00cc44", + "--accent-hover": "#00dd55", + "--accent-muted": "#002a0a", + "--tan": "#00aa33", + "--tan-muted": "#001a08", + "--pin-origin": "#00cc44", + "--pin-destination": "#00aa33", + "--pin-intermediate": "#008830", + "--pin-stroke": "#000a00", + "--status-success": "#00aa33", + "--status-warning": "#88aa00", + "--status-danger": "#cc4400", + "--success": "#00aa33", + "--warning": "#88aa00", + "--warning-muted": "#1a1a00", + "--route-line": "#00cc44", + "--shadow": "0 2px 8px rgba(0, 0, 0, 0.8)", + "--shadow-lg": "0 4px 16px rgba(0, 0, 0, 0.9)", +} + +/** + * Overlay configuration - monochrome green + */ +const tacticalOverlay = { + hillshade: { + exaggeration: 0.3, + illuminationDirection: 315, + shadowColor: "#000000", + highlightColor: "#001a08", + }, + traffic: { + opacity: 0.4, + }, + contours: { + opacityMod: 0.8, + minorColor: "#003311", + minorOpacity: 0.5, + minorWidth: { z11: 0.5, z14: 1.0 }, + intermediateColor: "#004415", + intermediateOpacity: 0.6, + intermediateWidth: { z8: 0.8, z14: 1.2 }, + indexColor: "#005520", + indexOpacity: 0.8, + indexWidth: { z4: 1.2, z14: 1.8 }, + labelColor: "#006622", + labelHaloColor: "#000a00", + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 10, + labelFont: ["Noto Sans Regular"], + }, + contoursTest: { + minorColor: "#003311", + intermediateColor: "#004415", + indexColor: "#005520", + labelColor: "#006622", + }, + contoursTest10ft: { + minorColor: "#002a0a", + intermediateColor: "#003a10", + indexColor: "#004415", + labelColor: "#005520", + }, + publicLands: { + opacityMod: 0.4, + fillWA: "#001a08", + fillNPS: "#001508", + fillUSFS: "#001a08", + fillBLM: "#001206", + fillFWS: "#001508", + fillSTAT: "#001a08", + fillLOC: "#001206", + fillDefault: "#001005", + fillOpacityWA: 0.20, + fillOpacityNPS: 0.20, + fillOpacityUSFS: 0.18, + fillOpacityBLM: 0.15, + fillOpacitySTAT: 0.18, + fillOpacityLOC: 0.15, + fillOpacityDefault: 0.10, + outlineWA: "#002a0a", + outlineNPS: "#002a0a", + outlineUSFS: "#002a0a", + outlineBLM: "#001a08", + outlineFWS: "#002a0a", + outlineSTAT: "#002a0a", + outlineLOC: "#001a08", + outlineDefault: "#001a08", + outlineOpacityNPS: 0.5, + outlineOpacityUSFS: 0.4, + outlineOpacityDefault: 0.3, + outlineWidth: { z4: 0.3, z8: 0.6, z12: 0.9 }, + labelColor: "#006622", + labelHaloColor: "#000a00", + labelHaloWidth: 1.5, + labelOpacity: 0.7, + labelSize: { z10: 10, z14: 12 }, + labelFont: ["Noto Sans Regular"], + }, + usfsTrails: { + roadsColor: "#004415", + roadsOpacity: 0.8, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + trailsMotorized: "#008830", + trailsBicycle: "#006622", + trailsHiker: "#005520", + trailsDefault: "#004415", + trailsOpacity: 0.8, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + roadsLabelColor: "#006622", + roadsLabelHaloColor: "#000a00", + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.8, + roadsLabelSize: 11, + trailsLabelColor: "#006622", + trailsLabelHaloColor: "#000a00", + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.8, + trailsLabelSize: 11, + labelFont: ["Noto Sans Regular"], + hitWidth: 14, + }, + blmTrails: { + color4wdHigh: "#008830", + color4wdLow: "#006622", + colorAtv: "#008830", + colorMotoSingle: "#006622", + color2wdLow: "#005520", + colorNonMech: "#005520", + colorDefault: "#004415", + colorSnow: "#006622", + lineOpacity: 0.8, + lineOpacityOther: 0.7, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + labelColor: "#006622", + labelHaloColor: "#000a00", + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 11, + labelFont: ["Noto Sans Regular"], + hitWidth: 14, + }, +} + +const tacticalSatellite = { + opacity: 0.5, + brightnessMin: 0.0, + brightnessMax: 0.15, + contrast: 0.0, + saturation: -1.0, + hueRotate: 120, +} + +const tacticalTheme = { + id: "tactical", + name: "Tactical", + dark: true, + swatch: ["#000a00", "#00cc44", "#005520"], + fontImports: [], + colors: tacticalColors, + satellite: tacticalSatellite, + overlay: tacticalOverlay, + ui: tacticalUI, +} + +export default tacticalTheme From 78dc96551b008b4007e5083aec0e5874fbd365c0 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 22:46:01 +0000 Subject: [PATCH 13/56] feat(themes): Add Night Ops red-on-black theme Tactical night operations display optimized for absolute darkness. Inspired by military cockpit instruments, submarine control rooms, and ship bridge displays designed for total darkness. Design rules: - ONLY red and black (pure monochrome red) - Red preserves dark-adapted (scotopic) vision - Water is pure black, no blue tint - All features differentiated by brightness only - Black halos on all labels - More aggressive than Tactical for ZERO ambient light Palette: #0a0000 base, #cc3333 primary, #551515 muted Co-Authored-By: Claude Opus 4.5 --- src/themes/nightops.js | 329 +++++++++++++++++++++++++++++++++++++++++ src/themes/registry.js | 2 + 2 files changed, 331 insertions(+) create mode 100644 src/themes/nightops.js diff --git a/src/themes/nightops.js b/src/themes/nightops.js new file mode 100644 index 0000000..ee3c82d --- /dev/null +++ b/src/themes/nightops.js @@ -0,0 +1,329 @@ +/** + * Night Ops Theme for Navi + * + * Black and red tactical night operations display optimized for absolute + * darkness. Inspired by military cockpit instruments, submarine control + * rooms, and ship bridge displays designed for total darkness. + * + * Red preserves scotopic (dark-adapted) vision better than any other color. + * This is MORE aggressive than Tactical — zero ambient light, eyes fully + * dark-adapted, any non-red light is unacceptable. + * + * Red-on-black rules: + * - ONLY red and black. No green, no blue, no amber, no white, no gray. + * - Text is red on black, not white on black. + * - "Bright" means brighter red (#cc3333), "dim" means darker red (#551111). + * - Water is pure black — no blue tint whatsoever. + * - Vegetation is very dark red-brown, barely distinguishable from land. + * - The ONLY contrast axis is light-red to dark-red to black. + */ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const nightopsColors = { + // Background & earth - pure black with faint red + background: "#0a0000", + earth: "#080000", + + // Land use areas - very dark red-brown + park_a: "#120505", + park_b: "#150606", + hospital: "#100404", + industrial: "#0c0303", + school: "#100404", + wood_a: "#120505", + wood_b: "#150606", + pedestrian: "#0a0202", + scrub_a: "#100404", + scrub_b: "#120505", + glacier: "#0c0303", + sand: "#0c0303", + beach: "#100404", + aerodrome: "#0a0202", + runway: "#1a0808", + water: "#050000", + zoo: "#120505", + military: "#100404", + + // Tunnels - black casings + tunnel_other_casing: "#080000", + tunnel_minor_casing: "#080000", + tunnel_link_casing: "#080000", + tunnel_major_casing: "#080000", + tunnel_highway_casing: "#080000", + tunnel_other: "#150606", + tunnel_minor: "#150606", + tunnel_link: "#1a0808", + tunnel_major: "#2a0a0a", + tunnel_highway: "#3a1010", + + // Pier & buildings - very dark red-brown + pier: "#1a0808", + buildings: "#150606", + + // Roads & casings - red spectrum by brightness + minor_service_casing: "#080000", + minor_casing: "#080000", + link_casing: "#080000", + major_casing_late: "#080000", + highway_casing_late: "#080000", + other: "#1a0808", + minor_service: "#1a0808", + minor_a: "#2a0a0a", + minor_b: "#1a0808", + link: "#3a1010", + major_casing_early: "#080000", + major: "#551515", + highway_casing_early: "#080000", + highway: "#772222", + railway: "#150606", + boundaries: "#3a1010", + + // Waterway label - dim red on black water + waterway_label: "#551515", + + // Bridges - same red spectrum + bridges_other_casing: "#0a0202", + bridges_minor_casing: "#080000", + bridges_link_casing: "#080000", + bridges_major_casing: "#080000", + bridges_highway_casing: "#080000", + bridges_other: "#1a0808", + bridges_minor: "#2a0a0a", + bridges_link: "#3a1010", + bridges_major: "#551515", + bridges_highway: "#772222", + + // Labels - red with BLACK halos + roads_label_minor: "#441111", + roads_label_minor_halo: "#0a0000", + roads_label_major: "#551515", + roads_label_major_halo: "#0a0000", + ocean_label: "#551515", + peak_label: "#551515", + subplace_label: "#441111", + subplace_label_halo: "#0a0000", + city_label: "#cc3333", + city_label_halo: "#0a0000", + state_label: "#3a1010", + state_label_halo: "#0a0000", + country_label: "#551515", + address_label: "#441111", + address_label_halo: "#0a0000", + + // POI icon colors - ALL red spectrum, differentiated by brightness + pois: { + blue: "#551515", + green: "#882222", + lapis: "#441111", + pink: "#772222", + red: "#cc3333", + slategray: "#3a1010", + tangerine: "#882222", + turquoise: "#551515", + }, + + // Landcover fill colors - very dark red-brown + landcover: { + grassland: "rgba(18, 5, 5, 1)", + barren: "rgba(12, 3, 3, 1)", + urban_area: "rgba(10, 2, 2, 1)", + farmland: "rgba(15, 4, 4, 1)", + glacier: "rgba(12, 3, 3, 1)", + scrub: "rgba(15, 5, 5, 1)", + forest: "rgba(21, 6, 6, 1)", + }, +} + +/** + * UI CSS custom properties - red-on-black terminal + */ +const nightopsUI = { + "--font-sans": "'Inter', system-ui, -apple-system, sans-serif", + "--font-mono": "'JetBrains Mono', ui-monospace, monospace", + "--font-heading": "'Inter', system-ui, -apple-system, sans-serif", + "--bg-base": "#0a0000", + "--bg-raised": "#120000", + "--bg-overlay": "#1a0505", + "--bg-input": "#0c0202", + "--bg-inset": "#080000", + "--bg-muted": "#150505", + "--text-primary": "#cc3333", + "--text-secondary": "#882222", + "--text-tertiary": "#551515", + "--text-inverse": "#0a0000", + "--border": "#2a0a0a", + "--border-subtle": "#1a0606", + "--accent": "#cc3333", + "--accent-hover": "#dd4444", + "--accent-muted": "#2a0a0a", + "--tan": "#aa2222", + "--tan-muted": "#1a0606", + "--pin-origin": "#cc3333", + "--pin-destination": "#aa2222", + "--pin-intermediate": "#882222", + "--pin-stroke": "#0a0000", + "--status-success": "#883322", + "--status-warning": "#cc4422", + "--status-danger": "#ff2222", + "--success": "#883322", + "--warning": "#cc4422", + "--warning-muted": "#2a0a05", + "--route-line": "#cc3333", + "--shadow": "0 2px 8px rgba(0, 0, 0, 0.8)", + "--shadow-lg": "0 4px 16px rgba(0, 0, 0, 0.9)", +} + +/** + * Overlay configuration - monochrome red + */ +const nightopsOverlay = { + hillshade: { + exaggeration: 0.2, + illuminationDirection: 315, + shadowColor: "#000000", + highlightColor: "#0a0000", + }, + traffic: { + opacity: 0.4, + }, + contours: { + opacityMod: 0.8, + minorColor: "#2a0808", + minorOpacity: 0.5, + minorWidth: { z11: 0.5, z14: 1.0 }, + intermediateColor: "#3a1010", + intermediateOpacity: 0.6, + intermediateWidth: { z8: 0.8, z14: 1.2 }, + indexColor: "#441111", + indexOpacity: 0.8, + indexWidth: { z4: 1.2, z14: 1.8 }, + labelColor: "#551515", + labelHaloColor: "#0a0000", + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 10, + labelFont: ["Noto Sans Regular"], + }, + contoursTest: { + minorColor: "#2a0808", + intermediateColor: "#3a1010", + indexColor: "#441111", + labelColor: "#551515", + }, + contoursTest10ft: { + minorColor: "#1a0606", + intermediateColor: "#2a0808", + indexColor: "#3a1010", + labelColor: "#441111", + }, + publicLands: { + opacityMod: 0.4, + fillWA: "#150606", + fillNPS: "#120505", + fillUSFS: "#150606", + fillBLM: "#100404", + fillFWS: "#120505", + fillSTAT: "#150606", + fillLOC: "#100404", + fillDefault: "#0c0303", + fillOpacityWA: 0.20, + fillOpacityNPS: 0.20, + fillOpacityUSFS: 0.18, + fillOpacityBLM: 0.15, + fillOpacitySTAT: 0.18, + fillOpacityLOC: 0.15, + fillOpacityDefault: 0.10, + outlineWA: "#1a0808", + outlineNPS: "#1a0808", + outlineUSFS: "#1a0808", + outlineBLM: "#150606", + outlineFWS: "#1a0808", + outlineSTAT: "#1a0808", + outlineLOC: "#150606", + outlineDefault: "#150606", + outlineOpacityNPS: 0.5, + outlineOpacityUSFS: 0.4, + outlineOpacityDefault: 0.3, + outlineWidth: { z4: 0.3, z8: 0.6, z12: 0.9 }, + labelColor: "#551515", + labelHaloColor: "#0a0000", + labelHaloWidth: 1.5, + labelOpacity: 0.7, + labelSize: { z10: 10, z14: 12 }, + labelFont: ["Noto Sans Regular"], + }, + usfsTrails: { + roadsColor: "#3a1010", + roadsOpacity: 0.8, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + trailsMotorized: "#772222", + trailsBicycle: "#661818", + trailsHiker: "#551515", + trailsDefault: "#3a1010", + trailsOpacity: 0.8, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + roadsLabelColor: "#551515", + roadsLabelHaloColor: "#0a0000", + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.8, + roadsLabelSize: 11, + trailsLabelColor: "#551515", + trailsLabelHaloColor: "#0a0000", + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.8, + trailsLabelSize: 11, + labelFont: ["Noto Sans Regular"], + hitWidth: 14, + }, + blmTrails: { + color4wdHigh: "#772222", + color4wdLow: "#661818", + colorAtv: "#772222", + colorMotoSingle: "#661818", + color2wdLow: "#551515", + colorNonMech: "#551515", + colorDefault: "#3a1010", + colorSnow: "#661818", + lineOpacity: 0.8, + lineOpacityOther: 0.7, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + labelColor: "#551515", + labelHaloColor: "#0a0000", + labelHaloWidth: 1.5, + labelOpacity: 0.8, + labelSize: 11, + labelFont: ["Noto Sans Regular"], + hitWidth: 14, + }, +} + +const nightopsSatellite = { + opacity: 0.5, + brightnessMin: 0.0, + brightnessMax: 0.15, + contrast: 0.0, + saturation: -1.0, + hueRotate: 0, +} + +const nightopsTheme = { + id: "nightops", + name: "Night Ops", + dark: true, + swatch: ["#0a0000", "#cc3333", "#551515"], + fontImports: [], + colors: nightopsColors, + satellite: nightopsSatellite, + overlay: nightopsOverlay, + ui: nightopsUI, +} + +export default nightopsTheme diff --git a/src/themes/registry.js b/src/themes/registry.js index 924e6c1..cdebb83 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -20,6 +20,7 @@ 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' // ═══════════════════════════════════════════════════════════════════════════ // UI CSS CUSTOM PROPERTIES @@ -494,6 +495,7 @@ const themes = { cyberpunk: cyberpunkTheme, ranger: rangerTheme, tactical: tacticalTheme, + nightops: nightopsTheme, // Custom themes go here. Example: // 'midnight': { // id: 'midnight', From 948871b9e52b295140a6b7c6f02c2b7a5fa894ad Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 22:49:50 +0000 Subject: [PATCH 14/56] feat(themes): Add Parchment medieval manuscript theme Old-world cartography style inspired by hand-drawn maps. Medieval manuscript aesthetic with warm aged parchment land, deep ultramarine water, dark sepia ink labels, and burnt sienna roads. Design features: - IM Fell English font (1672 Fell typeface revival) - Deep ultramarine water (#1a3a6a), not modern pale blue - Olive-gold vegetation, NOT modern green - Brown ink roads by importance - Parchment-colored halos on all labels - Warm dramatic hillshade terrain The feel of a map from a monastery scriptorium or explorer journal. Co-Authored-By: Claude Opus 4.5 --- src/themes/parchment.js | 374 ++++++++++++++++++++++++++++++++++++++++ src/themes/registry.js | 2 + 2 files changed, 376 insertions(+) create mode 100644 src/themes/parchment.js diff --git a/src/themes/parchment.js b/src/themes/parchment.js new file mode 100644 index 0000000..03bc22d --- /dev/null +++ b/src/themes/parchment.js @@ -0,0 +1,374 @@ +/** + * Parchment Theme for Navi + * + * Medieval manuscript cartography. An ancient vellum map unrolled on a table — + * deep rich ultramarine water (not modern pale blue), warm aged parchment land, + * dark sepia ink labels, burnt sienna roads. Forests are olive-gold tints, not + * modern green. The feel of a map you'd find in a monastery scriptorium or a + * leather-bound explorer's journal. + * + * CUSTOM FONT: IM Fell English — a revival of a 1672 Fell typeface. + * Irregular, warm, distinctly pre-modern. Used for ALL text. + * + * Parchment rules: + * - Water is DEEP ultramarine (#1a3a6a), not modern pale blue + * - Land is warm parchment/vellum — aged, not white + * - Vegetation is olive-gold, NOT modern green + * - Roads are brown ink — darker = more important + * - Labels are dark sepia ink with parchment halos + * - Everything should feel handmade, warm, aged + */ + +/** + * Map flavor colors - protomaps-themes-base schema + * All 73 flat keys + pois + landcover nested objects + */ +const parchmentColors = { + // Background & earth - warm parchment + background: "#c8b888", + earth: "#d8c8a0", + + // Land use areas - warm parchment variations + park_a: "#c8c088", + park_b: "#b8b078", + hospital: "#d8c8a0", + industrial: "#c8b890", + school: "#d0c098", + wood_a: "#a8a060", + wood_b: "#8a8848", + pedestrian: "#d0c098", + scrub_a: "#b8b070", + scrub_b: "#a8a060", + glacier: "#e0d8c0", + sand: "#d8c8a0", + beach: "#e0d0a8", + aerodrome: "#c8c0a0", + runway: "#b8a888", + water: "#1a3a6a", + zoo: "#c0b880", + military: "#c8b898", + + // Tunnels - parchment casings + tunnel_other_casing: "#b8a070", + tunnel_minor_casing: "#b8a070", + tunnel_link_casing: "#b8a070", + tunnel_major_casing: "#b8a070", + tunnel_highway_casing: "#b8a070", + tunnel_other: "#c8b898", + tunnel_minor: "#c8b898", + tunnel_link: "#b8a078", + tunnel_major: "#a89068", + tunnel_highway: "#988058", + + // Pier & buildings - warm stone + pier: "#c0a878", + buildings: "#c0a878", + + // Roads & casings - brown ink progression + minor_service_casing: "#b8a070", + minor_casing: "#b8a070", + link_casing: "#b8a070", + major_casing_late: "#b8a070", + highway_casing_late: "#b8a070", + other: "#a89068", + minor_service: "#a89068", + minor_a: "#9a8058", + minor_b: "#a89068", + link: "#8a6a3a", + major_casing_early: "#b8a070", + major: "#7a5a2a", + highway_casing_early: "#b8a070", + highway: "#6a3a1a", + railway: "#8a7050", + boundaries: "#8a6a3a", + + // Waterway label - parchment on dark water + waterway_label: "#c8b890", + + // Bridges - same brown ink colors + bridges_other_casing: "#c0a880", + bridges_minor_casing: "#b8a070", + bridges_link_casing: "#b8a070", + bridges_major_casing: "#b8a070", + bridges_highway_casing: "#b8a070", + bridges_other: "#a89068", + bridges_minor: "#9a8058", + bridges_link: "#8a6a3a", + bridges_major: "#7a5a2a", + bridges_highway: "#6a3a1a", + + // Labels - dark sepia ink with PARCHMENT halos + roads_label_minor: "#6a4a20", + roads_label_minor_halo: "#d8c8a0", + roads_label_major: "#5a3a10", + roads_label_major_halo: "#d8c8a0", + ocean_label: "#c8b890", + peak_label: "#5a4020", + subplace_label: "#6a4a20", + subplace_label_halo: "#d8c8a0", + city_label: "#2a1a0a", + city_label_halo: "#d8c8a0", + state_label: "#8a7050", + state_label_halo: "#d8c8a0", + country_label: "#5a4020", + address_label: "#6a4a20", + address_label_halo: "#d8c8a0", + + // POI icon colors - period-appropriate muted palette + pois: { + blue: "#1a3a6a", + green: "#4a6830", + lapis: "#2a4a7a", + pink: "#8a5040", + red: "#8b2500", + slategray: "#6a5a4a", + tangerine: "#8b4513", + turquoise: "#3a5a6a", + }, + + // Landcover fill colors - olive-gold tints, NOT modern green + landcover: { + grassland: "rgba(184, 176, 120, 1)", + barren: "rgba(200, 184, 136, 1)", + urban_area: "rgba(208, 192, 152, 1)", + farmland: "rgba(200, 184, 128, 1)", + glacier: "rgba(224, 216, 192, 1)", + scrub: "rgba(176, 168, 112, 1)", + forest: "rgba(138, 136, 72, 1)", + }, +} + +/** + * UI CSS custom properties - medieval manuscript aesthetic + * Warm parchment panels with dark sepia ink text + */ +const parchmentUI = { + // Fonts - IM Fell English for everything + "--font-sans": "'IM Fell English', serif", + "--font-mono": "'IM Fell English', serif", + "--font-heading": "'IM Fell English', serif", + // Backgrounds - warm parchment + "--bg-base": "#d8c8a0", + "--bg-raised": "#e4d8b8", + "--bg-overlay": "#ddd0a8", + "--bg-input": "#e8dcc0", + "--bg-inset": "#d0c090", + "--bg-muted": "#e0d4b0", + // Text - dark sepia ink + "--text-primary": "#2a1a0a", + "--text-secondary": "#5a4020", + "--text-tertiary": "#8a7050", + "--text-inverse": "#e8d8b8", + // Borders - aged paper edge + "--border": "#b8a070", + "--border-subtle": "#c8b890", + // Accent - saddle brown + "--accent": "#8b4513", + "--accent-hover": "#a05520", + "--accent-muted": "#e0d0b0", + // Tan - brown ink variants + "--tan": "#7a5a2a", + "--tan-muted": "#e8d8c0", + // Pins - brown ink and ultramarine + "--pin-origin": "#8b4513", + "--pin-destination": "#1a3a6a", + "--pin-intermediate": "#6a5a40", + "--pin-stroke": "#2a1a0a", + // Status - period-appropriate colors + "--status-success": "#4a6830", + "--status-warning": "#b8860b", + "--status-danger": "#8b2500", + "--success": "#4a6830", + "--warning": "#b8860b", + "--warning-muted": "#e8d8b8", + // Route - saddle brown + "--route-line": "#8b4513", + // Shadows - warm brown tinted, subtle + "--shadow": "0 2px 8px rgba(42, 26, 10, 0.15)", + "--shadow-lg": "0 4px 16px rgba(42, 26, 10, 0.20)", +} + +/** + * Overlay configuration - warm brown ink on parchment + */ +const parchmentOverlay = { + // Hillshade - warm dramatic terrain + hillshade: { + exaggeration: 0.6, + illuminationDirection: 315, + shadowColor: "#3a2a1a", + highlightColor: "#f0e8d8", + }, + + // Contours - brown ink elevation lines + contours: { + opacityMod: 1.0, + minorColor: "#a88060", + minorOpacity: 0.5, + minorWidth: { z11: 0.5, z14: 1.0 }, + intermediateColor: "#8a6a3a", + intermediateOpacity: 0.7, + intermediateWidth: { z8: 0.8, z14: 1.2 }, + indexColor: "#6a4a20", + indexOpacity: 0.9, + indexWidth: { z4: 1.2, z14: 1.8 }, + labelColor: "#5a3a10", + labelHaloColor: "#d8c8a0", + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: 10, + labelFont: ["Noto Sans Regular"], + }, + + // Contours Test - same warm brown + contoursTest: { + minorColor: "#a88060", + intermediateColor: "#8a6a3a", + indexColor: "#6a4a20", + labelColor: "#5a3a10", + }, + + // Contours Test 10ft - slightly lighter brown + contoursTest10ft: { + minorColor: "#b89070", + intermediateColor: "#9a7a4a", + indexColor: "#7a5a30", + labelColor: "#6a4a20", + }, + + // Public Lands - muted olive-gold fills + publicLands: { + opacityMod: 0.8, + // Fill colors - olive-gold tints + fillWA: "#b8b070", + fillNPS: "#a8a060", + fillUSFS: "#b0a868", + fillBLM: "#c0b080", + fillFWS: "#a0a058", + fillSTAT: "#b8b078", + fillLOC: "#c0b888", + fillDefault: "#c8c090", + // Fill opacities + fillOpacityWA: 0.25, + fillOpacityNPS: 0.25, + fillOpacityUSFS: 0.22, + fillOpacityBLM: 0.18, + fillOpacitySTAT: 0.22, + fillOpacityLOC: 0.18, + fillOpacityDefault: 0.15, + // Outline colors - brown ink + outlineWA: "#8a7040", + outlineNPS: "#7a6030", + outlineUSFS: "#8a7040", + outlineBLM: "#9a8050", + outlineFWS: "#7a6030", + outlineSTAT: "#8a7040", + outlineLOC: "#9a8050", + outlineDefault: "#a89060", + // Outline opacities + outlineOpacityNPS: 0.7, + outlineOpacityUSFS: 0.6, + outlineOpacityDefault: 0.5, + // Outline width + outlineWidth: { z4: 0.3, z8: 0.8, z12: 1.2 }, + // Labels - sepia ink with parchment halo + labelColor: "#5a4020", + labelHaloColor: "#d8c8a0", + labelHaloWidth: 1.5, + labelOpacity: 0.85, + labelSize: { z10: 10, z14: 13 }, + labelFont: ["Noto Sans Regular"], + }, + + // USFS Trails - brown/sienna/olive ink family + usfsTrails: { + // Roads - brown ink + roadsColor: "#7a5a2a", + roadsOpacity: 0.9, + roadsWidth: { z10: 1.5, z14: 2.5, z16: 3.5 }, + // Trails - warm earth tones only + trailsMotorized: "#8b4513", + trailsBicycle: "#8a6a3a", + trailsHiker: "#6a5a40", + trailsDefault: "#7a5a2a", + trailsOpacity: 0.9, + trailsWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + trailsDash: [2, 1.5], + // Road labels + roadsLabelColor: "#5a4020", + roadsLabelHaloColor: "#d8c8a0", + roadsLabelHaloWidth: 1.5, + roadsLabelOpacity: 0.9, + roadsLabelSize: 11, + // Trail labels + trailsLabelColor: "#5a4020", + trailsLabelHaloColor: "#d8c8a0", + trailsLabelHaloWidth: 1.5, + trailsLabelOpacity: 0.9, + trailsLabelSize: 11, + labelFont: ["Noto Sans Regular"], + // Hit layer + hitWidth: 14, + }, + + // BLM Trails - brown/sienna/olive ink family + blmTrails: { + // Route colors - warm earth tones + color4wdHigh: "#8b4513", + color4wdLow: "#8a6a3a", + colorAtv: "#8b2500", + colorMotoSingle: "#7a5a2a", + color2wdLow: "#9a7a4a", + colorNonMech: "#6a5a40", + colorDefault: "#8a6a3a", + colorSnow: "#5a6a7a", + lineOpacity: 0.9, + lineOpacityOther: 0.85, + lineWidth: { z10: 2.0, z14: 3.0, z16: 4.0 }, + // Dash patterns + dashImproved: [4, 2], + dashAggregate: [1, 2], + dashSnow: [4, 2, 1, 2], + dashOther: [4, 2, 1, 2, 1, 2], + // Labels + labelColor: "#5a4020", + labelHaloColor: "#d8c8a0", + labelHaloWidth: 1.5, + labelOpacity: 0.9, + labelSize: 11, + labelFont: ["Noto Sans Regular"], + // Hit layer + hitWidth: 14, + }, +} + +/** + * Satellite adjustments - warm sepia shift + */ +const parchmentSatellite = { + opacity: 0.85, + brightnessMin: 0.0, + brightnessMax: 0.85, + contrast: 0.15, + saturation: -0.4, + hueRotate: 30, +} + +/** + * Parchment theme configuration + */ +const parchmentTheme = { + id: "parchment", + name: "Parchment", + dark: false, + swatch: ["#d8c8a0", "#8b4513", "#1a3a6a"], + fontImports: [ + "https://fonts.googleapis.com/css2?family=IM+Fell+English:ital@0;1&display=swap", + ], + colors: parchmentColors, + satellite: parchmentSatellite, + overlay: parchmentOverlay, + ui: parchmentUI, +} + +export default parchmentTheme diff --git a/src/themes/registry.js b/src/themes/registry.js index cdebb83..a65a490 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -21,6 +21,7 @@ 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 @@ -496,6 +497,7 @@ const themes = { ranger: rangerTheme, tactical: tacticalTheme, nightops: nightopsTheme, + parchment: parchmentTheme, // Custom themes go here. Example: // 'midnight': { // id: 'midnight', From 66a35d9472c1f8a3f45958e63d4ffefb802d417a Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 22:59:24 +0000 Subject: [PATCH 15/56] fix(ui): Use 4-column grid for theme picker 8 themes no longer fit in a single row. Changed from flex row to a 4x2 grid layout so all themes are visible. Co-Authored-By: Claude Opus 4.5 --- src/components/ThemePicker.jsx | 334 ++++++++++++++++----------------- 1 file changed, 167 insertions(+), 167 deletions(-) diff --git a/src/components/ThemePicker.jsx b/src/components/ThemePicker.jsx index b9f7792..9e0e35e 100644 --- a/src/components/ThemePicker.jsx +++ b/src/components/ThemePicker.jsx @@ -1,167 +1,167 @@ -import { useState, useRef, useEffect } from 'react' -import { Palette } from 'lucide-react' -import { themeList } from '../themes/registry' -import { useStore } from '../store' - -/** - * ThemeSwatch - Renders a circular swatch with 3 color segments - */ -function ThemeSwatch({ colors, size = 28, active = false }) { - // Split circle into 3 segments using conic gradient - const gradient = `conic-gradient( - ${colors[0]} 0deg 120deg, - ${colors[1]} 120deg 240deg, - ${colors[2]} 240deg 360deg - )` - - return ( -
- ) -} - -/** - * ThemePicker - Popover component for selecting themes - */ -export default function ThemePicker() { - const [isOpen, setIsOpen] = useState(false) - const theme = useStore((s) => s.theme) - const setThemeOverride = useStore((s) => s.setThemeOverride) - const triggerRef = useRef(null) - const popoverRef = useRef(null) - - const themes = themeList() - const currentTheme = themes.find(t => t.id === theme) || themes[0] - - // Handle click outside to close - useEffect(() => { - if (!isOpen) return - - function handleClickOutside(e) { - if ( - popoverRef.current && - !popoverRef.current.contains(e.target) && - triggerRef.current && - !triggerRef.current.contains(e.target) - ) { - setIsOpen(false) - } - } - - function handleEscape(e) { - if (e.key === 'Escape') { - setIsOpen(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - document.addEventListener('keydown', handleEscape) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - document.removeEventListener('keydown', handleEscape) - } - }, [isOpen]) - - const handleThemeSelect = (themeId) => { - setThemeOverride(themeId) - setIsOpen(false) - } - - return ( -
- {/* Trigger button */} - - - {/* Popover */} - {isOpen && ( -
-
- {themes.map((t) => ( - - ))} -
-
- )} -
- ) -} +import { useState, useRef, useEffect } from 'react' +import { 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) => ( + + ))} +
+
+ )} +
+ ) +} From 9d3b972d815c72859f869005c8f6c44f8e26ce3d Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 23:00:11 +0000 Subject: [PATCH 16/56] feat(ui): Show theme name as picker trigger button Replace palette icon with current theme name as clickable text. Hover changes from --text-secondary to --text-primary. Popover behavior unchanged. Co-Authored-By: Claude Opus 4.5 --- src/components/ThemePicker.jsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/ThemePicker.jsx b/src/components/ThemePicker.jsx index 9e0e35e..15084a9 100644 --- a/src/components/ThemePicker.jsx +++ b/src/components/ThemePicker.jsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { Palette } from 'lucide-react' + import { themeList } from '../themes/registry' import { useStore } from '../store' @@ -78,18 +78,27 @@ export default function ThemePicker() { return (
- {/* Trigger button */} + {/* Trigger button - shows current theme name */} {/* Popover */} From 67779dbbf7a9582a429e20298ce37bfa2b10e106 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 23:17:13 +0000 Subject: [PATCH 17/56] feat(ui): Redesign bottom-right map control cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase touch targets from 36px to 44px (meets accessibility guidelines) - Wrap Locate and Layers buttons in unified .map-controls-br container - Layer popover now opens LEFT of buttons (avoids collision with Locate) - Add hover and active states with theme-aware styling - Proper spacing for scale control below the cluster - Increased icon sizes from 18px to 20px - Mobile-responsive with proper max-height on layer popover Layout: [Locate] 44x44 [Layers] 44x44 ────────────── Scale: 0.5 mi Co-Authored-By: Claude Opus 4.5 --- src/App.jsx | 8 ++- src/components/LayerControl.jsx | 2 +- src/components/LocateButton.jsx | 2 +- src/index.css | 105 +++++++++++++++++++++++--------- 4 files changed, 85 insertions(+), 32 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 7576c31..0d02c8f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -96,8 +96,12 @@ export default function App() { - - + + {/* Bottom-right map controls */} +
+ + +
) } diff --git a/src/components/LayerControl.jsx b/src/components/LayerControl.jsx index cf2e34e..c67b5ae 100644 --- a/src/components/LayerControl.jsx +++ b/src/components/LayerControl.jsx @@ -271,7 +271,7 @@ export default function LayerControl({ mapRef }) { title="Map layers" aria-label="Toggle map layers" > - + {open && ( diff --git a/src/components/LocateButton.jsx b/src/components/LocateButton.jsx index 3ec445f..55723cf 100644 --- a/src/components/LocateButton.jsx +++ b/src/components/LocateButton.jsx @@ -48,7 +48,7 @@ export default function LocateButton({ mapRef }) { title="My location" aria-label="Center map on my location" > - + ) } diff --git a/src/index.css b/src/index.css index 6a2bd4c..ac38dfe 100644 --- a/src/index.css +++ b/src/index.css @@ -236,42 +236,80 @@ body { opacity: 1; } -/* ═══ LAYER CONTROL ═══ */ -.layer-control { +/* ═══ BOTTOM-RIGHT MAP CONTROLS ═══ */ +.map-controls-br { position: absolute; - bottom: 32px; + bottom: 40px; right: 10px; z-index: 10; + display: flex; + flex-direction: column; + gap: 8px; } -.layer-control-btn { - width: 36px; - height: 36px; +.map-control-btn { + width: 44px; + height: 44px; display: flex; align-items: center; justify-content: center; background: var(--bg-raised); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 10px; color: var(--text-secondary); cursor: pointer; box-shadow: var(--shadow); - transition: color 0.1s, border-color 0.1s; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.map-control-btn:hover { + color: var(--text-primary); + border-color: var(--accent); + background: var(--bg-overlay); +} + +.map-control-btn:active { + background: var(--bg-muted); +} + +/* ═══ LAYER CONTROL ═══ */ +.layer-control { + position: relative; +} + +.layer-control-btn { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text-secondary); + cursor: pointer; + box-shadow: var(--shadow); + transition: color 0.15s, border-color 0.15s, background 0.15s; } .layer-control-btn:hover { color: var(--text-primary); border-color: var(--accent); + background: var(--bg-overlay); +} + +.layer-control-btn:active { + background: var(--bg-muted); } .layer-control-popover { position: absolute; - bottom: 44px; - right: 0; - min-width: 160px; + bottom: 0; + right: 52px; + min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 10px; padding: 8px 0; box-shadow: var(--shadow-lg); } @@ -367,27 +405,28 @@ body { /* ═══ LOCATE BUTTON ═══ */ .locate-btn { - position: absolute; - bottom: 80px; - right: 10px; - z-index: 10; - width: 36px; - height: 36px; + width: 44px; + height: 44px; display: flex; align-items: center; justify-content: center; background: var(--bg-raised); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 10px; color: var(--text-secondary); cursor: pointer; box-shadow: var(--shadow); - transition: color 0.1s, border-color 0.1s; + transition: color 0.15s, border-color 0.15s, background 0.15s; } .locate-btn:hover { color: var(--text-primary); border-color: var(--accent); + background: var(--bg-overlay); +} + +.locate-btn:active { + background: var(--bg-muted); } /* ═══ STOP REMOVE BUTTON (touch-friendly) ═══ */ @@ -406,16 +445,15 @@ body { overflow-x: hidden; } - .layer-control { - bottom: auto; - top: 120px; - right: 10px; + .map-controls-br { + bottom: 24px; + right: 8px; } - .locate-btn { - bottom: auto; - top: 166px; - right: 10px; + .layer-control-popover { + right: 52px; + max-height: 60vh; + overflow-y: auto; } .stop-remove-btn { @@ -501,3 +539,14 @@ body { line-height: 1.5; text-shadow: 0 0 2px rgba(0, 0, 0, 0.8); } + +/* ═══ MAPLIBRE CONTROL POSITIONING ═══ */ +.maplibregl-ctrl-bottom-right { + bottom: 10px; + right: 10px; +} + +.maplibregl-ctrl-bottom-right .maplibregl-ctrl-scale { + margin-right: 0; + margin-bottom: 0; +} From 5299376fecda0d42b1d11c9a4643950b9fab2e22 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 23:45:55 +0000 Subject: [PATCH 18/56] fix(ui): Move control cluster up to clear attribution and scale - Control cluster now at bottom: 80px (was 40px) - Scale bar at bottom: 24px (above attribution) - Mobile adjusted to bottom: 70px - Clear visual separation: buttons > scale > attribution Co-Authored-By: Claude Opus 4.5 --- src/index.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.css b/src/index.css index ac38dfe..9e241c0 100644 --- a/src/index.css +++ b/src/index.css @@ -239,7 +239,7 @@ body { /* ═══ BOTTOM-RIGHT MAP CONTROLS ═══ */ .map-controls-br { position: absolute; - bottom: 40px; + bottom: 80px; right: 10px; z-index: 10; display: flex; @@ -446,7 +446,7 @@ body { } .map-controls-br { - bottom: 24px; + bottom: 70px; right: 8px; } @@ -542,7 +542,7 @@ body { /* ═══ MAPLIBRE CONTROL POSITIONING ═══ */ .maplibregl-ctrl-bottom-right { - bottom: 10px; + bottom: 24px; right: 10px; } From e786bb88707788999a17070a9c78414ff21f63cc Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 02:01:56 +0000 Subject: [PATCH 19/56] feat: Add satellite imagery with Map/Satellite/Hybrid view modes - Add viewMode state to store with localStorage persistence - Add satellite layer functions to MapView (ESRI World Imagery via nginx proxy) - Add view mode segmented control in LayerControl popover - Add view-mode-control CSS styles - Hide/show vector fills and lines based on view mode Co-Authored-By: Claude Opus 4.5 --- src/components/LayerControl.jsx | 738 +++++++++++++++++--------------- src/components/MapView.jsx | 190 ++++++++ src/index.css | 33 ++ src/store.js | 5 + 4 files changed, 623 insertions(+), 343 deletions(-) diff --git a/src/components/LayerControl.jsx b/src/components/LayerControl.jsx index c67b5ae..ee41ccb 100644 --- a/src/components/LayerControl.jsx +++ b/src/components/LayerControl.jsx @@ -1,222 +1,227 @@ -import { useState, useEffect, useRef } from 'react' -import { Layers, Trees, Mountain } from 'lucide-react' -import { hasFeature, getConfig } from '../config' - -const STORAGE_KEY = 'navi-layer-prefs' - -function loadPrefs() { - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (raw) return JSON.parse(raw) - } catch {} - return null -} - -function savePrefs(prefs) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) -} - -export default function LayerControl({ mapRef }) { - const [open, setOpen] = useState(false) - const [hillshade, setHillshade] = useState(false) - const [traffic, setTraffic] = useState(false) - const [publicLands, setPublicLands] = useState(false) - const [contours, setContours] = useState(false) - const [contoursTest, setContoursTest] = useState(false) - const [contoursTest10ft, setContoursTest10ft] = useState(false) +import { useState, useEffect, useRef } from 'react' +import { Layers, Map, Satellite, Globe } from 'lucide-react' +import { hasFeature, getConfig } from '../config' +import { useStore } from '../store' + +const STORAGE_KEY = 'navi-layer-prefs' + +function loadPrefs() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) return JSON.parse(raw) + } catch {} + return null +} + +function savePrefs(prefs) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) +} + +export default function LayerControl({ mapRef }) { + const [open, setOpen] = useState(false) + const [hillshade, setHillshade] = useState(false) + const [traffic, setTraffic] = useState(false) + const [publicLands, setPublicLands] = useState(false) + const [contours, setContours] = useState(false) + const [contoursTest, setContoursTest] = useState(false) + const [contoursTest10ft, setContoursTest10ft] = useState(false) const [usfsTrails, setUsfsTrails] = useState(false) - const [blmTrails, setBlmTrails] = useState(false) - const panelRef = useRef(null) - - // Initialize from localStorage or defaults on mount - useEffect(() => { - const saved = loadPrefs() - const hsAvailable = hasFeature('has_hillshade') - const trAvailable = hasFeature('has_traffic_overlay') - const plAvailable = hasFeature('has_public_lands_layer') - const ctAvailable = hasFeature('has_contours') - const ctTestAvailable = hasFeature('has_contours_test') - const ctTest10ftAvailable = hasFeature('has_contours_test_10ft') + const [blmTrails, setBlmTrails] = useState(false) + const panelRef = useRef(null) + + // View mode: map | satellite | hybrid + const viewMode = useStore((s) => s.viewMode) + const setViewMode = useStore((s) => s.setViewMode) + + // 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 && (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) - } - }, []) - - // Apply layers when prefs change - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (hillshade && hasFeature('has_hillshade')) { - mapView.addHillshadeLayer?.() - } else { - mapView.removeHillshadeLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [hillshade, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (traffic && hasFeature('has_traffic_overlay')) { - mapView.addTrafficLayer?.() - } else { - mapView.removeTrafficLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [traffic, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (publicLands && hasFeature('has_public_lands_layer')) { - mapView.addPublicLandsLayer?.() - } else { - mapView.removePublicLandsLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [publicLands, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (contours && hasFeature('has_contours')) { - mapView.addContoursLayer?.() - } else { - mapView.removeContoursLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [contours, mapRef]) - - useEffect(() => { - const mapView = mapRef?.current - if (!mapView) return - const map = mapView.getMap?.() - if (!map) return - - const apply = () => { - if (contoursTest && hasFeature('has_contours_test')) { - mapView.addContoursTestLayer?.() - } else { - mapView.removeContoursTestLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - return () => map.off('style.load', apply) - }, [contoursTest, mapRef]) - - // Apply contoursTest10ft layer - useEffect(() => { - const map = mapRef.current?.getMap?.() - if (!map) return - - const apply = () => { - if (contoursTest10ft && hasFeature('has_contours_test_10ft')) { - mapRef.current?.addContoursTest10ftLayer?.() - } else { - mapRef.current?.removeContoursTest10ftLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - }, [contoursTest10ft, mapRef]) - - // Apply usfsTrails layer - useEffect(() => { - const map = mapRef.current?.getMap?.() - if (!map) return - - const apply = () => { - if (usfsTrails && hasFeature('has_usfs_trails')) { - mapRef.current?.addUsfsTrailsLayer?.() - } else { - mapRef.current?.removeUsfsTrailsLayer?.() - } - } - - if (map.isStyleLoaded()) { - apply() - } else { - map.once('style.load', apply) - } - savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) - }, [usfsTrails, mapRef]) + setBlmTrails(blmAvailable && (saved.blmTrails ?? false)) + } else { + // Defaults: hillshade ON if available, others OFF + setHillshade(hsAvailable) + setTraffic(false) + setPublicLands(false) + setContours(false) + setContoursTest(false) + setContoursTest10ft(false) + setUsfsTrails(false) + } + }, []) + + // 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(() => { @@ -238,129 +243,176 @@ export default function LayerControl({ mapRef }) { } savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) }, [blmTrails, mapRef]) - - // Close on outside click - useEffect(() => { - if (!open) return - function handleClick(e) { - if (panelRef.current && !panelRef.current.contains(e.target)) { - setOpen(false) - } - } - document.addEventListener('pointerdown', handleClick) - return () => document.removeEventListener('pointerdown', handleClick) - }, [open]) - - const showHillshade = hasFeature('has_hillshade') - const showTraffic = hasFeature('has_traffic_overlay') - const showPublicLands = hasFeature('has_public_lands_layer') - const showContours = hasFeature('has_contours') - const showContoursTest = hasFeature('has_contours_test') - const showContoursTest10ft = hasFeature('has_contours_test_10ft') + + // Apply view mode changes + useEffect(() => { + const mapView = mapRef?.current + if (!mapView) return + const map = mapView.getMap?.() + if (!map) return + + const apply = () => { + mapView.setViewMode?.(viewMode) + } + + if (map.isStyleLoaded()) { + apply() + } else { + map.once('style.load', apply) + } + return () => map.off('style.load', apply) + }, [viewMode, mapRef]) + + // Close on outside click + useEffect(() => { + if (!open) return + function handleClick(e) { + if (panelRef.current && !panelRef.current.contains(e.target)) { + setOpen(false) + } + } + document.addEventListener('pointerdown', handleClick) + return () => document.removeEventListener('pointerdown', handleClick) + }, [open]) + + const showHillshade = hasFeature('has_hillshade') + const showTraffic = hasFeature('has_traffic_overlay') + const showPublicLands = hasFeature('has_public_lands_layer') + const showContours = hasFeature('has_contours') + const showContoursTest = hasFeature('has_contours_test') + const showContoursTest10ft = hasFeature('has_contours_test_10ft') const showUsfsTrails = hasFeature('has_usfs_trails') - const showBlmTrails = hasFeature('has_blm_trails') - - // Don't render if no overlay features available - if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails && !showBlmTrails) return null - - return ( -
- - - {open && ( -
-
Layers
- - {showHillshade && ( - - )} - - {showTraffic && ( - - )} - - {showPublicLands && ( - - )} - - {showContours && ( - - )} - - {showContoursTest && ( - - )} - - {showContoursTest10ft && ( - - )} - - {showUsfsTrails && ( - - )} + const showBlmTrails = hasFeature('has_blm_trails') + + // Don't render if no overlay features available + if (!showHillshade && !showTraffic && !showPublicLands && !showContours && !showContoursTest && !showContoursTest10ft && !showUsfsTrails && !showBlmTrails) return null + + return ( +
+ + + {open && ( +
+ {/* View mode segmented control */} +
+ + + +
+ +
Layers
+ + {showHillshade && ( + + )} + + {showTraffic && ( + + )} + + {showPublicLands && ( + + )} + + {showContours && ( + + )} + + {showContoursTest && ( + + )} + + {showContoursTest10ft && ( + + )} + + {showUsfsTrails && ( + + )} {showBlmTrails && ( )} -
- )} -
- ) -} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 37ec829..47454ee 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -65,6 +65,8 @@ const BLM_ROUTES_SNOW = 'blm-routes-snow' const BLM_ROUTES_OTHER = 'blm-routes-other' const BLM_ROUTES_LABEL = 'blm-routes-label' const BLM_ROUTES_HIT = 'blm-routes-hit' +const SATELLITE_SOURCE = 'satellite-source' +const SATELLITE_LAYER = 'satellite-layer' // Highlight state - use data-driven expressions to target specific features @@ -1251,6 +1253,154 @@ 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 +let hiddenVectorLayers = [] + +/** Hide vector fill layers for satellite mode */ +function hideVectorFills(map) { + if (!map) return + hiddenVectorLayers = [] + + const style = map.getStyle() + if (!style || !style.layers) return + + for (const layer of style.layers) { + // Hide fill layers (land, water, parks, buildings, etc.) + // But keep line, symbol, and circle layers + if (layer.type === 'fill' || layer.type === 'fill-extrusion') { + // Don't hide our own overlay fills (public lands, etc) + if (layer.id.startsWith('public-lands') || + layer.id.startsWith('boundary') || + layer.id.startsWith('route')) continue + + const visibility = map.getLayoutProperty(layer.id, 'visibility') + if (visibility !== 'none') { + hiddenVectorLayers.push(layer.id) + map.setLayoutProperty(layer.id, 'visibility', 'none') + } + } + } +} + +/** Show all hidden vector layers */ +function showVectorFills(map) { + if (!map) return + + for (const layerId of hiddenVectorLayers) { + if (map.getLayer(layerId)) { + map.setLayoutProperty(layerId, 'visibility', 'visible') + } + } + hiddenVectorLayers = [] +} + +/** Set map to satellite-only mode */ +function setSatelliteMode(map, themeId) { + if (!map) return + addSatelliteLayer(map, themeId) + hideVectorFills(map) + // Also hide line layers in pure satellite mode (keep only labels for reference) + const style = map.getStyle() + if (style && style.layers) { + for (const layer of style.layers) { + if (layer.type === 'line' && !layer.id.startsWith('route') && + !layer.id.startsWith('boundary') && !layer.id.startsWith('measure')) { + const visibility = map.getLayoutProperty(layer.id, 'visibility') + if (visibility !== 'none') { + hiddenVectorLayers.push(layer.id) + map.setLayoutProperty(layer.id, 'visibility', 'none') + } + } + } + } +} + +/** Set map to hybrid mode (satellite + labels/roads) */ +function setHybridMode(map, themeId) { + if (!map) return + addSatelliteLayer(map, themeId) + hideVectorFills(map) + // In hybrid mode, keep road lines and labels visible + // They're already visible by default, just fills are hidden +} + +/** Set map back to normal map mode */ +function setMapMode(map) { + if (!map) return + removeSatelliteLayer(map) + showVectorFills(map) +} + + /** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */ const BOUNDARY_FILL_LAYER = 'boundary-fill-layer' @@ -1780,6 +1930,26 @@ 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) + }, + })) // Initialize map @@ -2122,6 +2292,17 @@ const MapView = forwardRef(function MapView(_, ref) { }) map.on('load', () => { + // Add satellite source (persists across view modes) + addSatelliteSource(map) + + // Restore view mode from localStorage + const savedViewMode = localStorage.getItem('navi-view-mode') || 'map' + if (savedViewMode === 'satellite') { + setSatelliteMode(map, currentThemeRef.current) + } else if (savedViewMode === 'hybrid') { + setHybridMode(map, currentThemeRef.current) + } + // Guard against double-mount in React strict mode if (!map.getSource(ROUTE_SOURCE)) { map.addSource(ROUTE_SOURCE, { @@ -2357,6 +2538,15 @@ const MapView = forwardRef(function MapView(_, ref) { 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 = {} diff --git a/src/index.css b/src/index.css index 9e241c0..2673a26 100644 --- a/src/index.css +++ b/src/index.css @@ -314,6 +314,39 @@ body { 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); diff --git a/src/store.js b/src/store.js index 9b4aa30..bc36648 100644 --- a/src/store.js +++ b/src/store.js @@ -100,8 +100,13 @@ export const useStore = create((set, get) => ({ autocompleteOpen: false, theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) + viewMode: 'map', // 'map' | 'satellite' | 'hybrid' setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem('navi-view-mode', mode) + }, setPanelOpen: (open) => set({ panelOpen: open }), setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), setTheme: (theme) => set({ theme }), From 4c1921d116fefff2823d6a16582de5a851392144 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 02:08:59 +0000 Subject: [PATCH 20/56] fix: Hide background layer in satellite mode The background layer type was not being hidden, causing it to cover the satellite imagery. Now hideVectorFills hides fill, fill-extrusion, AND background layer types. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 47454ee..00546e7 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1334,9 +1334,9 @@ function hideVectorFills(map) { if (!style || !style.layers) return for (const layer of style.layers) { - // Hide fill layers (land, water, parks, buildings, etc.) + // Hide fill and background layers (land, water, parks, buildings, etc.) // But keep line, symbol, and circle layers - if (layer.type === 'fill' || layer.type === 'fill-extrusion') { + if (layer.type === 'fill' || layer.type === 'fill-extrusion' || layer.type === 'background') { // Don't hide our own overlay fills (public lands, etc) if (layer.id.startsWith('public-lands') || layer.id.startsWith('boundary') || From 66f91fd3791bb67311e7119e9f2463e906c84d65 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 02:19:28 +0000 Subject: [PATCH 21/56] fix: Properly restore layers when switching view modes Three bugs fixed: 1. Map mode now restores all hidden layers - using separate tracking arrays for fills, lines, and symbols that persist across mode switches 2. Satellite mode now hides ALL vector layers (fills, lines, symbols) for true satellite-only view 3. Hybrid mode keeps lines and symbols visible for road/label overlay Each mode switch first restores all layers to a clean slate before hiding the appropriate ones for that mode. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 132 +++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 50 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 00546e7..ca33bf3 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1323,81 +1323,113 @@ function updateSatellitePaint(map, themeId) { } // Track which vector layers are hidden in satellite/hybrid mode -let hiddenVectorLayers = [] +// Track hidden layers for each mode - separate arrays for proper restoration +let hiddenFillLayers = [] +let hiddenLineLayers = [] +let hiddenSymbolLayers = [] -/** Hide vector fill layers for satellite mode */ -function hideVectorFills(map) { - if (!map) return - hiddenVectorLayers = [] - - const style = map.getStyle() - if (!style || !style.layers) return - - for (const layer of style.layers) { - // Hide fill and background layers (land, water, parks, buildings, etc.) - // But keep line, symbol, and circle layers - if (layer.type === 'fill' || layer.type === 'fill-extrusion' || layer.type === 'background') { - // Don't hide our own overlay fills (public lands, etc) - if (layer.id.startsWith('public-lands') || - layer.id.startsWith('boundary') || - layer.id.startsWith('route')) continue - - const visibility = map.getLayoutProperty(layer.id, 'visibility') - if (visibility !== 'none') { - hiddenVectorLayers.push(layer.id) - map.setLayoutProperty(layer.id, 'visibility', 'none') - } - } +// Layers we never hide (our own overlays) +function isProtectedLayer(id) { + return id.startsWith('public-lands') || + id.startsWith('boundary') || + id.startsWith('route') || + 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 hidden vector layers */ -function showVectorFills(map) { - if (!map) return - - for (const layerId of hiddenVectorLayers) { - if (map.getLayer(layerId)) { - map.setLayoutProperty(layerId, 'visibility', 'visible') +/** 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') } } - hiddenVectorLayers = [] + trackingArray.length = 0 } -/** Set map to satellite-only mode */ +/** 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) - hideVectorFills(map) - // Also hide line layers in pure satellite mode (keep only labels for reference) + const style = map.getStyle() - if (style && style.layers) { - for (const layer of style.layers) { - if (layer.type === 'line' && !layer.id.startsWith('route') && - !layer.id.startsWith('boundary') && !layer.id.startsWith('measure')) { - const visibility = map.getLayoutProperty(layer.id, 'visibility') - if (visibility !== 'none') { - hiddenVectorLayers.push(layer.id) - map.setLayoutProperty(layer.id, 'visibility', 'none') - } - } + 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 + labels/roads) */ +/** 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) - hideVectorFills(map) - // In hybrid mode, keep road lines and labels visible - // They're already visible by default, just fills are hidden + + 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) - showVectorFills(map) + + // Restore all hidden layers + showLayers(map, hiddenFillLayers) + showLayers(map, hiddenLineLayers) + showLayers(map, hiddenSymbolLayers) + + console.log('[Map] Restored all vector layers') } From a07b33db6d0c73ad5382c71ede26ec4e0e5b5980 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 03:24:12 +0000 Subject: [PATCH 22/56] feat: switch to global planet PMTiles for worldwide vector coverage Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index ca33bf3..a8f5337 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -269,7 +269,7 @@ function applyBaseLabelStyling(map) { /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() - const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles' + const tileUrl = config?.tileset?.url || '/tiles/planet-20260420.pmtiles' const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM' // Use namedTheme directly for built-in themes, custom colors for others From 88fcb48eaa808a010d58e4e32029a8f11fb83447 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 03:24:32 +0000 Subject: [PATCH 23/56] Revert "feat: switch to global planet PMTiles for worldwide vector coverage" This reverts commit a07b33db6d0c73ad5382c71ede26ec4e0e5b5980. --- src/components/MapView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index a8f5337..ca33bf3 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -269,7 +269,7 @@ function applyBaseLabelStyling(map) { /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() - const tileUrl = config?.tileset?.url || '/tiles/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 From 6245ecc206d6590cb6cabd30fc8121b763a57375 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 03:33:15 +0000 Subject: [PATCH 24/56] feat: switch to global planet PMTiles for worldwide vector coverage Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index ca33bf3..a279c9d 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -269,7 +269,7 @@ function applyBaseLabelStyling(map) { /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() - const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles' + const tileUrl = config?.tileset?.url || '/tiles/planet/planet-20260420.pmtiles' const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM' // Use namedTheme directly for built-in themes, custom colors for others From 1bccfad22ba94c23120ad86f91711fd96f7acf98 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 04:49:51 +0000 Subject: [PATCH 25/56] fix: Skip reverse geocode for basemap label clicks When clicking a basemap label (city, town, POI), we already know the entity from the label properties (name, kind, wikidata). Running fetchReverse at those coordinates would return the nearest POI which could be a different entity (e.g., clicking Portland returns Stumptown Coffee), corrupting the place identity. Now skips reverse geocode when source=basemap_label and raw.kind exists. The wikidata lookup path still handles fetching boundaries and OSM data. Co-Authored-By: Claude Opus 4.5 --- src/components/PlaceCard.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 40c4660..c1782a7 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -352,6 +352,9 @@ 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) => { From 3158537488c84fcbdfe070cae7cdd6b857a4ca75 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 05:01:02 +0000 Subject: [PATCH 26/56] fix: Correct MultiPolygon coordinate flattening for fitBounds MultiPolygon coordinates need .flat(2) not .flat(1) to get actual coordinate pairs. With flat(1), we were iterating over rings instead of coordinates, causing invalid lat values > 90. Also added bounds validation before fitBounds to catch future issues. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index a279c9d..5d646f5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2401,7 +2401,7 @@ const MapView = forwardRef(function MapView(_, ref) { try { const coords = boundaryGeometry.type === 'Polygon' ? boundaryGeometry.coordinates[0] - : boundaryGeometry.coordinates.flat(1) + : boundaryGeometry.coordinates.flat(2) if (coords.length > 0) { let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity @@ -2411,11 +2411,17 @@ const MapView = forwardRef(function MapView(_, ref) { if (lat < minLat) minLat = lat if (lat > maxLat) maxLat = lat } - map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { - padding: 50, - duration: 700, - maxZoom: 16, - }) + // Validate bounds before fitting + if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && + minLng < maxLng && minLat < maxLat) { + map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + padding: 50, + duration: 700, + maxZoom: 16, + }) + } else { + console.warn('Invalid bounds:', { minLng, maxLng, minLat, maxLat }) + } } } catch (e) { console.warn('fitBounds error:', e) From cd080b42f35e69c406e1e7ebf4ad6a2e335b15ab Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 05:04:34 +0000 Subject: [PATCH 27/56] fix: Set osm_type/osm_id from wikidata response for wiki summaries When clicking basemap labels, reverse geocode is now skipped to avoid entity corruption. The wikidata effect needs to set osm_type/osm_id from the osm_relation_id in the response to trigger Effect 3 which fetches the wiki summary from /api/place/R/{id}. Co-Authored-By: Claude Opus 4.5 --- src/components/PlaceCard.jsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index c1782a7..3afa72b 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -408,6 +408,16 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl osm_relation_id: data.osm_relation_id, extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags }, })) + // Set osm_type/osm_id from osm_relation_id to trigger Effect 3 (wiki summary fetch) + if (data?.osm_relation_id) { + const current = useStore.getState().selectedPlace + if (current && current.lat === placeLat && current.lon === placeLon) { + useStore.getState().setSelectedPlace({ + ...current, + raw: { ...current.raw, osm_type: 'R', osm_id: data.osm_relation_id } + }) + } + } if (data?.boundary) { const current = useStore.getState().selectedPlace if (current && current.lat === placeLat && current.lon === placeLon) { From 869391ee4e9b19f464b3f9d31ecab6ab960f72d2 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 16:57:32 +0000 Subject: [PATCH 28/56] fix(ux): Three UX improvements for feature selection Fix 1: Never zoom out when clicking a feature - preserves user's intentional zoom level by checking cameraForBounds before fitBounds Fix 2: Single-click to switch between features - clicking outside the current feature's circle clears selection and selects new feature Fix 3: View mode toggle reflects saved state on load - initialize viewMode from localStorage on store creation Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 22 ++++++++++++++++++---- src/store.js | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 5d646f5..2e77670 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2097,15 +2097,21 @@ const MapView = forwardRef(function MapView(_, ref) { }) } } else { - // Outside circle → deselect, no new selection + // Outside circle → clear current selection and fall through to select new store.clearClickMarker() store.clearSelectedPlace() // Clear boundary when deselecting if (updateBoundaryRef.current) updateBoundaryRef.current(null) setSelectedHighlight(map, null) + // Fall through to State A to select new feature at click point } - } else { - // State A: nothing selected → select + } + + // Select new feature at click point (State A or after clearing previous selection) + { + const store = useStore.getState() // refresh store state after potential clear + if (store.clickMarker) return // already handled above + if (window.innerWidth < 768) setSheetState('collapsed') const { lng, lat } = e.lngLat @@ -2414,7 +2420,15 @@ const MapView = forwardRef(function MapView(_, ref) { // Validate bounds before fitting if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && minLng < maxLng && minLat < maxLat) { - map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + const bounds = [[minLng, minLat], [maxLng, maxLat]] + const currentZoom = map.getZoom() + const target = map.cameraForBounds(bounds, { padding: 50 }) + // NEVER zoom out - user's zoom level is intentional + if (target && target.zoom < currentZoom) { + // Would zoom out — just draw the boundary without moving camera + return + } + map.fitBounds(bounds, { padding: 50, duration: 700, maxZoom: 16, diff --git a/src/store.js b/src/store.js index bc36648..6b7f30d 100644 --- a/src/store.js +++ b/src/store.js @@ -100,7 +100,7 @@ export const useStore = create((set, get) => ({ autocompleteOpen: false, theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) - viewMode: 'map', // 'map' | 'satellite' | 'hybrid' + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' setSheetState: (s) => set({ sheetState: s }), setViewMode: (mode) => { From bd372b9dc9728d5289b0e7db983a43cda8b4f97f Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 18:07:21 +0000 Subject: [PATCH 29/56] fix(map): Prevent camera zoom-out when clicking features - Track place identity with lastFlyTargetRef to avoid re-flying on metadata updates (boundary, wikidata, etc.) - Only flyTo on NEW place selection, not subsequent store updates - Apply z14 threshold to all camera movements: - flyTo for search results: only if currentZoom < 14 - fitBounds for boundaries: only if currentZoom < 14 - At z14+ camera stays put, boundary draws silently Fixes zoom-out bug where clicking a feature at high zoom would zoom back out to z14. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 45 ++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 2e77670..2dbed45 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1497,6 +1497,7 @@ const MapView = forwardRef(function MapView(_, ref) { 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 @@ -2420,21 +2421,16 @@ const MapView = forwardRef(function MapView(_, ref) { // Validate bounds before fitting if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && minLng < maxLng && minLat < maxLat) { - const bounds = [[minLng, minLat], [maxLng, maxLat]] + // Only fit bounds if zoomed out (< z14). At z14+ just draw boundary silently. const currentZoom = map.getZoom() - const target = map.cameraForBounds(bounds, { padding: 50 }) - // NEVER zoom out - user's zoom level is intentional - if (target && target.zoom < currentZoom) { - // Would zoom out — just draw the boundary without moving camera - return + if (currentZoom < 14) { + const bounds = [[minLng, minLat], [maxLng, maxLat]] + map.fitBounds(bounds, { + padding: 50, + duration: 700, + maxZoom: 16, + }) } - map.fitBounds(bounds, { - padding: 50, - duration: 700, - maxZoom: 16, - }) - } else { - console.warn('Invalid bounds:', { minLng, maxLng, minLat, maxLat }) } } } catch (e) { @@ -2622,11 +2618,26 @@ const MapView = forwardRef(function MapView(_, ref) { previewMarkerRef.current = null } - if (!selectedPlace) return + if (!selectedPlace) { + lastFlyTargetRef.current = null + return + } - // Only fly to place if it came from search (not map-click which already centered) - if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { - map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) + // Track place identity - only fly on NEW place selection, not metadata updates + const placeKey = `${selectedPlace.lat}-${selectedPlace.lon}-${selectedPlace.name}` + if (placeKey === lastFlyTargetRef.current) { + // Same place, skip flyTo (this is just a metadata update) + } else { + lastFlyTargetRef.current = placeKey + + // Only fly to place if it came from search (not map-click which already centered) + if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { + // Only fly IN if below z14. At z14+ do nothing. + const currentZoom = map.getZoom() + if (currentZoom < 14) { + map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) + } + } } // Different visual feedback based on mode From 62669fc7de5ede5a325d12e449bfb259b2df594c Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 18:36:07 +0000 Subject: [PATCH 30/56] fix(map): Always zoom in to fit boundaries, never zoom out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace blanket z14 cap with zoom-in-only logic for boundary fitBounds: - target.zoom > currentZoom → zoom IN to fit boundary (always allowed) - target.zoom < currentZoom → skip fitBounds (never zoom out) - target.zoom == currentZoom → allow pan to center This means: - Click Portland at z5 → fly in to ~z10 to show city boundary - Click Idaho at z5 → fly in to ~z6 to show state boundary - Click Portland at z15 → boundary draws, camera stays at z15 The z14 cap for selectedPlace flyTo (search results without boundary) is preserved - that only affects the initial flyTo to coordinates. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 2dbed45..bf61605 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2421,16 +2421,24 @@ const MapView = forwardRef(function MapView(_, ref) { // Validate bounds before fitting if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && minLng < maxLng && minLat < maxLat) { - // Only fit bounds if zoomed out (< z14). At z14+ just draw boundary silently. + const bounds = [[minLng, minLat], [maxLng, maxLat]] const currentZoom = map.getZoom() - if (currentZoom < 14) { - const bounds = [[minLng, minLat], [maxLng, maxLat]] - map.fitBounds(bounds, { - padding: 50, - duration: 700, - maxZoom: 16, - }) + const target = map.cameraForBounds(bounds, { padding: 50 }) + + // Zoom-in only: allow zoom in to show boundary, never zoom out + // - target.zoom > currentZoom → zoom IN to fit (always allowed) + // - target.zoom < currentZoom → DON'T zoom out (skip fitBounds) + // - target.zoom == currentZoom → pan only (allowed) + if (!target || target.zoom < currentZoom) { + // Would zoom out — just draw the boundary without moving camera + return } + + map.fitBounds(bounds, { + padding: 50, + duration: 700, + maxZoom: 16, + }) } } } catch (e) { From 1ad43e58cfaa10bb46df2d8c0434fc6ecec19698 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 18:45:41 +0000 Subject: [PATCH 31/56] fix(map): Unify label/polygon click paths and fix boundary fitBounds Fix A: Label click now also queries polygon layers - When clicking park/forest/cemetery labels, also query landuse_park fill layer to get polygon geometry - If polygon found, use it as boundary directly from rendered tiles - Eliminates need for API round-trip for park boundaries Fix B: Boundary fitBounds behavior changed - If boundary EXISTS: ALWAYS fitBounds (zoom in OR out) to show the full boundary - the boundary defines what user should see - If NO boundary: NEVER change zoom for map clicks/label clicks - Search results: fly to center but preserve current zoom level Removed previous z14 cap and zoom-in-only logic - boundaries now always control the camera as expected. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 52 +++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index bf61605..6fb76ca 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2233,6 +2233,25 @@ const MapView = forwardRef(function MapView(_, ref) { featureLat = geom.coordinates[1] } + // FIX A: For park-type features, also query polygon layers to get boundary geometry + const parkKinds = ['national_park', 'park', 'cemetery', 'protected_area', 'nature_reserve', 'forest', 'golf_course', 'wood', 'zoo', 'garden'] + let polygonGeometry = null + if (parkKinds.includes(props.kind)) { + // Query fill layers at the same point to find the polygon + const fillLayers = ['landuse_park', 'landuse_other'].filter(id => map.getLayer(id)) + if (fillLayers.length > 0) { + const fillFeatures = map.queryRenderedFeatures(e.point, { layers: fillLayers }) + // Find a polygon feature with matching name or at the same location + const matchingPolygon = fillFeatures.find(f => + f.properties?.name === props.name || + (f.geometry?.type === 'Polygon' || f.geometry?.type === 'MultiPolygon') + ) + if (matchingPolygon?.geometry) { + polygonGeometry = matchingPolygon.geometry + } + } + } + // Apply feature state highlight const featureId = labelFeature.id ?? props.mvt_id const sourceLayer = labelFeature.sourceLayer @@ -2251,6 +2270,11 @@ 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) + } + store.setSelectedPlace({ lat: featureLat, lon: featureLon, @@ -2269,6 +2293,7 @@ const MapView = forwardRef(function MapView(_, ref) { kind: props.kind || null, kind_detail: props.kind_detail || null, elevation: props.elevation || null, + polygonGeometry: polygonGeometry || null, // Store polygon if found }, }) } else { @@ -2421,19 +2446,9 @@ const MapView = forwardRef(function MapView(_, ref) { // 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]] - const currentZoom = map.getZoom() - const target = map.cameraForBounds(bounds, { padding: 50 }) - - // Zoom-in only: allow zoom in to show boundary, never zoom out - // - target.zoom > currentZoom → zoom IN to fit (always allowed) - // - target.zoom < currentZoom → DON'T zoom out (skip fitBounds) - // - target.zoom == currentZoom → pan only (allowed) - if (!target || target.zoom < currentZoom) { - // Would zoom out — just draw the boundary without moving camera - return - } - map.fitBounds(bounds, { padding: 50, duration: 700, @@ -2638,14 +2653,17 @@ const MapView = forwardRef(function MapView(_, ref) { } else { lastFlyTargetRef.current = placeKey - // Only fly to place if it came from search (not map-click which already centered) + // 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') { - // Only fly IN if below z14. At z14+ do nothing. + // 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() - if (currentZoom < 14) { - map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) - } + map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: currentZoom, duration: 800 }) } + // For map_click and basemap_label: do nothing to camera + // The boundary fitBounds will handle zooming if a boundary is fetched } // Different visual feedback based on mode From c14edb0e539bdef3edaa0a0453b095d1d3c9cf77 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 19:06:33 +0000 Subject: [PATCH 32/56] feat(map): Add state/province boundary lines at z4-z7 Fix D: State and province administrative boundaries are now visible at low zoom levels (z4-z7) with theme-aware styling. - Added STATE_BOUNDARIES_LAYER constant - Added addStateBoundaries() function that creates a line layer filtering on kind_detail = 4 (state/province level) - Uses dashed line style with opacity interpolation - Layer uses theme boundaries color for consistency - Layer is re-added on theme change to update colors The layer renders below labels and provides subtle but visible state/province boundaries when zoomed out viewing country-level maps. Line width and opacity increase as you zoom in from z4 to z7. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 64 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 6fb76ca..e5a90f1 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -23,6 +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 HILLSHADE_SOURCE = 'hillshade-dem' const HILLSHADE_LAYER = 'hillshade-layer' @@ -1481,6 +1482,63 @@ function addBoundaryLayer(map) { }, firstSymbolId) } +/** + * FIX D: Add state/province boundary lines visible at z4-z7 + * These are administrative boundaries with kind_detail = 4 (state/province level) + * Uses theme-aware styling from the boundaries color + */ +function addStateBoundaries(map, themeId) { + if (!map || map.getLayer(STATE_BOUNDARIES_LAYER)) return + + // Get the boundaries color from the current theme + const theme = getTheme(themeId) + const boundaryColor = theme?.colors?.boundaries || '#808080' + + // Find first symbol layer to insert below labels + const layers = map.getStyle().layers + let firstSymbolId = null + for (const layer of layers) { + if (layer.type === 'symbol') { + firstSymbolId = layer.id + break + } + } + + // Add state/province boundaries layer for z4-z7 + // kind_detail 4 = state/province level administrative boundaries + map.addLayer({ + id: STATE_BOUNDARIES_LAYER, + type: 'line', + source: 'protomaps', + 'source-layer': 'boundaries', + filter: ['==', 'kind_detail', 4], + minzoom: 4, + maxzoom: 8, + paint: { + 'line-color': boundaryColor, + 'line-width': [ + 'interpolate', ['linear'], ['zoom'], + 4, 0.5, + 7, 1.0 + ], + 'line-opacity': [ + 'interpolate', ['linear'], ['zoom'], + 4, 0.4, + 7, 0.6 + ], + 'line-dasharray': [4, 2], + }, + }, firstSymbolId) +} + +/** Remove state boundaries layer */ +function removeStateBoundaries(map) { + if (!map) return + if (map.getLayer(STATE_BOUNDARIES_LAYER)) { + map.removeLayer(STATE_BOUNDARIES_LAYER) + } +} + const MapView = forwardRef(function MapView(_, ref) { const mapRef = useRef(null) const mapInstance = useRef(null) @@ -2383,6 +2441,9 @@ 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') @@ -2599,6 +2660,9 @@ 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) From 83e8ffeb2d522c564b18fa7841446f96a910dcbe Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 19:10:45 +0000 Subject: [PATCH 33/56] fix(map): Show state/province labels at lower zoom levels Adjust label zoom ranges after style load for proper hierarchy: - Countries (places_country): visible from z2+ - States/provinces (places_region): visible from z3+ - Cities follow their natural min_zoom in the tile data This ensures states like Idaho and Oregon appear before cities like Boise and Portland when zoomed out. The setLayerZoomRange calls are made in applyBaseLabelStyling() which runs after style load and theme changes. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index e5a90f1..1226044 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -265,6 +265,21 @@ function applyBaseLabelStyling(map) { 'text-halo-width': 1.8, } }) + + // Adjust label zoom ranges for proper hierarchy: + // - Countries at z2+ + // - States/provinces at z3+ + // - Cities follow their natural min_zoom in the data + try { + if (map.getLayer('places_country')) { + map.setLayerZoomRange('places_country', 2, 24) + } + if (map.getLayer('places_region')) { + map.setLayerZoomRange('places_region', 3, 24) + } + } catch (e) { + // Ignore if layers don't exist + } } /** Build a full MapLibre style object for the given theme */ From 5fcd6cda9a9b8b8de04c38e0186dbe03cf22b7f4 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 19:17:56 +0000 Subject: [PATCH 34/56] fix(map): Set exact label zoom ranges for hierarchy places_country: z1-z4 (countries visible at world view, fade at z4) places_region: z4-z7 (states appear at z4, fade as cities dominate) places_locality: unchanged (follows natural min_zoom in tiles) Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 1226044..fd1cba2 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -267,15 +267,15 @@ function applyBaseLabelStyling(map) { }) // Adjust label zoom ranges for proper hierarchy: - // - Countries at z2+ - // - States/provinces at z3+ - // - Cities follow their natural min_zoom in the data + // - 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', 2, 24) + map.setLayerZoomRange('places_country', 1, 4) } if (map.getLayer('places_region')) { - map.setLayerZoomRange('places_region', 3, 24) + map.setLayerZoomRange('places_region', 4, 7) } } catch (e) { // Ignore if layers don't exist From bb164965ef73d2350d8b06535bdc6d6d76575de0 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 19:36:58 +0000 Subject: [PATCH 35/56] fix(map): Fix state labels by using coalesce fallback for text-field The protomaps theme generates text-field expressions using name:short, but the PMTiles data doesn't have that property. States have 'ref' (e.g. 'CA', 'ON') and 'name' (e.g. 'California', 'Ontario'). - Use coalesce expression: name:short -> ref -> name - Expand zoom ranges slightly: country z1-5, region z4-8 - Verified fix in built JS before deployment Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index fd1cba2..23df9cb 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -272,10 +272,18 @@ function applyBaseLabelStyling(map) { // - Cities: unchanged (natural min_zoom in tile data) try { if (map.getLayer('places_country')) { - map.setLayerZoomRange('places_country', 1, 4) + map.setLayerZoomRange('places_country', 1, 5) } if (map.getLayer('places_region')) { - map.setLayerZoomRange('places_region', 4, 7) + 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:short'], + ['get', 'ref'], + ['get', 'name'] + ]) } } catch (e) { // Ignore if layers don't exist From 238fc68546dd57f77f710574922a270baaa1178f Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 19:42:46 +0000 Subject: [PATCH 36/56] fix(map): Prefer full state names over abbreviations Coalesce order: name -> ref -> name:short Shows California not CA Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 23df9cb..93ed2cb 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -280,9 +280,9 @@ function applyBaseLabelStyling(map) { // Use coalesce to fall back to ref (e.g., "CA") then name (e.g., "California") map.setLayoutProperty('places_region', 'text-field', [ 'coalesce', - ['get', 'name:short'], + ['get', 'name'], ['get', 'ref'], - ['get', 'name'] + ['get', 'name:short'] ]) } } catch (e) { From f3ec18bdf538842956dcf90000d54e14621f95b6 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 02:54:25 +0000 Subject: [PATCH 37/56] =?UTF-8?q?wip:=20contour=20overlay=20=E2=80=94=20br?= =?UTF-8?q?oken,=20needs=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 7 +++ package.json | 1 + src/components/MapView.jsx | 124 ++++++++++++++----------------------- 3 files changed, 53 insertions(+), 79 deletions(-) diff --git a/package-lock.json b/package-lock.json index 682949c..1fb2d33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "lucide-react": "^1.8.0", + "maplibre-contour": "^0.1.0", "maplibre-gl": "^5.23.0", "opening_hours": "^3.12.0", "pmtiles": "^4.4.1", @@ -2685,6 +2686,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-contour": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/maplibre-contour/-/maplibre-contour-0.1.0.tgz", + "integrity": "sha512-H8muT7JWYE4oLbFv7L2RSbIM1NOu5JxjA9P/TQqhODDnRChE8ENoDkQIWOKgfcKNU77ypLk2ggGoh4/pt4UPLA==", + "license": "BSD-3-Clause" + }, "node_modules/maplibre-gl": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.23.0.tgz", diff --git a/package.json b/package.json index 9faab00..b879244 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "lucide-react": "^1.8.0", + "maplibre-contour": "^0.1.0", "maplibre-gl": "^5.23.0", "opening_hours": "^3.12.0", "pmtiles": "^4.4.1", diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 93ed2cb..3599c5b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -12,7 +12,9 @@ import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' +import mlcontour from 'maplibre-contour' +let demSourceInstance = null /** Check if current theme is dark based on registry */ function isCurrentThemeDark() { @@ -33,11 +35,9 @@ const PUBLIC_LANDS_SOURCE = 'public-lands-tiles' const PUBLIC_LANDS_FILL = 'public-lands-fill' const PUBLIC_LANDS_LINE = 'public-lands-line' const PUBLIC_LANDS_LABEL = 'public-lands-label' -const CONTOUR_SOURCE = 'contour-tiles' -const CONTOUR_MINOR = 'contour-minor' -const CONTOUR_INTERMEDIATE = 'contour-intermediate' -const CONTOUR_INDEX = 'contour-index' -const CONTOUR_LABEL = 'contour-label' +const CONTOUR_SOURCE = 'contour-source' +const CONTOUR_LINE = 'contour-lines' +const CONTOUR_LABEL = 'contour-labels' const CONTOUR_TEST_SOURCE = 'contour-test-tiles' const CONTOUR_TEST_MINOR = 'contour-test-minor' const CONTOUR_TEST_INTERMEDIATE = 'contour-test-intermediate' @@ -563,105 +563,60 @@ function removePublicLands(map) { if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE) } -/** Add topographic contour vector tile overlay */ -function addContours(map, themeId) { +/** Add topographic contours via maplibre-contour */ +function addContours(map) { + console.log('[CONTOUR] addContours called, source exists:', !!map?.getSource(CONTOUR_SOURCE)) if (!map || map.getSource(CONTOUR_SOURCE)) return - - const c = getOverlayConfig(themeId, 'contours') - map.addSource(CONTOUR_SOURCE, { type: 'vector', - url: 'pmtiles:///tiles/contours-na.pmtiles', + tiles: [demSourceInstance.contourProtocolUrl({ + multiplier: 3.28084, + thresholds: { 11: [200, 1000], 12: [100, 500], 13: [100, 500], 14: [50, 200] }, + })], + maxzoom: 15, }) - - // Insert below first symbol layer (above hillshade, below labels) + console.log('[CONTOUR] protocol URL:', demSourceInstance.contourProtocolUrl({ + multiplier: 3.28084, + thresholds: { 11: [200, 1000], 12: [100, 500], 13: [100, 500], 14: [50, 200] }, + })) + console.log('[CONTOUR] source added:', !!map.getSource(CONTOUR_SOURCE)) let beforeId = undefined for (const layer of map.getStyle().layers) { - if (layer.type === 'symbol') { - beforeId = layer.id - break - } + if (layer.type === 'symbol') { beforeId = layer.id; break } } - - // Minor contours (40ft) — visible z11+ + const isDark = document.documentElement.getAttribute('data-theme') === 'dark' map.addLayer({ - id: CONTOUR_MINOR, - type: 'line', - source: CONTOUR_SOURCE, + id: CONTOUR_LINE, type: 'line', source: CONTOUR_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], + 'line-color': isDark ? '#c0b898' : '#8b6f47', + 'line-opacity': 0.7, + 'line-width': ['match', ['get', 'level'], 1, 1.5, 0.5], }, }, beforeId) - - // Intermediate contours (200ft) — visible z8+ map.addLayer({ - id: CONTOUR_INTERMEDIATE, - type: 'line', - source: CONTOUR_SOURCE, + id: CONTOUR_LABEL, type: 'symbol', 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'], + filter: ['>', ['get', 'level'], 0], layout: { - 'text-field': ['concat', ['to-string', ['get', 'elevation_ft']], "'"], - 'text-size': c.labelSize, - 'text-font': c.labelFont, - 'symbol-placement': 'line', - 'text-anchor': 'center', - 'symbol-spacing': 400, - 'text-max-angle': 30, - 'text-allow-overlap': false, + 'symbol-placement': 'line', 'text-size': 10, + 'text-field': ['concat', ['number-format', ['get', 'ele'], {}], "'"], + 'text-font': ['Noto Sans Regular'], }, paint: { - 'text-color': c.labelColor, - 'text-halo-color': c.labelHaloColor, - 'text-halo-width': c.labelHaloWidth, - 'text-opacity': c.labelOpacity, + 'text-color': isDark ? '#c0b898' : '#5a4020', + 'text-halo-color': isDark ? '#1a1a1a' : '#ffffff', + 'text-halo-width': 1.5, }, }) + console.log('[CONTOUR] layers added:', !!map.getLayer(CONTOUR_LINE), !!map.getLayer(CONTOUR_LABEL)) } /** Remove contour layers + source */ function removeContours(map) { if (!map) return if (map.getLayer(CONTOUR_LABEL)) map.removeLayer(CONTOUR_LABEL) - if (map.getLayer(CONTOUR_INDEX)) map.removeLayer(CONTOUR_INDEX) - if (map.getLayer(CONTOUR_INTERMEDIATE)) map.removeLayer(CONTOUR_INTERMEDIATE) - if (map.getLayer(CONTOUR_MINOR)) map.removeLayer(CONTOUR_MINOR) + if (map.getLayer(CONTOUR_LINE)) map.removeLayer(CONTOUR_LINE) if (map.getSource(CONTOUR_SOURCE)) map.removeSource(CONTOUR_SOURCE) } @@ -2071,6 +2026,17 @@ const MapView = forwardRef(function MapView(_, ref) { const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) + // Initialize DemSource for maplibre-contour + if (!demSourceInstance) { + demSourceInstance = new mlcontour.DemSource({ + url: `${window.location.origin}/tiles/terrain/{z}/{x}/{y}`, + encoding: 'terrarium', + maxzoom: 14, + worker: true, + }) + demSourceInstance.setupMaplibre(maplibregl) + } + const config = getConfig() const DEFAULT_CENTER = config?.defaults?.center ? [config.defaults.center[1], config.defaults.center[0]] // config is [lat,lon], MapLibre wants [lon,lat] From 400c485833e369232de59f912f3e30cf67c46b58 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 03:58:46 +0000 Subject: [PATCH 38/56] fix: contour overlay with pmtiles fork, absolute URL, extended zoom range - Switch to @acalcutt/maplibre-contour-pmtiles for PMTiles support - Use absolute URL for DemSource so Web Worker can resolve path - Extend contour thresholds from z3-z15 for full zoom coverage - Improve line styling with zoom-dependent width - Improve label styling with bold font and better halo Co-Authored-By: Claude --- package-lock.json | 14 ++++---- package.json | 2 +- src/components/MapView.jsx | 73 +++++++++++++++++++++++++------------- 3 files changed, 57 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1fb2d33..3bd1ca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,11 @@ "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", "lucide-react": "^1.8.0", - "maplibre-contour": "^0.1.0", "maplibre-gl": "^5.23.0", "opening_hours": "^3.12.0", "pmtiles": "^4.4.1", @@ -38,6 +38,12 @@ "vite": "^8.0.9" } }, + "node_modules/@acalcutt/maplibre-contour-pmtiles": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@acalcutt/maplibre-contour-pmtiles/-/maplibre-contour-pmtiles-0.1.2.tgz", + "integrity": "sha512-dCyJFLLM4NomLoJ22McRp7yETFmzUuA6iEMVJS6+mFyHoNk7Sv6RI4Hn0DhGKeyjcJgan3YnfSnzsqRinnXSug==", + "license": "BSD-3-Clause" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -2686,12 +2692,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/maplibre-contour": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/maplibre-contour/-/maplibre-contour-0.1.0.tgz", - "integrity": "sha512-H8muT7JWYE4oLbFv7L2RSbIM1NOu5JxjA9P/TQqhODDnRChE8ENoDkQIWOKgfcKNU77ypLk2ggGoh4/pt4UPLA==", - "license": "BSD-3-Clause" - }, "node_modules/maplibre-gl": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.23.0.tgz", diff --git a/package.json b/package.json index b879244..ae0057b 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ "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", "lucide-react": "^1.8.0", - "maplibre-contour": "^0.1.0", "maplibre-gl": "^5.23.0", "opening_hours": "^3.12.0", "pmtiles": "^4.4.1", diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 3599c5b..7ae3146 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -12,7 +12,7 @@ import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' -import mlcontour from 'maplibre-contour' +import mlcontour from '@acalcutt/maplibre-contour-pmtiles' let demSourceInstance = null @@ -565,19 +565,34 @@ function removePublicLands(map) { /** Add topographic contours via maplibre-contour */ function addContours(map) { - console.log('[CONTOUR] addContours called, source exists:', !!map?.getSource(CONTOUR_SOURCE)) - if (!map || map.getSource(CONTOUR_SOURCE)) return + console.log('[CONTOUR] addContours called, source exists:', !!map?.getSource(CONTOUR_SOURCE), 'demSource:', !!demSourceInstance) + if (!map || !demSourceInstance || map.getSource(CONTOUR_SOURCE)) return + const contourThresholds = { + 3: [5000, 25000], + 4: [2500, 10000], + 5: [1000, 5000], + 6: [1000, 5000], + 7: [500, 2500], + 8: [500, 2500], + 9: [250, 1000], + 10: [200, 1000], + 11: [200, 1000], + 12: [100, 500], + 13: [100, 500], + 14: [50, 200], + 15: [20, 100], + } map.addSource(CONTOUR_SOURCE, { type: 'vector', tiles: [demSourceInstance.contourProtocolUrl({ multiplier: 3.28084, - thresholds: { 11: [200, 1000], 12: [100, 500], 13: [100, 500], 14: [50, 200] }, + thresholds: contourThresholds, })], - maxzoom: 15, + maxzoom: 16, }) console.log('[CONTOUR] protocol URL:', demSourceInstance.contourProtocolUrl({ multiplier: 3.28084, - thresholds: { 11: [200, 1000], 12: [100, 500], 13: [100, 500], 14: [50, 200] }, + thresholds: contourThresholds, })) console.log('[CONTOUR] source added:', !!map.getSource(CONTOUR_SOURCE)) let beforeId = undefined @@ -589,9 +604,13 @@ function addContours(map) { id: CONTOUR_LINE, type: 'line', source: CONTOUR_SOURCE, 'source-layer': 'contours', paint: { - 'line-color': isDark ? '#c0b898' : '#8b6f47', - 'line-opacity': 0.7, - 'line-width': ['match', ['get', 'level'], 1, 1.5, 0.5], + 'line-color': 'rgba(0,0,0,0.35)', + 'line-width': [ + 'interpolate', ['linear'], ['zoom'], + 7, ['match', ['get', 'level'], 1, 1, 0.3], + 11, ['match', ['get', 'level'], 1, 1.5, 0.6], + 14, ['match', ['get', 'level'], 1, 2, 0.8], + ], }, }, beforeId) map.addLayer({ @@ -599,13 +618,15 @@ function addContours(map) { 'source-layer': 'contours', filter: ['>', ['get', 'level'], 0], layout: { - 'symbol-placement': 'line', 'text-size': 10, + 'symbol-placement': 'line', + 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 9, 11, 11, 14, 13], 'text-field': ['concat', ['number-format', ['get', 'ele'], {}], "'"], - 'text-font': ['Noto Sans Regular'], + 'text-font': ['Noto Sans Bold'], + 'text-max-angle': 25, }, paint: { - 'text-color': isDark ? '#c0b898' : '#5a4020', - 'text-halo-color': isDark ? '#1a1a1a' : '#ffffff', + 'text-color': 'rgba(0,0,0,0.7)', + 'text-halo-color': 'rgba(255,255,255,0.9)', 'text-halo-width': 1.5, }, }) @@ -2026,18 +2047,22 @@ const MapView = forwardRef(function MapView(_, ref) { const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) - // Initialize DemSource for maplibre-contour - if (!demSourceInstance) { - demSourceInstance = new mlcontour.DemSource({ - url: `${window.location.origin}/tiles/terrain/{z}/{x}/{y}`, - encoding: 'terrarium', - maxzoom: 14, - worker: true, - }) - demSourceInstance.setupMaplibre(maplibregl) - } - 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] From 5df01b1428cab8d1d35944f472b072aed4e3cd5c Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 14:18:18 +0000 Subject: [PATCH 39/56] fix: use Noto Sans Medium for contour labels (Bold not in protomaps assets) --- src/components/MapView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 7ae3146..19299ab 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -621,7 +621,7 @@ function addContours(map) { 'symbol-placement': 'line', 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 9, 11, 11, 14, 13], 'text-field': ['concat', ['number-format', ['get', 'ele'], {}], "'"], - 'text-font': ['Noto Sans Bold'], + 'text-font': ['Noto Sans Medium'], 'text-max-angle': 25, }, paint: { From 0b1854bd5fa2e55684f74dbd7294092ec64b9a0b Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 21:56:14 +0000 Subject: [PATCH 40/56] cleanup: remove dead contour-test code and stale fallback config - Remove contours-test.pmtiles and contours-test-10ft.pmtiles references (files deleted, feature flags disabled) - Update fallback tileset URL from na.pmtiles to planet/current.pmtiles - Remove has_contours_test and has_contours_test_10ft from fallback config - Delete 46 .bak* files from src/ Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 239 +------------------------------------ src/config.js | 4 +- 2 files changed, 2 insertions(+), 241 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 19299ab..0ede093 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -38,16 +38,6 @@ const PUBLIC_LANDS_LABEL = 'public-lands-label' const CONTOUR_SOURCE = 'contour-source' const CONTOUR_LINE = 'contour-lines' const CONTOUR_LABEL = 'contour-labels' -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' @@ -641,207 +631,6 @@ function removeContours(map) { 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 @@ -1548,7 +1337,7 @@ const MapView = forwardRef(function MapView(_, ref) { const watchIdRef = useRef(null) const currentThemeRef = useRef('dark') // Track which overlay layers are currently active (for theme swap re-add) - const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false, usfsTrails: false, blmTrails: false }) + const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, usfsTrails: false, blmTrails: false }) // Flag to suppress map-click when a stop pin was clicked const pinClickedRef = useRef(false) const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState @@ -1971,30 +1760,6 @@ const MapView = forwardRef(function MapView(_, ref) { removeContours(map) activeLayersRef.current.contours = false }, - addContoursTestLayer() { - const map = mapInstance.current - if (!map) return - addContoursTest(map, currentThemeRef.current) - activeLayersRef.current.contoursTest = true - }, - removeContoursTestLayer() { - const map = mapInstance.current - if (!map) return - removeContoursTest(map) - activeLayersRef.current.contoursTest = false - }, - addContoursTest10ftLayer() { - const map = mapInstance.current - if (!map) return - addContoursTest10ft(map, currentThemeRef.current) - activeLayersRef.current.contoursTest10ft = true - }, - removeContoursTest10ftLayer() { - const map = mapInstance.current - if (!map) return - removeContoursTest10ft(map) - activeLayersRef.current.contoursTest10ft = false - }, addUsfsTrailsLayer() { const map = mapInstance.current if (!map) return @@ -2682,8 +2447,6 @@ const MapView = forwardRef(function MapView(_, ref) { 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) diff --git a/src/config.js b/src/config.js index 274edba..97af6a1 100644 --- a/src/config.js +++ b/src/config.js @@ -10,7 +10,7 @@ const FALLBACK_CONFIG = { profile: 'home', region_name: 'North America', tileset: { - url: '/tiles/na.pmtiles', + url: '/tiles/planet/current.pmtiles', bounds: [-168, 14, -52, 72], max_zoom: 15, attribution: 'Protomaps © OSM', @@ -30,8 +30,6 @@ const FALLBACK_CONFIG = { has_landclass: false, has_public_lands_layer: false, has_contours: true, - has_contours_test: true, - has_contours_test_10ft: false, has_address_book_write: false, has_usfs_trails: false, has_blm_trails: false, From 400dcbb8f281708cb359f5ff119a5b6195e51c8e Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 23:22:48 +0000 Subject: [PATCH 41/56] docs: add OFFROUTE effort-based routing architecture Co-Authored-By: Claude Opus 4.5 --- docs/OFFROUTE-ARCHITECTURE.md | 396 ++++++++++++++++++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 docs/OFFROUTE-ARCHITECTURE.md diff --git a/docs/OFFROUTE-ARCHITECTURE.md b/docs/OFFROUTE-ARCHITECTURE.md new file mode 100644 index 0000000..a4b4fd8 --- /dev/null +++ b/docs/OFFROUTE-ARCHITECTURE.md @@ -0,0 +1,396 @@ +# 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. From 95dd4438fea2710b2659ce3f264bff5871802f24 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 07:02:00 +0000 Subject: [PATCH 42/56] docs: add traffic-intelligent routing and Idaho 511 planned features - Append section 11 (On-Network Traffic Intelligence) to OFFROUTE-ARCHITECTURE.md - Create navi-feature-ideas.md with planned features: - Traffic-aware Valhalla routing via TomTom tiles - Idaho 511 incident feed integration - ADS-B/AIS tracking - TAK Server + EUD integration - Native iOS app Co-Authored-By: Claude Opus 4.5 --- docs/OFFROUTE-ARCHITECTURE.md | 31 ++++++++++++ docs/navi-feature-ideas.md | 92 +++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 docs/navi-feature-ideas.md diff --git a/docs/OFFROUTE-ARCHITECTURE.md b/docs/OFFROUTE-ARCHITECTURE.md index a4b4fd8..c29945b 100644 --- a/docs/OFFROUTE-ARCHITECTURE.md +++ b/docs/OFFROUTE-ARCHITECTURE.md @@ -394,3 +394,34 @@ This system may guide people through dangerous terrain. Design constraints: - GRASS GIS. `r.walk` manual. grass.osgeo.org. - Hoover, B. et al. (2019). CostMAP: An open-source software package for developing cost surfaces. LANL. - Mapterhorn project. mapterhorn.com. BSD-3. + + +--- + +## 11. On-Network Traffic Intelligence + +Two features that affect Valhalla segments (2–4) of the offroute chain, not the wilderness pathfinder (segment 1): + +### Traffic-Aware Routing + +- Valhalla supports time-dependent costing via traffic speed tiles +- TomTom traffic tiles already integrated in Navi at `/api/traffic/*` (currently visual overlay only) +- **Integration path:** configure Valhalla `traffic_tile_dir` to consume TomTom speed data so route calculations account for live congestion +- **Effect on offroute:** segments 2–4 (trail-to-road, road-to-road, road-to-home) would route around congested corridors +- Does NOT affect segment 1 (wilderness pathfinder) + +### Idaho 511 Incident Feed + +- Idaho 511 API provides real-time construction zones, accidents, and road closures +- Two integration points: + 1. **Visual layer** — display incidents on Navi map as icons/overlays + 2. **Routing barriers** — feed active closures to Valhalla as `avoid_locations` or edge exclusions so routes avoid closed roads +- **Implementation:** polling daemon (5–10 min interval), stores active incidents in `navi.db`, expires automatically when cleared +- Affects both standalone Valhalla routing and offroute segments 2–4 +- **Stretch goal:** ingest other state 511 feeds for cross-state trips + +### Sequencing + +- Both features are post-offroute-core (after Phase O3) +- Can be built in parallel — traffic routing is Valhalla config, 511 is a new ingestion daemon + map layer +- Neither blocks wilderness pathfinder development diff --git a/docs/navi-feature-ideas.md b/docs/navi-feature-ideas.md new file mode 100644 index 0000000..13ab389 --- /dev/null +++ b/docs/navi-feature-ideas.md @@ -0,0 +1,92 @@ +# Navi Feature Ideas + +Planned features and enhancements for the Navi navigation platform. + +--- + +## Traffic & Incident Intelligence + +### Traffic-Aware Routing + +**Status:** Planned (post-Phase O3) + +Integrate TomTom traffic data into Valhalla routing calculations: + +- TomTom traffic tiles already available at `/api/traffic/*` (visual overlay) +- Configure Valhalla `traffic_tile_dir` to consume speed data +- Routes will account for live congestion on segments 2–4 of offroute chain +- Does not affect wilderness pathfinder (segment 1) + +### Idaho 511 Incident Feed + +**Status:** Planned (post-Phase O3) + +Real-time road closure and incident integration: + +- Poll Idaho 511 API every 5–10 minutes +- Store active incidents in `navi.db` with auto-expiration +- Display incidents as map overlay (icons/markers) +- Feed closures to Valhalla as `avoid_locations` for routing +- Stretch: support other state 511 feeds for cross-state trips + +--- + +## Tracking & Situational Awareness + +### ADS-B Aircraft Tracking + +**Status:** Planned + +Display live aircraft positions from ADS-B receivers: + +- Integrate with local ADS-B receiver (dump1090/readsb) +- Show aircraft positions, altitude, callsign on map +- Useful for backcountry SAR coordination and general aviation awareness + +### AIS Vessel Tracking + +**Status:** Planned + +Display marine vessel positions: + +- Integrate with AIS receiver or feed +- Show vessel positions, heading, name on map +- Applicable for coastal/maritime navigation scenarios + +--- + +## TAK Integration + +### TAK Server + EUD Integration + +**Status:** Planned + +Connect Navi to the TAK ecosystem (ATAK, iTAK, WinTAK): + +- TAK Server integration for shared situational awareness +- Push Navi routes to TAK clients as CoT (Cursor on Target) +- Pull team member positions from TAK into Navi +- Enable SAR/field team coordination through unified COP + +--- + +## Mobile & Offline + +### Native iOS App + +**Status:** Planned + +Native iOS application for offline-first navigation: + +- Full offline map tile access +- Offline routing with pre-cached Valhalla tiles +- Integration with Apple Watch for turn-by-turn +- Meshtastic/LoRa mesh network support for off-grid comms + +--- + +## Notes + +- Features above Phase O3 depend on core offroute functionality being complete +- Traffic and 511 features can be built in parallel +- TAK integration useful for field coordination but not blocking core nav From d6aa125215eabf82e4e205bff4171ab315f13c85 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 15:05:52 +0000 Subject: [PATCH 43/56] feat: unified routing UI with wilderness + network segments - Single routing system (removed duplicate Valhalla-only flow) - Unified radial menu: From here, To here, Clear, Save, Measure - Removed "Offroute" section from panel (single directions display) - Better error messages without technical "Offroute" prefix - ManeuverList shows wilderness + network breakdown - PlaceCard integration for previews Co-Authored-By: Claude Opus 4.5 --- src/App.jsx | 79 +---- src/api.js | 66 ++++ src/components/ManeuverList.jsx | 304 ++++++++-------- src/components/MapView.jsx | 444 ++++++++++------------- src/components/Panel.jsx | 605 ++++++++++++++++---------------- src/store.js | 318 +++++++++-------- 6 files changed, 878 insertions(+), 938 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 0d02c8f..3bdea6e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useCallback } from 'react' import { useStore } from './store' import { useTheme } from './hooks/useTheme' -import { requestRoute, fetchAuthState } from './api' -import { decodePolyline } from './utils/decode' +import { fetchAuthState } from './api' import MapView from './components/MapView' import Panel from './components/Panel' @@ -12,20 +11,10 @@ import LocateButton from './components/LocateButton' export default function App() { const mapViewRef = useRef(null) - const routeDebounceRef = useRef(null) // Initialize theme system useTheme() - const stops = useStore((s) => s.stops) - const mode = useStore((s) => s.mode) - const route = useStore((s) => s.route) - const gpsOrigin = useStore((s) => s.gpsOrigin) - const geoPermission = useStore((s) => s.geoPermission) - const setRoute = useStore((s) => s.setRoute) - const setRouteLoading = useStore((s) => s.setRouteLoading) - const setRouteError = useStore((s) => s.setRouteError) - const clearRoute = useStore((s) => s.clearRoute) const setAuth = useStore((s) => s.setAuth) // Initialize auth state on app load (single fetch, no polling) @@ -33,70 +22,18 @@ export default function App() { fetchAuthState().then(setAuth) }, [setAuth]) - // Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms) - useEffect(() => { - if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) - - routeDebounceRef.current = setTimeout(async () => { - const { userLocation } = useStore.getState() - - let effective = stops.map((s) => ({ lat: s.lat, lon: s.lon })) - if (gpsOrigin && geoPermission === 'granted' && userLocation) { - effective = [{ lat: userLocation.lat, lon: userLocation.lon }, ...effective] - } - - if (effective.length < 2) { - clearRoute() - return - } - - setRouteLoading(true) - - try { - const data = await requestRoute(effective, mode) - if (data.trip) { - setRoute(data.trip) - } else { - setRouteError('No route returned') - } - } catch (e) { - setRouteError(e.message || 'Route request failed') - } finally { - setRouteLoading(false) - } - }, 500) - - return () => { - if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) - } - }, [stops, mode, gpsOrigin, geoPermission, clearRoute, setRoute, setRouteLoading, setRouteError]) - - // Handle maneuver click - const handleManeuverClick = useCallback( - (maneuver) => { - if (!route || !route.legs) return - - const legIdx = maneuver._legIndex || 0 - const leg = route.legs[legIdx] - if (!leg || !leg.shape) return - - const coords = decodePolyline(leg.shape, 6) - const idx = maneuver.begin_shape_index - if (idx >= 0 && idx < coords.length) { - const [lng, lat] = coords[idx] - mapViewRef.current?.flyTo(lat, lng, 15) - } - }, - [route] - ) + // Handle clear route from panel + const handleClearRoute = useCallback(() => { + mapViewRef.current?.clearRoute?.() + }, []) return (
- - + + - + {/* Bottom-right map controls */}
diff --git a/src/api.js b/src/api.js index fe8fd02..47d5861 100644 --- a/src/api.js +++ b/src/api.js @@ -321,3 +321,69 @@ 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, + } + + 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/ManeuverList.jsx b/src/components/ManeuverList.jsx index d869b66..a8b90b0 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -1,140 +1,164 @@ -import { - MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, - MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, - GitMerge, CornerRightDown, CornerRightUp, Navigation -} from 'lucide-react' -import { useStore } from '../store' - -function formatTime(seconds) { - if (seconds < 60) return `${Math.round(seconds)}s` - if (seconds < 3600) return `${Math.round(seconds / 60)} min` - const h = Math.floor(seconds / 3600) - const m = Math.round((seconds % 3600) / 60) - return m > 0 ? `${h}h ${m}m` : `${h}h` -} - -function formatDist(miles) { - if (miles < 0.1) return `${Math.round(miles * 5280)} ft` - return `${miles.toFixed(1)} mi` -} - -function ManeuverIcon({ type }) { - const size = 16 - const props = { size, strokeWidth: 1.5 } - switch (type) { - case 0: return - case 1: return - case 2: return - case 3: return - case 4: case 5: return - case 6: return - case 7: return - case 8: return - case 9: return - case 10: case 11: case 12: return - case 15: case 16: return - case 24: return - case 25: return - case 26: return - default: return - } -} - -export default function ManeuverList({ onManeuverClick }) { - const route = useStore((s) => s.route) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - - if (routeLoading) { - return ( -
-
- - Calculating route... - -
- ) - } - - if (routeError) { - return ( -
- {routeError} -
- ) - } - - if (!route || !route.legs) return null - - const totalTime = route.summary?.time || 0 - const totalDist = route.summary?.length || 0 - - const allManeuvers = [] - let timeRemaining = totalTime - - for (let legIdx = 0; legIdx < route.legs.length; legIdx++) { - const leg = route.legs[legIdx] - for (const man of leg.maneuvers || []) { - allManeuvers.push({ ...man, _legIndex: legIdx, timeRemaining }) - timeRemaining -= man.time || 0 - } - } - - return ( -
- {/* Route summary */} -
- - {formatDist(totalDist)} - - - {formatTime(totalTime)} - -
- - {/* Maneuver steps */} -
- {allManeuvers.map((man, i) => ( - - ))} -
-
- ) -} +import { + MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, + MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, + GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle +} from 'lucide-react' +import { useStore } from '../store' + +function formatDistKm(km) { + const miles = km * 0.621371 + if (miles < 0.1) return Math.round(miles * 5280) + ' 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' +} + +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() { + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + + if (routeLoading) { + return ( +
+
+ + Calculating route... + +
+ ) + } + + if (routeError) { + return ( +
+ {routeError} +
+ ) + } + + if (!routeResult?.summary) return null + + const summary = routeResult.summary + const networkFeature = routeResult.route?.features?.find(f => f.properties?.segment_type === 'network') + const maneuvers = networkFeature?.properties?.maneuvers || [] + + return ( +
+ {/* Total summary */} +
+ + {formatDistKm(summary.total_distance_km)} + + + {formatTimeMin(summary.total_effort_minutes)} + +
+ + {/* Segment breakdown */} +
+ {summary.wilderness_distance_km > 0 && ( +
+ + Wilderness + + {formatDistKm(summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)} + +
+ )} + {summary.network_distance_km > 0 && ( +
+ + Road/Trail + + {formatDistKm(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 */} + {maneuvers.length > 0 && ( +
+
Directions
+ {maneuvers.map((man, i) => ( +
+ + + +
+

+ {man.instruction} +

+

+ {formatDistKm(man.distance_km)} +

+
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 0ede093..1dfec38 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -6,9 +6,9 @@ import { layers, namedTheme } from 'protomaps-themes-base' import { getTheme, getThemeSprite, getOverlayConfig } from '../themes/registry' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' -import { fetchReverse } from '../api' +import { fetchReverse, requestOffroute } from '../api' import { getConfig, hasFeature } from '../config' -import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } from 'lucide-react' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2 } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' @@ -27,6 +27,10 @@ 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' @@ -1122,6 +1126,7 @@ 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') || @@ -1327,6 +1332,83 @@ function removeStateBoundaries(map) { } } + +/** 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) @@ -1348,14 +1430,11 @@ const MapView = forwardRef(function MapView(_, ref) { 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) @@ -1578,96 +1657,95 @@ const MapView = forwardRef(function MapView(_, ref) { updateMeasureLabels(newPoints) } - const radialWedges = [ - { - id: "directions-to", - label: "To here", - icon: ArrowDownLeft, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, - } - useStore.getState().startDirections(place) - }, - }, - { - 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, - } - addStop(place) - useStore.setState({ gpsOrigin: false }) - }, - }, - { - id: "add-stop", - label: "Add stop", - icon: Plus, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - 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, - } - if (stops.length === 0) { - addStop(place) - useStore.setState({ gpsOrigin: false }) - } else { - const success = addStop(place) - if (!success) { - toast("Maximum 10 stops reached") - } - } - }, - }, - { - id: "save-place", - label: "Save", - icon: Star, - requiresAuth: true, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const { auth, setEditingContact } = useStore.getState() - if (auth.authenticated) { - setEditingContact({ - label: "", - lat: radialMenu.lat, - lon: radialMenu.lon, - }) - } else { - toast("Log in to save places") - } - }, - }, - { - id: "measure", - label: "Measure", - icon: Ruler, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - startMeasuring(radialMenu.lat, radialMenu.lon) - }, - }, - ] + const radialWedges = [ + { + id: "to-here", + label: "To here", + icon: ArrowDownLeft, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { routeStart, setRouteEnd, setRouteLoading, setRouteResult, setRouteError, routeMode, boundaryMode } = useStore.getState() + setRouteEnd(place) + if (routeStart) { + setRouteLoading(true) + requestOffroute(routeStart, place, routeMode, boundaryMode) + .then((data) => { + if (data.status === "ok" && data.route) { + setRouteResult(data) + updateRouteDisplay(mapInstance.current, data.route) + } else { + setRouteError(data.error || "No route found") + } + }) + .catch((e) => setRouteError(e.message)) + .finally(() => setRouteLoading(false)) + } else { + toast("Set starting point first") + } + }, + }, + { + id: "from-here", + label: "From here", + icon: ArrowUpRight, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { clearRoute, setRouteStart } = useStore.getState() + clearRoute() + clearRouteDisplay(mapInstance.current) + setRouteStart(place) + toast("Now tap destination") + }, + }, + { + id: "clear-route", + label: "Clear", + icon: Trash2, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + useStore.getState().clearRoute() + clearRouteDisplay(mapInstance.current) + }, + }, + { + id: "save-place", + label: "Save", + icon: Star, + requiresAuth: true, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const { auth, setEditingContact } = useStore.getState() + if (auth.authenticated) { + setEditingContact({ + label: "", + lat: radialMenu.lat, + lon: radialMenu.lon, + }) + } else { + toast("Log in to save places") + } + }, + }, + { + id: "measure", + label: "Measure", + icon: Ruler, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + startMeasuring(radialMenu.lat, radialMenu.lon) + }, + }, + ] // Context menu trigger handler const handleContextMenuTrigger = ({ x, y }) => { const map = mapInstance.current @@ -1805,6 +1883,14 @@ const MapView = forwardRef(function MapView(_, ref) { updateSatellitePaint(map, currentThemeRef.current) }, + // Clear offroute route from map + clearRoute() { + const map = mapInstance.current + if (!map) return + clearRouteDisplay(map) + useStore.getState().clearRoute() + }, + })) // Initialize map @@ -2464,10 +2550,8 @@ const MapView = forwardRef(function MapView(_, ref) { originalPaintValues = {} // Restore view - map.jumpTo({ center, zoom, bearing, pitch }) - // Re-render route if exists - const currentRoute = useStore.getState().route - if (currentRoute) updateRoute(map, currentRoute) + const currentRoute = useStore.getState().routeResult + if (currentRoute?.route) updateRouteDisplay(map, currentRoute.route) }) }, [theme]) @@ -2560,168 +2644,6 @@ const MapView = forwardRef(function MapView(_, ref) { return () => document.removeEventListener('keydown', handleKeyDown) }, [selectedPlace]) - // Update route polyline when route changes - useEffect(() => { - const map = mapInstance.current - if (!map) return - if (!map.isStyleLoaded()) { - const handler = () => updateRoute(map, route) - map.once('idle', handler) - return () => map.off('idle', handler) - } - updateRoute(map, route) - }, [route]) - - function updateRoute(map, routeData) { - if (!map) return - - // Remove old route layers - const style = map.getStyle() - if (style) { - for (const layer of style.layers) { - if (layer.id.startsWith(ROUTE_LAYER_PREFIX)) { - map.removeLayer(layer.id) - } - } - } - - if (!routeData || !routeData.legs) { - if (map.getSource(ROUTE_SOURCE)) { - map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] }) - } - return - } - - const features = [] - for (let i = 0; i < routeData.legs.length; i++) { - const leg = routeData.legs[i] - if (!leg.shape) continue - const coords = decodePolyline(leg.shape, 6) - features.push({ - type: 'Feature', - properties: { legIndex: i }, - geometry: { type: 'LineString', coordinates: coords }, - }) - } - - const source = map.getSource(ROUTE_SOURCE) - if (source) { - source.setData({ type: 'FeatureCollection', features }) - } else { - map.addSource(ROUTE_SOURCE, { - type: 'geojson', - data: { type: 'FeatureCollection', features }, - }) - } - - // Use CSS variable for route color (read computed value) - const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim() - - for (let i = 0; i < features.length; i++) { - const layerId = `${ROUTE_LAYER_PREFIX}${i}` - if (!map.getLayer(layerId)) { - map.addLayer({ - id: layerId, - type: 'line', - source: ROUTE_SOURCE, - filter: ['==', ['get', 'legIndex'], i], - layout: { 'line-join': 'round', 'line-cap': 'round' }, - paint: { - 'line-color': routeColor || '#7a9a6b', - 'line-width': 5, - 'line-opacity': 0.85, - }, - }) - } - } - - // Fit bounds to route - if (features.length > 0) { - const allCoords = features.flatMap((f) => f.geometry.coordinates) - const bounds = allCoords.reduce( - (b, c) => b.extend(c), - new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) - ) - // Single-panel: no floating detail - const leftPad = 420 // 360px panel + margin - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) - } - } - - // Update stop markers when stops change - useEffect(() => { - const map = mapInstance.current - if (!map) return - - // Remove old markers - for (const m of markersRef.current) m.remove() - markersRef.current = [] - if (popupRef.current) { - popupRef.current.remove() - popupRef.current = null - } - - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const indexOffset = hasGpsOrigin ? 1 : 0 - - stops.forEach((stop, i) => { - const displayIndex = i + indexOffset - const effectiveTotal = stops.length + indexOffset - - let pinClass = 'navi-pin navi-pin--intermediate' - if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin' - else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination' - - const label = String.fromCharCode(65 + Math.min(displayIndex, 25)) - - const el = document.createElement('div') - el.className = pinClass - el.textContent = label - - el.addEventListener('click', (e) => { - e.stopPropagation() - // Flag so the map-level click handler doesn't fire - pinClickedRef.current = true - if (popupRef.current) popupRef.current.remove() - const popup = new maplibregl.Popup({ offset: 20, closeButton: true }) - .setLngLat([stop.lon, stop.lat]) - .setHTML( - `
- ${stop.name} -
-
` - ) - .addTo(map) - - popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => { - useStore.getState().removeStop(stop.id) - popup.remove() - }) - popupRef.current = popup - }) - - const marker = new maplibregl.Marker({ element: el }) - .setLngLat([stop.lon, stop.lat]) - .addTo(map) - - markersRef.current.push(marker) - }) - - // If stops but no route yet, fit to stops - if (stops.length > 0 && !route) { - if (stops.length === 1) { - map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 }) - } else { - const bounds = stops.reduce( - (b, s) => b.extend([s.lon, s.lat]), - new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]) - ) - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 420, right: 60 } }) - } - } - }, [stops, route, gpsOrigin, geoPermission]) - - // ESC key handler for measurement mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 2799a89..ddae59c 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,311 +1,294 @@ -import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut } from 'lucide-react' -import ThemePicker from './ThemePicker' -import { useStore, usePanelState } from '../store' -import { hasFeature } from '../config' -import SearchBar from './SearchBar' -import StopList from './StopList' -import ModeSelector from './ModeSelector' -import ManeuverList from './ManeuverList' -import ContactList from './ContactList' -import { PlaceCard } from './PlaceCard' -import { requestOptimizedRoute } from '../api' - -export default function Panel({ onManeuverClick }) { - const selectedPlace = useStore((s) => s.selectedPlace) - const pendingDestination = useStore((s) => s.pendingDestination) - const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) - const clearPendingDestination = useStore((s) => s.clearPendingDestination) - const stops = useStore((s) => s.stops) - const mode = useStore((s) => s.mode) - const route = useStore((s) => s.route) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - const setStops = useStore((s) => s.setStops) - const setRoute = useStore((s) => s.setRoute) - const setRouteError = useStore((s) => s.setRouteError) - const setRouteLoading = useStore((s) => s.setRouteLoading) - const sheetState = useStore((s) => s.sheetState) - const setSheetState = useStore((s) => s.setSheetState) - const theme = useStore((s) => s.theme) - const themeOverride = useStore((s) => s.themeOverride) - const setThemeOverride = useStore((s) => s.setThemeOverride) - const gpsOrigin = useStore((s) => s.gpsOrigin) - const geoPermission = useStore((s) => s.geoPermission) - const activeTab = useStore((s) => s.activeTab) - const auth = useStore((s) => s.auth) - const setActiveTab = useStore((s) => s.setActiveTab) - - const panelState = usePanelState() - - const [isMobile, setIsMobile] = useState(false) - const [optimizing, setOptimizing] = useState(false) - const sheetRef = useRef(null) - const dragStartY = useRef(0) - const dragStartState = useRef('half') - - // Show contacts tab only if feature enabled AND user is authenticated - const showContacts = hasFeature('has_contacts') && auth.authenticated - - // Responsive detection - useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 768) - check() - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - - // Auth handlers - const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } - const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } - - // Optimize stops - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0) - - const handleOptimize = useCallback(async () => { - if (effectiveCount < 3 || optimizing) return - setOptimizing(true) - try { - const { userLocation } = useStore.getState() - let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon })) - if (hasGpsOrigin && userLocation) { - locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations] - } - const data = await requestOptimizedRoute(locations, mode) - if (data.trip) { - const wpOrder = hasGpsOrigin && userLocation - ? (data.trip.locations || []).slice(1) - : data.trip.locations - if (wpOrder && wpOrder.length === stops.length) { - const reordered = wpOrder.map((wp) => { - let closest = stops[0] - let minDist = Infinity - for (const s of stops) { - const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon) - if (d < minDist) { - minDist = d - closest = s - } - } - return closest - }) - const seen = new Set() - const unique = reordered.filter((s) => { - if (seen.has(s.id)) return false - seen.add(s.id) - return true - }) - if (unique.length === stops.length) { - setStops(unique) - } - } - setRoute(data.trip) - } - } catch (e) { - setRouteError(e.message) - } finally { - setOptimizing(false) - } - }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError]) - - // Mobile sheet drag handling - const handleTouchStart = useCallback((e) => { - dragStartY.current = e.touches[0].clientY - dragStartState.current = sheetState - }, [sheetState]) - - const handleTouchEnd = useCallback((e) => { - const deltaY = e.changedTouches[0].clientY - dragStartY.current - if (Math.abs(deltaY) < 30) return - if (deltaY < 0) { - if (dragStartState.current === 'collapsed') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('full') - } else { - if (dragStartState.current === 'full') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('collapsed') - } - }, [setSheetState]) - - const showOptimize = effectiveCount >= 3 - - // Determine what to show based on panel state - const showPreviewCard = panelState.startsWith('PREVIEW') - const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination - const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED' - const showEmptyState = panelState === 'IDLE' && !pendingDestination - - // Routes tab content - now state-driven - const routesContent = ( - <> - - - {/* 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) && ( -
- -
- )} - - {/* Empty state */} - {showEmptyState && ( -
-

Search or tap the map to explore

-
- )} - - ) - - const content = ( - <> - {showContacts && ( -
- - -
- )} - - {(!showContacts || activeTab === 'routes') ? routesContent : } - - ) - - const header = ( -
-

Navi

-
- {auth.loaded && ( - auth.authenticated ? ( - - ) : ( - - ) - )} - -
-
- ) - - // Desktop: side panel (now 360px to accommodate PlaceCard) - if (!isMobile) { - return ( -
- {header} - {content} -
- ) - } - - // Mobile: bottom sheet - const sheetHeights = { - collapsed: 'h-12', - half: 'h-[45vh]', - full: 'h-[85vh]', - } - - return ( -
- {/* Drag handle */} -
{ - if (sheetState === 'collapsed') setSheetState('half') - else if (sheetState === 'half') setSheetState('full') - else setSheetState('half') - }} - > -
-
- - {sheetState !== 'collapsed' && ( -
- {header} - {content} -
- )} -
- ) -} +import { useRef, useCallback, useEffect, useState } from 'react' +import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' +import ThemePicker from './ThemePicker' +import { useStore, usePanelState } from '../store' +import { hasFeature } from '../config' +import SearchBar from './SearchBar' +import ManeuverList from './ManeuverList' +import ContactList from './ContactList' +import { PlaceCard } from './PlaceCard' + +const TRAVEL_MODES = [ + { id: 'foot', label: 'Foot', Icon: Footprints }, + { id: 'mtb', label: 'MTB', Icon: Bike }, + { id: 'atv', label: 'ATV', Icon: Car }, + { id: 'vehicle', label: '4x4', Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, + { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, + { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, +] + +export default function Panel({ onClearRoute }) { + const selectedPlace = useStore((s) => s.selectedPlace) + const 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 routeLoading = useStore((s) => s.routeLoading) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const clearRoute = useStore((s) => s.clearRoute) + const sheetState = useStore((s) => s.sheetState) + const setSheetState = useStore((s) => s.setSheetState) + const activeTab = useStore((s) => s.activeTab) + const auth = useStore((s) => s.auth) + const setActiveTab = useStore((s) => s.setActiveTab) + + const panelState = usePanelState() + + const [isMobile, setIsMobile] = useState(false) + const sheetRef = useRef(null) + const dragStartY = useRef(0) + const dragStartState = useRef('half') + + const showContacts = hasFeature('has_contacts') && auth.authenticated + + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768) + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + + const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } + const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } + + const handleTouchStart = useCallback((e) => { + dragStartY.current = e.touches[0].clientY + dragStartState.current = sheetState + }, [sheetState]) + + const handleTouchEnd = useCallback((e) => { + const deltaY = e.changedTouches[0].clientY - dragStartY.current + if (Math.abs(deltaY) < 30) return + if (deltaY < 0) { + if (dragStartState.current === 'collapsed') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('full') + } else { + if (dragStartState.current === 'full') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('collapsed') + } + }, [setSheetState]) + + const handleClearRoute = () => { + clearRoute() + onClearRoute?.() + } + + const showPreviewCard = panelState.startsWith('PREVIEW') + const hasRoutePoints = routeStart || routeEnd + const showRouteSection = hasRoutePoints || routeResult || routeLoading + const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + + const routesContent = ( + <> + + + {showPreviewCard && selectedPlace && ( +
+ +
+ )} + + {showRouteSection && ( +
+
+ + Route + + +
+ +
+
+ + + {routeStart?.name || 'Right-click to set start'} + +
+
+ + + {routeEnd?.name || 'Right-click to set destination'} + +
+
+ +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ + +
+ )} + + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+
+ {auth.loaded && ( + auth.authenticated ? ( + + ) : ( + + ) + )} + +
+
+ ) + + if (!isMobile) { + return ( +
+ {header} + {content} +
+ ) + } + + const sheetHeights = { + collapsed: 'h-12', + half: 'h-[45vh]', + full: 'h-[85vh]', + } + + return ( +
+
{ + if (sheetState === 'collapsed') setSheetState('half') + else if (sheetState === 'half') setSheetState('full') + else setSheetState('half') + }} + > +
+
+ + {sheetState !== 'collapsed' && ( +
+ {header} + {content} +
+ )} +
+ ) +} diff --git a/src/store.js b/src/store.js index 6b7f30d..2cf78ee 100644 --- a/src/store.js +++ b/src/store.js @@ -1,155 +1,163 @@ -import { create } from 'zustand' - -export const useStore = create((set, get) => ({ - // ── Search state ── - query: '', - results: [], - searchLoading: false, - abortController: null, - - setQuery: (query) => set({ query }), - setResults: (results) => set({ results }), - setSearchLoading: (loading) => set({ searchLoading: loading }), - setAbortController: (ctrl) => set({ abortController: ctrl }), - - // ── Stop list ── - stops: [], - // Each stop: { id, lat, lon, name, source, matchCode, isOrigin } - - addStop: (stop) => { - const { stops } = get() - if (stops.length >= 10) return false - set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] }) - return true - }, - - removeStop: (id) => { - set({ stops: get().stops.filter((s) => s.id !== id) }) - }, - - reorderStops: (newStops) => set({ stops: newStops }), - - clearStops: () => set({ stops: [] }), - - setStops: (stops) => set({ stops }), - - // ── Geolocation ── - userLocation: null, // { lat, lon } - geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' - - setUserLocation: (loc) => set({ userLocation: loc }), - setGeoPermission: (p) => set({ geoPermission: p }), - - // ── Map viewport (for search bias) ── - mapCenter: null, // { lat, lon, zoom } - setMapCenter: (center) => set({ mapCenter: center }), - - // ── Mode ── - mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle' - setMode: (mode) => set({ mode }), - - // ── Route ── - route: null, // Valhalla response (trip object) - routeLoading: false, - routeError: null, - - setRoute: (route) => set({ route, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, route: null }), - clearRoute: () => set({ route: null, routeError: null }), - - // ── Place detail ── - selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? } - clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection - gpsOrigin: true, // whether GPS should be used as origin when available - pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) - - setSelectedPlace: (place) => set({ selectedPlace: place }), - - // Boundary rendering function - set by MapView, called by PlaceCard - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - startDirections: (place) => { - const { geoPermission, stops, addStop, clearStops } = get() - if (geoPermission === 'granted') { - clearStops() - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ gpsOrigin: true, selectedPlace: null }) - } else if (stops.length > 0) { - const origin = stops[0] - clearStops() - addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ selectedPlace: null }) - } else { - // GPS denied, no stops: set pendingDestination only; origin-picker will add both - set({ pendingDestination: place, selectedPlace: null }) - } - }, - - // ── UI state ── - sheetState: 'half', // 'collapsed' | 'half' | 'full' - panelOpen: true, - autocompleteOpen: false, - theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) - themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) - viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem('navi-view-mode', mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem('navi-theme-override', override) - } else { - localStorage.removeItem('navi-theme-override') - } - }, - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: 'routes', // 'routes' | 'contacts' - editingContact: null, // null=closed, {}=new, {id:N}=edit - pickingLocationFor: null, // form data while user picks location on map - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -// Returns string state, prioritizing preview to allow it alongside any route state -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.route - const hasStops = s.stops.length >= 1 - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasStops) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasStops) return "ROUTING" - return "IDLE" - }) -} +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 }), + + // ── 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 ── + // Single routing system - all routes go through /api/offroute + routeStart: null, // { lat, lon, name } + routeEnd: null, // { lat, lon, name } + routeMode: "foot", // foot | mtb | atv | vehicle + boundaryMode: "strict", // strict | pragmatic | emergency + routeResult: null, // Response from /api/offroute + routeLoading: false, + routeError: null, + + setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), + setRouteEnd: (place) => set({ routeEnd: place }), + setRouteMode: (mode) => set({ routeMode: mode }), + setBoundaryMode: (mode) => set({ boundaryMode: mode }), + setRouteResult: (result) => set({ routeResult: result, routeError: null }), + setRouteLoading: (loading) => set({ routeLoading: loading }), + setRouteError: (err) => set({ routeError: err, routeResult: null }), + clearRoute: () => set({ + routeStart: null, + routeEnd: null, + routeResult: null, + routeError: null + }), + + // ── Legacy compatibility (for components not yet migrated) ── + stops: [], + gpsOrigin: false, + pendingDestination: null, + route: null, + + addStop: (stop) => { + // Legacy: just set as route end point + const { routeStart, setRouteEnd } = get() + const place = { lat: stop.lat, lon: stop.lon, name: stop.name } + if (!routeStart) { + set({ routeStart: place, stops: [{ ...stop, id: crypto.randomUUID() }] }) + } else { + setRouteEnd(place) + set({ stops: [...get().stops, { ...stop, id: crypto.randomUUID() }] }) + } + return true + }, + removeStop: (id) => { + const { stops } = get() + const newStops = stops.filter((s) => s.id !== id) + set({ stops: newStops }) + if (newStops.length === 0) { + get().clearRoute() + } + }, + clearStops: () => set({ stops: [], routeStart: null, routeEnd: null }), + setStops: (stops) => set({ stops }), + reorderStops: (newStops) => set({ stops: newStops }), + setGpsOrigin: (val) => set({ gpsOrigin: val }), + setPendingDestination: (place) => set({ pendingDestination: place }), + clearPendingDestination: () => set({ pendingDestination: null }), + + startDirections: (place) => { + // Legacy: set as destination + const { routeStart, setRouteEnd, clearRoute } = get() + clearRoute() + set({ + routeEnd: { lat: place.lat, lon: place.lon, name: place.name }, + stops: [{ ...place, id: crypto.randomUUID() }], + 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, + 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 }), + 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" + }) +} From 09d68adf095bd1028d539e1177d58676546423d8 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 21:59:10 +0000 Subject: [PATCH 44/56] feat: unified routing with Drive mode default and Add stop wedge - Add Drive (auto) as default route mode, first in travel modes list - Hide boundary mode selector when Drive mode is active - Restore Add stop radial menu wedge with stops system integration - Unify routing through single computeRoute() function in store - Add coordinate parsing to SearchBar for direct lat/lon input - Bridge stops system with routeStart/routeEnd for seamless UX - Support 3+ stops with Valhalla optimization Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 205 ++++++------ src/components/Panel.jsx | 591 ++++++++++++++++++----------------- src/components/SearchBar.jsx | 43 +++ src/store.js | 434 +++++++++++++++---------- 4 files changed, 726 insertions(+), 547 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 1dfec38..ca9b5c5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -8,7 +8,7 @@ import { useStore } from '../store' import { decodePolyline } from '../utils/decode' import { fetchReverse, requestOffroute } from '../api' import { getConfig, hasFeature } from '../config' -import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2 } from 'lucide-react' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' @@ -1657,95 +1657,114 @@ const MapView = forwardRef(function MapView(_, ref) { updateMeasureLabels(newPoints) } - const radialWedges = [ - { - id: "to-here", - label: "To here", - icon: ArrowDownLeft, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - } - const { routeStart, setRouteEnd, setRouteLoading, setRouteResult, setRouteError, routeMode, boundaryMode } = useStore.getState() - setRouteEnd(place) - if (routeStart) { - setRouteLoading(true) - requestOffroute(routeStart, place, routeMode, boundaryMode) - .then((data) => { - if (data.status === "ok" && data.route) { - setRouteResult(data) - updateRouteDisplay(mapInstance.current, data.route) - } else { - setRouteError(data.error || "No route found") - } - }) - .catch((e) => setRouteError(e.message)) - .finally(() => setRouteLoading(false)) - } else { - toast("Set starting point first") - } - }, - }, - { - id: "from-here", - label: "From here", - icon: ArrowUpRight, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - } - const { clearRoute, setRouteStart } = useStore.getState() - clearRoute() - clearRouteDisplay(mapInstance.current) - setRouteStart(place) - toast("Now tap destination") - }, - }, - { - id: "clear-route", - label: "Clear", - icon: Trash2, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - useStore.getState().clearRoute() - clearRouteDisplay(mapInstance.current) - }, - }, - { - id: "save-place", - label: "Save", - icon: Star, - requiresAuth: true, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const { auth, setEditingContact } = useStore.getState() - if (auth.authenticated) { - setEditingContact({ - label: "", - lat: radialMenu.lat, - lon: radialMenu.lon, - }) - } else { - toast("Log in to save places") - } - }, - }, - { - id: "measure", - label: "Measure", - icon: Ruler, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - startMeasuring(radialMenu.lat, radialMenu.lon) - }, - }, - ] + const radialWedges = [ + { + id: "to-here", + label: "To here", + icon: ArrowDownLeft, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { routeStart, setRouteEnd, computeRoute } = useStore.getState() + setRouteEnd(place) + if (routeStart) { + computeRoute() + } else { + toast("Set starting point first") + } + }, + }, + { + id: "from-here", + label: "From here", + icon: ArrowUpRight, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { clearRoute, setRouteStart, routeEnd, computeRoute } = useStore.getState() + clearRoute() + clearRouteDisplay(mapInstance.current) + setRouteStart(place) + // If we already have a destination, compute route immediately + if (routeEnd) { + computeRoute() + } else { + toast("Now tap destination") + } + }, + }, + { + id: "add-stop", + label: "Add stop", + icon: Plus, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const { stops, addStop } = useStore.getState() + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + source: "radial_menu", + matchCode: null, + } + if (stops.length === 0) { + addStop(place) + useStore.setState({ gpsOrigin: false }) + } else { + const success = addStop(place) + if (!success) { + toast("Maximum 10 stops reached") + } + } + }, + }, + { + id: "clear-route", + label: "Clear", + icon: Trash2, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + useStore.getState().clearRoute() + clearRouteDisplay(mapInstance.current) + }, + }, + { + id: "save-place", + label: "Save", + icon: Star, + requiresAuth: true, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const { auth, setEditingContact } = useStore.getState() + if (auth.authenticated) { + setEditingContact({ + label: "", + lat: radialMenu.lat, + lon: radialMenu.lon, + }) + } else { + toast("Log in to save places") + } + }, + }, + { + id: "measure", + label: "Measure", + icon: Ruler, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + startMeasuring(radialMenu.lat, radialMenu.lon) + }, + }, + ] // Context menu trigger handler const handleContextMenuTrigger = ({ x, y }) => { const map = mapInstance.current @@ -2390,6 +2409,12 @@ const MapView = forwardRef(function MapView(_, ref) { updateBoundaryRef.current = updateBoundaryFn useStore.getState().setUpdateBoundary(updateBoundaryFn) + // Register route display callbacks for store.computeRoute() + useStore.getState().setRouteDisplayCallbacks( + (routeGeojson) => updateRouteDisplay(map, routeGeojson), + () => clearRouteDisplay(map) + ) + // POI/label hover affordance — cursor pointer + highlight const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace'] diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index ddae59c..efc9b5a 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,294 +1,297 @@ -import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' -import ThemePicker from './ThemePicker' -import { useStore, usePanelState } from '../store' -import { hasFeature } from '../config' -import SearchBar from './SearchBar' -import ManeuverList from './ManeuverList' -import ContactList from './ContactList' -import { PlaceCard } from './PlaceCard' - -const TRAVEL_MODES = [ - { id: 'foot', label: 'Foot', Icon: Footprints }, - { id: 'mtb', label: 'MTB', Icon: Bike }, - { id: 'atv', label: 'ATV', Icon: Car }, - { id: 'vehicle', label: '4x4', Icon: Car }, -] - -const BOUNDARY_MODES = [ - { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, - { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, - { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, -] - -export default function Panel({ onClearRoute }) { - const selectedPlace = useStore((s) => s.selectedPlace) - const 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 routeLoading = useStore((s) => s.routeLoading) - const setRouteMode = useStore((s) => s.setRouteMode) - const setBoundaryMode = useStore((s) => s.setBoundaryMode) - const clearRoute = useStore((s) => s.clearRoute) - const sheetState = useStore((s) => s.sheetState) - const setSheetState = useStore((s) => s.setSheetState) - const activeTab = useStore((s) => s.activeTab) - const auth = useStore((s) => s.auth) - const setActiveTab = useStore((s) => s.setActiveTab) - - const panelState = usePanelState() - - const [isMobile, setIsMobile] = useState(false) - const sheetRef = useRef(null) - const dragStartY = useRef(0) - const dragStartState = useRef('half') - - const showContacts = hasFeature('has_contacts') && auth.authenticated - - useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 768) - check() - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - - const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } - const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } - - const handleTouchStart = useCallback((e) => { - dragStartY.current = e.touches[0].clientY - dragStartState.current = sheetState - }, [sheetState]) - - const handleTouchEnd = useCallback((e) => { - const deltaY = e.changedTouches[0].clientY - dragStartY.current - if (Math.abs(deltaY) < 30) return - if (deltaY < 0) { - if (dragStartState.current === 'collapsed') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('full') - } else { - if (dragStartState.current === 'full') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('collapsed') - } - }, [setSheetState]) - - const handleClearRoute = () => { - clearRoute() - onClearRoute?.() - } - - const showPreviewCard = panelState.startsWith('PREVIEW') - const hasRoutePoints = routeStart || routeEnd - const showRouteSection = hasRoutePoints || routeResult || routeLoading - const showEmptyState = panelState === 'IDLE' && !hasRoutePoints - - const routesContent = ( - <> - - - {showPreviewCard && selectedPlace && ( -
- -
- )} - - {showRouteSection && ( -
-
- - Route - - -
- -
-
- - - {routeStart?.name || 'Right-click to set start'} - -
-
- - - {routeEnd?.name || 'Right-click to set destination'} - -
-
- -
- {TRAVEL_MODES.map((m) => { - const active = routeMode === m.id - return ( - - ) - })} -
- -
- {BOUNDARY_MODES.map((m) => { - const active = boundaryMode === m.id - return ( - - ) - })} -
- - -
- )} - - {showEmptyState && ( -
-

Search or tap the map to explore

-
- )} - - ) - - const content = ( - <> - {showContacts && ( -
- - -
- )} - - {(!showContacts || activeTab === 'routes') ? routesContent : } - - ) - - const header = ( -
-

Navi

-
- {auth.loaded && ( - auth.authenticated ? ( - - ) : ( - - ) - )} - -
-
- ) - - if (!isMobile) { - return ( -
- {header} - {content} -
- ) - } - - const sheetHeights = { - collapsed: 'h-12', - half: 'h-[45vh]', - full: 'h-[85vh]', - } - - return ( -
-
{ - if (sheetState === 'collapsed') setSheetState('half') - else if (sheetState === 'half') setSheetState('full') - else setSheetState('half') - }} - > -
-
- - {sheetState !== 'collapsed' && ( -
- {header} - {content} -
- )} -
- ) -} +import { useRef, useCallback, useEffect, useState } from 'react' +import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' +import ThemePicker from './ThemePicker' +import { useStore, usePanelState } from '../store' +import { hasFeature } from '../config' +import SearchBar from './SearchBar' +import ManeuverList from './ManeuverList' +import ContactList from './ContactList' +import { PlaceCard } from './PlaceCard' + +const TRAVEL_MODES = [ + { id: 'auto', label: 'Drive', Icon: Car }, + { id: 'foot', label: 'Foot', Icon: Footprints }, + { id: 'mtb', label: 'MTB', Icon: Bike }, + { id: 'atv', label: 'ATV', Icon: Car }, + { id: 'vehicle', label: '4x4', Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, + { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, + { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, +] + +export default function Panel({ onClearRoute }) { + const selectedPlace = useStore((s) => s.selectedPlace) + const 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 routeLoading = useStore((s) => s.routeLoading) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const clearRoute = useStore((s) => s.clearRoute) + const sheetState = useStore((s) => s.sheetState) + const setSheetState = useStore((s) => s.setSheetState) + const activeTab = useStore((s) => s.activeTab) + const auth = useStore((s) => s.auth) + const setActiveTab = useStore((s) => s.setActiveTab) + + const panelState = usePanelState() + + const [isMobile, setIsMobile] = useState(false) + const sheetRef = useRef(null) + const dragStartY = useRef(0) + const dragStartState = useRef('half') + + const showContacts = hasFeature('has_contacts') && auth.authenticated + + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768) + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + + const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } + const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } + + const handleTouchStart = useCallback((e) => { + dragStartY.current = e.touches[0].clientY + dragStartState.current = sheetState + }, [sheetState]) + + const handleTouchEnd = useCallback((e) => { + const deltaY = e.changedTouches[0].clientY - dragStartY.current + if (Math.abs(deltaY) < 30) return + if (deltaY < 0) { + if (dragStartState.current === 'collapsed') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('full') + } else { + if (dragStartState.current === 'full') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('collapsed') + } + }, [setSheetState]) + + const handleClearRoute = () => { + clearRoute() + onClearRoute?.() + } + + const showPreviewCard = panelState.startsWith('PREVIEW') + const hasRoutePoints = routeStart || routeEnd + const showRouteSection = hasRoutePoints || routeResult || routeLoading + const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + + const routesContent = ( + <> + + + {showPreviewCard && selectedPlace && ( +
+ +
+ )} + + {showRouteSection && ( +
+
+ + Route + + +
+ +
+
+ + + {routeStart?.name || 'Right-click to set start'} + +
+
+ + + {routeEnd?.name || 'Right-click to set destination'} + +
+
+ +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {routeMode !== 'auto' && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + +
+ )} + + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+
+ {auth.loaded && ( + auth.authenticated ? ( + + ) : ( + + ) + )} + +
+
+ ) + + if (!isMobile) { + return ( +
+ {header} + {content} +
+ ) + } + + const sheetHeights = { + collapsed: 'h-12', + half: 'h-[45vh]', + full: 'h-[85vh]', + } + + return ( +
+
{ + if (sheetState === 'collapsed') setSheetState('half') + else if (sheetState === 'half') setSheetState('full') + else setSheetState('half') + }} + > +
+
+ + {sheetState !== 'collapsed' && ( +
+ {header} + {content} +
+ )} +
+ ) +} diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx index 2e47bd9..1215a08 100644 --- a/src/components/SearchBar.jsx +++ b/src/components/SearchBar.jsx @@ -6,6 +6,30 @@ import { buildAddress } from '../utils/place' import { searchGeocode } from '../api' import { hasFeature } from '../config' + +/** Parse coordinate input like "42.35, -114.30" or "42.35 -114.30" */ +function parseCoordinates(input) { + if (!input) return null + const trimmed = input.trim() + + // Pattern: lat, lon or lat lon (with optional comma) + // Supports: "42.35, -114.30", "42.35 -114.30", "42.35,-114.30" + const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ + const match = trimmed.match(pattern) + + if (!match) return null + + const lat = parseFloat(match[1]) + const lon = parseFloat(match[2]) + + // Validate ranges + if (isNaN(lat) || isNaN(lon)) return null + if (lat < -90 || lat > 90) return null + if (lon < -180 || lon > 180) return null + + return { lat, lon } +} + /** Get category icon based on result type/source */ function CategoryIcon({ result }) { const type = result.type || '' @@ -71,6 +95,25 @@ const SearchBar = forwardRef(function SearchBar(_, ref) { return } + // Check for coordinate input first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setAutocompleteOpen(true) + setSearchLoading(false) + return + } + // Prepend matching contacts let contactResults = [] if (hasFeature('has_contacts') && contacts.length > 0) { diff --git a/src/store.js b/src/store.js index 2cf78ee..a4039dc 100644 --- a/src/store.js +++ b/src/store.js @@ -1,163 +1,271 @@ -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 }), - - // ── 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 ── - // Single routing system - all routes go through /api/offroute - routeStart: null, // { lat, lon, name } - routeEnd: null, // { lat, lon, name } - routeMode: "foot", // foot | mtb | atv | vehicle - boundaryMode: "strict", // strict | pragmatic | emergency - routeResult: null, // Response from /api/offroute - routeLoading: false, - routeError: null, - - setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), - setRouteEnd: (place) => set({ routeEnd: place }), - setRouteMode: (mode) => set({ routeMode: mode }), - setBoundaryMode: (mode) => set({ boundaryMode: mode }), - setRouteResult: (result) => set({ routeResult: result, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, routeResult: null }), - clearRoute: () => set({ - routeStart: null, - routeEnd: null, - routeResult: null, - routeError: null - }), - - // ── Legacy compatibility (for components not yet migrated) ── - stops: [], - gpsOrigin: false, - pendingDestination: null, - route: null, - - addStop: (stop) => { - // Legacy: just set as route end point - const { routeStart, setRouteEnd } = get() - const place = { lat: stop.lat, lon: stop.lon, name: stop.name } - if (!routeStart) { - set({ routeStart: place, stops: [{ ...stop, id: crypto.randomUUID() }] }) - } else { - setRouteEnd(place) - set({ stops: [...get().stops, { ...stop, id: crypto.randomUUID() }] }) - } - return true - }, - removeStop: (id) => { - const { stops } = get() - const newStops = stops.filter((s) => s.id !== id) - set({ stops: newStops }) - if (newStops.length === 0) { - get().clearRoute() - } - }, - clearStops: () => set({ stops: [], routeStart: null, routeEnd: null }), - setStops: (stops) => set({ stops }), - reorderStops: (newStops) => set({ stops: newStops }), - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - startDirections: (place) => { - // Legacy: set as destination - const { routeStart, setRouteEnd, clearRoute } = get() - clearRoute() - set({ - routeEnd: { lat: place.lat, lon: place.lon, name: place.name }, - stops: [{ ...place, id: crypto.randomUUID() }], - 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, - 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 }), - 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' +import { requestOffroute, requestOptimizedRoute } 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 ── + // Single routing system - all routes go through /api/offroute + routeStart: null, // { lat, lon, name } + routeEnd: null, // { 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, + routeResult: null, + routeError: null, + stops: [], + route: null + }) + }, + + // ── UNIFIED ROUTING TRIGGER ── + // This is the SINGLE routing function for everything + computeRoute: async () => { + const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() + + // Need both endpoints to route + if (!routeStart || !routeEnd) return + + set({ routeLoading: true, routeError: null }) + + try { + 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 }) + } + } catch (e) { + set({ routeError: e.message, routeResult: null }) + } finally { + set({ routeLoading: false }) + } + }, + + // ── Stop list (master compatibility) ── + stops: [], + gpsOrigin: true, // whether GPS should be used as origin when available + pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) + route: null, // Legacy Valhalla response (for 3+ stop optimization) + + addStop: (stop) => { + const { stops, routeMode, _updateRouteDisplay } = get() + if (stops.length >= 10) return false + const newStops = [...stops, { ...stop, id: crypto.randomUUID() }] + set({ stops: newStops }) + + // Route logic depends on stop count + if (newStops.length === 1) { + // Single stop = origin, waiting for second + const origin = newStops[0] + set({ routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name } }) + } else if (newStops.length === 2) { + // Two stops = use offroute (handles on-road and wilderness) + const origin = newStops[0] + const dest = newStops[1] + set({ + routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, + routeEnd: { lat: dest.lat, lon: dest.lon, name: dest.name } + }) + get().computeRoute() + } else { + // 3+ stops = use Valhalla multi-stop optimization + set({ routeLoading: true, routeError: null }) + const locations = newStops.map((s) => ({ lat: s.lat, lon: s.lon })) + const costing = routeMode === "auto" ? "auto" : routeMode === "foot" ? "pedestrian" : routeMode === "mtb" ? "bicycle" : "auto" + requestOptimizedRoute(locations, costing) + .then((data) => { + if (data.trip) { + set({ route: data.trip, routeError: null }) + // Update display via legacy route handler if available + if (_updateRouteDisplay && data.trip) { + // Multi-stop uses legacy route format, need to convert or use separate handler + } + } + }) + .catch((e) => set({ routeError: e.message })) + .finally(() => set({ routeLoading: false })) + } + + return true + }, + + removeStop: (id) => { + const { stops } = get() + const newStops = stops.filter((s) => s.id !== id) + set({ stops: newStops }) + if (newStops.length === 0) { + get().clearRoute() + } else if (newStops.length === 1) { + // Back to single stop + const origin = newStops[0] + set({ + routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, + routeEnd: null, + routeResult: null + }) + } + }, + + reorderStops: (newStops) => set({ stops: newStops }), + + clearStops: () => { + const { _clearRouteDisplay } = get() + if (_clearRouteDisplay) _clearRouteDisplay() + set({ stops: [], routeStart: null, routeEnd: null, routeResult: null, routeError: null }) + }, + + setStops: (stops) => set({ stops }), + + setGpsOrigin: (val) => set({ gpsOrigin: val }), + setPendingDestination: (place) => set({ pendingDestination: place }), + clearPendingDestination: () => set({ pendingDestination: null }), + + // Master startDirections - restored verbatim + 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 }) + } + }, + + // Legacy route setter (for 3+ stop Valhalla optimization) + setRoute: (route) => set({ route, routeError: null }), + setRouteError: (err) => set({ routeError: err, route: 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 + + 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 }), + + // ── UI state ── + sheetState: 'half', // 'collapsed' | 'half' | 'full' + panelOpen: true, + autocompleteOpen: false, + theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) + themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' + + setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem('navi-view-mode', mode) + }, + setPanelOpen: (open) => set({ panelOpen: open }), + setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setTheme: (theme) => set({ theme }), + setThemeOverride: (override) => { + set({ themeOverride: override }) + if (override) { + localStorage.setItem('navi-theme-override', override) + } else { + localStorage.removeItem('navi-theme-override') + } + }, + + // ── Auth state ── + auth: { authenticated: false, username: null, loaded: false }, + setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), + + // ── Contacts ── + contacts: [], + contactsLoaded: false, + activeTab: 'routes', // 'routes' | 'contacts' + editingContact: null, // null=closed, {}=new, {id:N}=edit + pickingLocationFor: null, // form data while user picks location on map + + setContacts: (c) => set({ contacts: c, contactsLoaded: true }), + setActiveTab: (tab) => set({ activeTab: tab }), + setEditingContact: (c) => set({ editingContact: c }), + clearEditingContact: () => set({ editingContact: null }), + setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), + clearPickingLocationFor: () => set({ pickingLocationFor: null }), +})) + +// ── Panel state selector ── +// Returns string state, prioritizing preview to allow it alongside any route state +export const usePanelState = () => { + return useStore((s) => { + const hasPreview = !!s.selectedPlace + const hasRoute = !!s.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" + }) +} From 7523ddd0a2478adcbd64de3e3603dd651cd875a8 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 22:44:45 +0000 Subject: [PATCH 45/56] feat: add directions panel with editable origin/destination inputs New UX for Get Directions: - DirectionsPanel component with two stacked input fields - LocationInput component with autocomplete, coordinate parsing - Swap button to flip origin/destination - Travel mode selector (Drive default, Foot, MTB, ATV, 4x4) - Boundary selector (only visible for non-Drive modes) - Map click fills active input field with crosshair cursor - Auto-route when both endpoints are filled - X button closes directions and returns to search view Store changes: - directionsMode state for panel switching - activeDirectionsField for map click targeting - startDirections now enters directions mode with destination pre-filled Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 263 +++++++++++++++++++++++++ src/components/LocationInput.jsx | 301 +++++++++++++++++++++++++++++ src/components/MapView.jsx | 50 ++++- src/components/Panel.jsx | 10 +- src/store.js | 49 +++-- 5 files changed, 656 insertions(+), 17 deletions(-) create mode 100644 src/components/DirectionsPanel.jsx create mode 100644 src/components/LocationInput.jsx diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx new file mode 100644 index 0000000..d2f7db4 --- /dev/null +++ b/src/components/DirectionsPanel.jsx @@ -0,0 +1,263 @@ +import { useEffect } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" +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" }, +] + +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 addStop = useStore((s) => s.addStop) + const removeStop = useStore((s) => s.removeStop) + const reorderStops = useStore((s) => s.reorderStops) + + // 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 handleSwap = () => { + const tempStart = routeStart + const tempEnd = routeEnd + setRouteStart(tempEnd) + setRouteEnd(tempStart) + } + + const handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + // Insert a stop between origin and destination + // For now, this adds to the stops array + // The UI will show intermediate stops + } + + // Multi-stop support: show intermediate stops from the stops array + const intermediateStops = stops.slice(1, -1) // Everything except first and last + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Origin/Destination inputs with swap button */} +
+ {/* Origin */} + + + {/* Swap button - positioned between inputs */} + + + {/* Intermediate stops (for multi-stop routes) */} + {intermediateStops.map((stop, idx) => ( +
+ { + if (place) { + const newStops = [...stops] + newStops[idx + 1] = { ...newStops[idx + 1], ...place } + reorderStops(newStops) + } else { + removeStop(stop.id) + } + }} + placeholder="Stop" + icon="stop" + fieldId={`stop-${idx}`} + /> +
+ ))} + + {/* Destination */} + + + {/* Add stop button */} + {routeStart && routeEnd && stops.length < 10 && ( + + )} +
+ + {/* 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 */} + {routeError && ( +
+ {routeError} +
+ )} + + {/* Route summary and maneuvers */} + {routeResult && !routeLoading && ( +
+ +
+ )} + + {/* Hint when waiting for input */} + {!routeStart && !routeEnd && !routeLoading && ( +
+

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

+
+ )} +
+ ) +} diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx new file mode 100644 index 0000000..a15b1bb --- /dev/null +++ b/src/components/LocationInput.jsx @@ -0,0 +1,301 @@ +import { useRef, useEffect, useCallback, useState } from "react" +import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2 } from "lucide-react" +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) + + // 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) + if (results.length > 0) setOpen(true) + onFocus?.() + } + + 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" ? ( + + ) : ( + + )} + + {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/MapView.jsx b/src/components/MapView.jsx index ca9b5c5..a46eac5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1441,6 +1441,8 @@ const MapView = forwardRef(function MapView(_, ref) { 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) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) @@ -1999,7 +2001,37 @@ const MapView = forwardRef(function MapView(_, ref) { return } - + // Handle directions mode — click fills the active field + const { directionsMode, activeDirectionsField, setRouteStart, setRouteEnd, setActiveDirectionsField } = useStore.getState() + if (directionsMode && activeDirectionsField) { + const { lng, lat } = e.lngLat + // 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 (activeDirectionsField === "origin") { + setRouteStart(location) + setActiveDirectionsField("destination") + } else if (activeDirectionsField === "destination") { + setRouteEnd(location) + setActiveDirectionsField(null) + } else if (activeDirectionsField.startsWith("stop-")) { + // Handle intermediate stops - would need more logic + setActiveDirectionsField(null) + } + }).catch(() => { + const name = lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (activeDirectionsField === "origin") { + setRouteStart(location) + setActiveDirectionsField("destination") + } else if (activeDirectionsField === "destination") { + setRouteEnd(location) + setActiveDirectionsField(null) + } + }) + return + } const store = useStore.getState() const marker = store.clickMarker @@ -2694,6 +2726,22 @@ const MapView = forwardRef(function MapView(_, ref) { } }, [pickingLocationFor]) + // Handle directions mode cursor + useEffect(() => { + const map = mapInstance.current + if (!map) return + if (directionsMode && activeDirectionsField) { + map.getCanvas().style.cursor = 'crosshair' + } else if (!measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + return () => { + if (map && !measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + } + }, [directionsMode, activeDirectionsField]) + // ESC key handler for location pick mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index efc9b5a..a708734 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -7,6 +7,7 @@ import SearchBar from './SearchBar' import ManeuverList from './ManeuverList' import ContactList from './ContactList' import { PlaceCard } from './PlaceCard' +import DirectionsPanel from './DirectionsPanel' const TRAVEL_MODES = [ { id: 'auto', label: 'Drive', Icon: Car }, @@ -39,6 +40,8 @@ export default function Panel({ onClearRoute }) { 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() @@ -86,7 +89,12 @@ export default function Panel({ onClearRoute }) { const showRouteSection = hasRoutePoints || routeResult || routeLoading const showEmptyState = panelState === 'IDLE' && !hasRoutePoints - const routesContent = ( + const routesContent = directionsMode ? ( + { + setDirectionsMode(false) + onClearRoute?.() + }} /> + ) : ( <> diff --git a/src/store.js b/src/store.js index a4039dc..4ea2839 100644 --- a/src/store.js +++ b/src/store.js @@ -173,23 +173,38 @@ export const useStore = create((set, get) => ({ setPendingDestination: (place) => set({ pendingDestination: place }), clearPendingDestination: () => set({ pendingDestination: null }), - // Master startDirections - restored verbatim + // Master startDirections - enters directions mode with destination pre-filled 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 }) + const { geoPermission, userLocation, clearRoute } = get() + clearRoute() + + // Set destination from the clicked place + const destination = { + lat: place.lat, + lon: place.lon, + name: place.name, + source: place.source, + matchCode: place.matchCode, } + + // Set origin from GPS if available + 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', // Focus origin if empty + selectedPlace: null, + }) }, // Legacy route setter (for 3+ stop Valhalla optimization) @@ -213,6 +228,8 @@ export const useStore = create((set, get) => ({ sheetState: 'half', // 'collapsed' | 'half' | 'full' panelOpen: true, autocompleteOpen: false, + directionsMode: false, // true when directions panel is active + activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for map click targeting) theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' @@ -224,6 +241,8 @@ export const useStore = create((set, get) => ({ }, setPanelOpen: (open) => set({ panelOpen: open }), setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }), + setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), setTheme: (theme) => set({ theme }), setThemeOverride: (override) => { set({ themeOverride: override }) From a6942b35ea1c58476b81e268351257925ba42442 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 23:08:38 +0000 Subject: [PATCH 46/56] fix: preserve click coordinates for wilderness routing When clicking on a labeled feature (e.g., "Monument Peak"), the code was using the feature's canonical coordinates instead of the actual click coordinates. This caused wilderness clicks to snap to named places that might be on roads, bypassing wilderness routing. Fix: Always use click coordinates (e.lngLat) for routing purposes. Feature coordinates are only used for display/detail fetching. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index a46eac5..00b7bf5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2199,12 +2199,16 @@ const MapView = forwardRef(function MapView(_, ref) { const props = labelFeature.properties const geom = labelFeature.geometry - // Get feature coordinates (Point geometry) - let featureLat = lat - let featureLon = lng + // CRITICAL: Always use CLICK coordinates for routing (lat, lng from e.lngLat) + // Feature coordinates are only for display/fetching details + let featureLat = lat // Click coordinate - used for routing + let featureLon = lng // Click coordinate - used for routing + let displayLat = lat // May be updated to feature coords for display + let displayLon = lng if (geom && geom.type === 'Point' && geom.coordinates) { - featureLon = geom.coordinates[0] - featureLat = geom.coordinates[1] + // Store feature's canonical coords separately - NOT for routing + displayLon = geom.coordinates[0] + displayLat = geom.coordinates[1] } // FIX A: For park-type features, also query polygon layers to get boundary geometry From 19a96cba5e42dc2c4730812bfa8d1def4b163dc0 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 03:37:05 +0000 Subject: [PATCH 47/56] feat: improve directions panel with route legend and place card below - Add route legend showing wilderness (dashed orange) vs road (solid blue) - Show place card below directions panel when clicking map during routing - Clean up error messages to be user-friendly (no offroute text) - Legend only appears when route has wilderness segments Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 562 +++++++++++++++-------------- src/components/Panel.jsx | 21 +- 2 files changed, 316 insertions(+), 267 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index d2f7db4..794cad6 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,263 +1,299 @@ -import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" -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" }, -] - -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 addStop = useStore((s) => s.addStop) - const removeStop = useStore((s) => s.removeStop) - const reorderStops = useStore((s) => s.reorderStops) - - // 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 handleSwap = () => { - const tempStart = routeStart - const tempEnd = routeEnd - setRouteStart(tempEnd) - setRouteEnd(tempStart) - } - - const handleClose = () => { - clearRoute() - setDirectionsMode(false) - onClose?.() - } - - const handleAddStop = () => { - // Insert a stop between origin and destination - // For now, this adds to the stops array - // The UI will show intermediate stops - } - - // Multi-stop support: show intermediate stops from the stops array - const intermediateStops = stops.slice(1, -1) // Everything except first and last - - return ( -
- {/* Header */} -
- - Directions - - -
- - {/* Origin/Destination inputs with swap button */} -
- {/* Origin */} - - - {/* Swap button - positioned between inputs */} - - - {/* Intermediate stops (for multi-stop routes) */} - {intermediateStops.map((stop, idx) => ( -
- { - if (place) { - const newStops = [...stops] - newStops[idx + 1] = { ...newStops[idx + 1], ...place } - reorderStops(newStops) - } else { - removeStop(stop.id) - } - }} - placeholder="Stop" - icon="stop" - fieldId={`stop-${idx}`} - /> -
- ))} - - {/* Destination */} - - - {/* Add stop button */} - {routeStart && routeEnd && stops.length < 10 && ( - - )} -
- - {/* 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 */} - {routeError && ( -
- {routeError} -
- )} - - {/* Route summary and maneuvers */} - {routeResult && !routeLoading && ( -
- -
- )} - - {/* Hint when waiting for input */} - {!routeStart && !routeEnd && !routeLoading && ( -
-

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

-
- )} -
- ) -} +import { useEffect } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" +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" }, +] + +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 addStop = useStore((s) => s.addStop) + const removeStop = useStore((s) => s.removeStop) + const reorderStops = useStore((s) => s.reorderStops) + + // 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 handleSwap = () => { + const tempStart = routeStart + const tempEnd = routeEnd + setRouteStart(tempEnd) + setRouteEnd(tempStart) + } + + const handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + // For now, show a message - multi-stop UI is complex + // TODO: Implement full multi-stop UI + } + + // Check if route has wilderness segments + const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 + + // Multi-stop support: show intermediate stops from the stops array + const intermediateStops = stops.slice(1, -1) + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Origin/Destination inputs with swap button */} +
+ {/* Origin */} + + + {/* Swap button - positioned between inputs */} + + + {/* Intermediate stops (for multi-stop routes) */} + {intermediateStops.map((stop, idx) => ( +
+ { + if (place) { + const newStops = [...stops] + newStops[idx + 1] = { ...newStops[idx + 1], ...place } + reorderStops(newStops) + } else { + removeStop(stop.id) + } + }} + placeholder="Stop" + icon="stop" + fieldId={`stop-${idx}`} + /> +
+ ))} + + {/* Destination */} + + + {/* Add stop button - only show when route exists */} + {routeStart && routeEnd && stops.length < 10 && ( + + )} +
+ + {/* 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/Panel.jsx b/src/components/Panel.jsx index a708734..98e9f16 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -90,10 +90,23 @@ export default function Panel({ onClearRoute }) { const showEmptyState = panelState === 'IDLE' && !hasRoutePoints const routesContent = directionsMode ? ( - { - setDirectionsMode(false) - onClearRoute?.() - }} /> + <> + { + setDirectionsMode(false) + onClearRoute?.() + }} /> + {/* Show place card below directions when clicking map during routing */} + {selectedPlace && ( +
+ +
+ )} + ) : ( <> From 816ea8dd1f84380968892cb5d7239dee8449eea2 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 06:09:14 +0000 Subject: [PATCH 48/56] feat: wilderness maneuvers, pick-from-map, distance formatting, place card panel - Wilderness maneuvers render with compass arrows and cardinal directions - Network maneuvers prefixed with transport mode (Drive/Walk/Ride) - Distances under 1 mile show feet with commas - Pick-from-map mode replaces auto-fill-on-focus (crosshair + toast) - ESC cancels pick mode - Place card slides out right during active routing - Removed debug toasts Co-Authored-By: Claude Opus 4.5 --- src/api.js | 1 + src/components/LocationInput.jsx | 622 ++++++++++++++++--------------- src/components/ManeuverList.jsx | 233 ++++++++++-- src/components/MapView.jsx | 27 +- src/components/Panel.jsx | 147 ++++++-- src/components/PlaceCard.jsx | 1 + src/store.js | 10 +- 7 files changed, 663 insertions(+), 378 deletions(-) diff --git a/src/api.js b/src/api.js index 47d5861..bed21ec 100644 --- a/src/api.js +++ b/src/api.js @@ -342,6 +342,7 @@ export async function requestOffroute(start, end, mode = "foot", boundaryMode = 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 diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx index a15b1bb..eb0a204 100644 --- a/src/components/LocationInput.jsx +++ b/src/components/LocationInput.jsx @@ -1,301 +1,321 @@ -import { useRef, useEffect, useCallback, useState } from "react" -import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2 } from "lucide-react" -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) - - // 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) - if (results.length > 0) setOpen(true) - onFocus?.() - } - - 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" ? ( - - ) : ( - - )} - - {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} -
    - )} -
  • - ) - })} -
- )} -
- ) -} +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 a8b90b0..44d1ffc 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -1,13 +1,32 @@ import { MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, - GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle + GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle, + Compass, ArrowUp, ArrowUpRight, ArrowRight, ArrowDownRight, ArrowDown, + ArrowDownLeft, ArrowLeft, ArrowUpLeft, MapPin } from 'lucide-react' import { useStore } from '../store' -function formatDistKm(km) { - const miles = km * 0.621371 - if (miles < 0.1) return Math.round(miles * 5280) + ' ft' +/** + * 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' } @@ -18,6 +37,51 @@ function formatTimeMin(minutes) { 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 } @@ -40,10 +104,55 @@ function ManeuverIcon({ type }) { } } +/** + * 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 ( @@ -77,8 +186,25 @@ export default function ManeuverList() { if (!routeResult?.summary) return null const summary = routeResult.summary - const networkFeature = routeResult.route?.features?.find(f => f.properties?.segment_type === 'network') - const maneuvers = networkFeature?.properties?.maneuvers || [] + 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 (
@@ -88,7 +214,7 @@ export default function ManeuverList() { style={{ background: 'var(--bg-overlay)', border: '1px solid var(--border-subtle)' }} > - {formatDistKm(summary.total_distance_km)} + {formatDistance(null, summary.total_distance_km)} {formatTimeMin(summary.total_effort_minutes)} @@ -102,7 +228,7 @@ export default function ManeuverList() { Wilderness - {formatDistKm(summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)} + {formatDistance(null, summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)}
)} @@ -111,7 +237,7 @@ export default function ManeuverList() { Road/Trail - {formatDistKm(summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)} + {formatDistance(null, summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)}
)} @@ -136,27 +262,80 @@ export default function ManeuverList() { )} {/* Turn-by-turn directions */} - {maneuvers.length > 0 && ( + {hasManeuvers && (
Directions
- {maneuvers.map((man, i) => ( -
- - - -
-

- {man.instruction} -

-

- {formatDistKm(man.distance_km)} -

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

+ {man.instruction} +

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

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

+

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

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

+ {man.instruction} +

+
+
+ ))} + + )}
)}
diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 00b7bf5..c77994b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1443,6 +1443,7 @@ const MapView = forwardRef(function MapView(_, ref) { 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) @@ -2001,34 +2002,30 @@ const MapView = forwardRef(function MapView(_, ref) { return } - // Handle directions mode — click fills the active field - const { directionsMode, activeDirectionsField, setRouteStart, setRouteEnd, setActiveDirectionsField } = useStore.getState() - if (directionsMode && activeDirectionsField) { + // 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 (activeDirectionsField === "origin") { + if (pickingRouteField === "origin") { setRouteStart(location) - setActiveDirectionsField("destination") - } else if (activeDirectionsField === "destination") { + } else if (pickingRouteField === "destination") { setRouteEnd(location) - setActiveDirectionsField(null) - } else if (activeDirectionsField.startsWith("stop-")) { - // Handle intermediate stops - would need more logic - setActiveDirectionsField(null) } + clearPickingRouteField() }).catch(() => { const name = lat.toFixed(5) + ", " + lng.toFixed(5) const location = { lat, lon: lng, name, source: "map_click" } - if (activeDirectionsField === "origin") { + if (pickingRouteField === "origin") { setRouteStart(location) - setActiveDirectionsField("destination") - } else if (activeDirectionsField === "destination") { + } else if (pickingRouteField === "destination") { setRouteEnd(location) - setActiveDirectionsField(null) } + clearPickingRouteField() }) return } @@ -2253,6 +2250,7 @@ const MapView = forwardRef(function MapView(_, ref) { 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, @@ -2284,6 +2282,7 @@ const MapView = forwardRef(function MapView(_, ref) { circleRadiusPx: MARKER_RADIUS_PX, }) + console.log('[TRACE-CLICK] Reticle click setSelectedPlace:', { lat, lng }) store.setSelectedPlace({ lat, lon: lng, diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 98e9f16..b89c661 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,5 +1,5 @@ import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' +import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin, Target } from 'lucide-react' import ThemePicker from './ThemePicker' import { useStore, usePanelState } from '../store' import { hasFeature } from '../config' @@ -8,6 +8,7 @@ import ManeuverList from './ManeuverList' import ContactList from './ContactList' import { PlaceCard } from './PlaceCard' import DirectionsPanel from './DirectionsPanel' +import PlaceDetail from './PlaceDetail' const TRAVEL_MODES = [ { id: 'auto', label: 'Drive', Icon: Car }, @@ -34,6 +35,8 @@ export default function Panel({ onClearRoute }) { 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 sheetState = useStore((s) => s.sheetState) const setSheetState = useStore((s) => s.setSheetState) @@ -89,29 +92,20 @@ export default function Panel({ onClearRoute }) { const showRouteSection = hasRoutePoints || routeResult || routeLoading const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + // Show side panel place card when building route (either mode) and place is selected + const showSidePlaceCard = (directionsMode || showRouteSection) && selectedPlace + const routesContent = directionsMode ? ( - <> - { - setDirectionsMode(false) - onClearRoute?.() - }} /> - {/* Show place card below directions when clicking map during routing */} - {selectedPlace && ( -
- -
- )} - + // Directions mode: just the directions panel, place card is shown in side panel + { + setDirectionsMode(false) + onClearRoute?.() + }} /> ) : ( <> - {showPreviewCard && selectedPlace && ( + {showPreviewCard && selectedPlace && !showRouteSection && (
- - {routeStart?.name || 'Right-click to set start'} + + {routeStart?.name || 'Click pin to pick start'} +
- - {routeEnd?.name || 'Right-click to set destination'} + + {routeEnd?.name || 'Click pin to pick destination'} +
@@ -263,19 +273,85 @@ export default function Panel({ onClearRoute }) {
) + // Side panel for place card during directions mode (desktop only) + const sidePlaceCardPanel = showSidePlaceCard && !isMobile && ( +
+
+ + {selectedPlace?.name || 'Place Info'} + + +
+ {/* Use PlaceCard in compact preview mode */} + +
+ ) + + // Mobile overlay for place card during directions mode + const mobilePlaceCardOverlay = showSidePlaceCard && isMobile && ( +
+
+ + {selectedPlace?.name || 'Place Info'} + + +
+
+ +
+
+ ) + if (!isMobile) { return ( -
- {header} - {content} -
+ <> +
+ {header} + {content} +
+ {sidePlaceCardPanel} + ) } @@ -308,9 +384,10 @@ export default function Panel({ onClearRoute }) {
{sheetState !== 'collapsed' && ( -
+
{header} {content} + {mobilePlaceCardOverlay}
)}
diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 3afa72b..84ecb6b 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -476,6 +476,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon) const 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/store.js b/src/store.js index 4ea2839..8b98599 100644 --- a/src/store.js +++ b/src/store.js @@ -72,6 +72,10 @@ export const useStore = create((set, get) => ({ // This is the SINGLE routing function for everything computeRoute: async () => { const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() + console.log('[TRACE-ROUTE] computeRoute called with:', { + startLat: routeStart?.lat, startLon: routeStart?.lon, startName: routeStart?.name, + endLat: routeEnd?.lat, endLon: routeEnd?.lon, endName: routeEnd?.name + }) // Need both endpoints to route if (!routeStart || !routeEnd) return @@ -175,6 +179,7 @@ export const useStore = create((set, get) => ({ // 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() @@ -229,7 +234,8 @@ export const useStore = create((set, get) => ({ panelOpen: true, autocompleteOpen: false, directionsMode: false, // true when directions panel is active - activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for map click targeting) + activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for input focus styling) + pickingRouteField: null, // 'origin' | 'destination' | null (explicit pick-from-map mode) theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' @@ -243,6 +249,8 @@ export const useStore = create((set, get) => ({ 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 }) From 2345334bc71db886748213fce6dbfb0ad2776fca Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 08:23:38 +0000 Subject: [PATCH 49/56] feat: wire up radial menu directions and multi-stop add button - Radial menu "From here" now sets origin and opens directions panel - Radial menu "To here" now sets destination, opens directions panel, and uses GPS as origin fallback when available - DirectionsPanel "Add stop" button now creates intermediate stops - Stops array initialized from routeStart/routeEnd when adding stops Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 39 ++++++++++++++++++++++++++++-- src/components/MapView.jsx | 25 +++++++++++++------ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index 794cad6..b485636 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -74,8 +74,43 @@ export default function DirectionsPanel({ onClose }) { } const handleAddStop = () => { - // For now, show a message - multi-stop UI is complex - // TODO: Implement full multi-stop UI + // Build stops array from current route endpoints if not already populated + let newStops = [...stops] + + // If stops is empty but we have endpoints, initialize from routeStart/routeEnd + if (newStops.length === 0) { + if (routeStart) { + newStops.push({ + id: crypto.randomUUID(), + lat: routeStart.lat, + lon: routeStart.lon, + name: routeStart.name || "Start", + }) + } + if (routeEnd) { + newStops.push({ + id: crypto.randomUUID(), + lat: routeEnd.lat, + lon: routeEnd.lon, + name: routeEnd.name || "Destination", + }) + } + } + + // Create placeholder intermediate stop + const newStop = { + id: crypto.randomUUID(), + lat: null, + lon: null, + name: "", + } + + // Insert before destination (last position), or at end if no destination + const insertIdx = Math.max(0, newStops.length - 1) + newStops.splice(insertIdx, 0, newStop) + + // Update stops array - reorderStops triggers UI update + reorderStops(newStops) } // Check if route has wilderness segments diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index c77994b..0f13fd1 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1672,13 +1672,24 @@ const MapView = forwardRef(function MapView(_, ref) { lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), } - const { routeStart, setRouteEnd, computeRoute } = useStore.getState() + const { routeStart, setRouteStart, setRouteEnd, computeRoute, setDirectionsMode, geoPermission, userLocation } = useStore.getState() + setRouteEnd(place) + setDirectionsMode(true) + if (routeStart) { computeRoute() - } else { - toast("Set starting point first") + } 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 }, }, { @@ -1692,16 +1703,16 @@ const MapView = forwardRef(function MapView(_, ref) { lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), } - const { clearRoute, setRouteStart, routeEnd, computeRoute } = useStore.getState() + const { clearRoute, setRouteStart, routeEnd, computeRoute, setDirectionsMode } = useStore.getState() clearRoute() clearRouteDisplay(mapInstance.current) setRouteStart(place) - // If we already have a destination, compute route immediately + setDirectionsMode(true) + if (routeEnd) { computeRoute() - } else { - toast("Now tap destination") } + // If no destination, directions panel opens and destination field auto-focuses }, }, { From 79413014a5cd2fbd6631af4c1298cc04a8f9176a Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 14:59:31 +0000 Subject: [PATCH 50/56] fix: separate stops[] from routeStart/routeEnd for multi-stop routing - stops[] now contains ONLY intermediate waypoints - routeStart and routeEnd are separate sources of truth - addIntermediateStop() adds empty placeholder to stops[] - updateStop() and removeStop() manage intermediate waypoints - computeRoute() chains sequential 2-point routes for multi-stop - DirectionsPanel renders: origin -> stops.map() -> destination - Each intermediate stop has remove button (Trash2 icon) Test scenarios verified: - Origin + destination routes normally (no stops involved) - Add Stop creates empty input between origin and destination - Setting intermediate location triggers route recalculation - Multiple stops can be added sequentially - Removing a stop recalculates route without it - Clear all returns to empty state Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 638 ++++++++++++++--------------- src/store.js | 609 +++++++++++++-------------- 2 files changed, 615 insertions(+), 632 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index b485636..2900e75 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,334 +1,304 @@ -import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" -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" }, -] - -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 addStop = useStore((s) => s.addStop) - const removeStop = useStore((s) => s.removeStop) - const reorderStops = useStore((s) => s.reorderStops) - - // 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 handleSwap = () => { - const tempStart = routeStart - const tempEnd = routeEnd - setRouteStart(tempEnd) - setRouteEnd(tempStart) - } - - const handleClose = () => { - clearRoute() - setDirectionsMode(false) - onClose?.() - } - - const handleAddStop = () => { - // Build stops array from current route endpoints if not already populated - let newStops = [...stops] - - // If stops is empty but we have endpoints, initialize from routeStart/routeEnd - if (newStops.length === 0) { - if (routeStart) { - newStops.push({ - id: crypto.randomUUID(), - lat: routeStart.lat, - lon: routeStart.lon, - name: routeStart.name || "Start", - }) - } - if (routeEnd) { - newStops.push({ - id: crypto.randomUUID(), - lat: routeEnd.lat, - lon: routeEnd.lon, - name: routeEnd.name || "Destination", - }) - } - } - - // Create placeholder intermediate stop - const newStop = { - id: crypto.randomUUID(), - lat: null, - lon: null, - name: "", - } - - // Insert before destination (last position), or at end if no destination - const insertIdx = Math.max(0, newStops.length - 1) - newStops.splice(insertIdx, 0, newStop) - - // Update stops array - reorderStops triggers UI update - reorderStops(newStops) - } - - // Check if route has wilderness segments - const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 - - // Multi-stop support: show intermediate stops from the stops array - const intermediateStops = stops.slice(1, -1) - - return ( -
- {/* Header */} -
- - Directions - - -
- - {/* Origin/Destination inputs with swap button */} -
- {/* Origin */} - - - {/* Swap button - positioned between inputs */} - - - {/* Intermediate stops (for multi-stop routes) */} - {intermediateStops.map((stop, idx) => ( -
- { - if (place) { - const newStops = [...stops] - newStops[idx + 1] = { ...newStops[idx + 1], ...place } - reorderStops(newStops) - } else { - removeStop(stop.id) - } - }} - placeholder="Stop" - icon="stop" - fieldId={`stop-${idx}`} - /> -
- ))} - - {/* Destination */} - - - {/* Add stop button - only show when route exists */} - {routeStart && routeEnd && stops.length < 10 && ( - - )} -
- - {/* 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 -

-
- )} -
- ) -} +import { useEffect } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2 } from "lucide-react" +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" }, +] + +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) + + // 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 handleSwap = () => { + const tempStart = routeStart + const tempEnd = routeEnd + setRouteStart(tempEnd) + setRouteEnd(tempStart) + } + + const handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + // Simply add a new empty intermediate stop + addIntermediateStop() + } + + // Check if route has wilderness segments + const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Origin/Destination inputs with swap button */} +
+ {/* Origin */} + + + {/* Intermediate stops - rendered between origin and destination */} + {stops.map((stop, idx) => ( +
+
+ { + if (place) { + updateStop(stop.id, place) + } + }} + placeholder={`Stop ${idx + 1}`} + icon="stop" + fieldId={`stop-${idx}`} + autoFocus={stop.lat == null} + /> +
+ +
+ ))} + + {/* Swap button - positioned between origin and destination (or after stops) */} + + + {/* Destination */} + + + {/* 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/store.js b/src/store.js index 8b98599..474163f 100644 --- a/src/store.js +++ b/src/store.js @@ -1,298 +1,311 @@ -import { create } from 'zustand' -import { requestOffroute, requestOptimizedRoute } 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 ── - // Single routing system - all routes go through /api/offroute - routeStart: null, // { lat, lon, name } - routeEnd: null, // { 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, - routeResult: null, - routeError: null, - stops: [], - route: null - }) - }, - - // ── UNIFIED ROUTING TRIGGER ── - // This is the SINGLE routing function for everything - computeRoute: async () => { - const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() - console.log('[TRACE-ROUTE] computeRoute called with:', { - startLat: routeStart?.lat, startLon: routeStart?.lon, startName: routeStart?.name, - endLat: routeEnd?.lat, endLon: routeEnd?.lon, endName: routeEnd?.name - }) - - // Need both endpoints to route - if (!routeStart || !routeEnd) return - - set({ routeLoading: true, routeError: null }) - - try { - 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 }) - } - } catch (e) { - set({ routeError: e.message, routeResult: null }) - } finally { - set({ routeLoading: false }) - } - }, - - // ── Stop list (master compatibility) ── - stops: [], - gpsOrigin: true, // whether GPS should be used as origin when available - pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) - route: null, // Legacy Valhalla response (for 3+ stop optimization) - - addStop: (stop) => { - const { stops, routeMode, _updateRouteDisplay } = get() - if (stops.length >= 10) return false - const newStops = [...stops, { ...stop, id: crypto.randomUUID() }] - set({ stops: newStops }) - - // Route logic depends on stop count - if (newStops.length === 1) { - // Single stop = origin, waiting for second - const origin = newStops[0] - set({ routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name } }) - } else if (newStops.length === 2) { - // Two stops = use offroute (handles on-road and wilderness) - const origin = newStops[0] - const dest = newStops[1] - set({ - routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, - routeEnd: { lat: dest.lat, lon: dest.lon, name: dest.name } - }) - get().computeRoute() - } else { - // 3+ stops = use Valhalla multi-stop optimization - set({ routeLoading: true, routeError: null }) - const locations = newStops.map((s) => ({ lat: s.lat, lon: s.lon })) - const costing = routeMode === "auto" ? "auto" : routeMode === "foot" ? "pedestrian" : routeMode === "mtb" ? "bicycle" : "auto" - requestOptimizedRoute(locations, costing) - .then((data) => { - if (data.trip) { - set({ route: data.trip, routeError: null }) - // Update display via legacy route handler if available - if (_updateRouteDisplay && data.trip) { - // Multi-stop uses legacy route format, need to convert or use separate handler - } - } - }) - .catch((e) => set({ routeError: e.message })) - .finally(() => set({ routeLoading: false })) - } - - return true - }, - - removeStop: (id) => { - const { stops } = get() - const newStops = stops.filter((s) => s.id !== id) - set({ stops: newStops }) - if (newStops.length === 0) { - get().clearRoute() - } else if (newStops.length === 1) { - // Back to single stop - const origin = newStops[0] - set({ - routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, - routeEnd: null, - routeResult: null - }) - } - }, - - reorderStops: (newStops) => set({ stops: newStops }), - - clearStops: () => { - const { _clearRouteDisplay } = get() - if (_clearRouteDisplay) _clearRouteDisplay() - set({ stops: [], routeStart: null, routeEnd: null, routeResult: null, routeError: null }) - }, - - setStops: (stops) => set({ stops }), - - 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() - - // Set destination from the clicked place - const destination = { - lat: place.lat, - lon: place.lon, - name: place.name, - source: place.source, - matchCode: place.matchCode, - } - - // Set origin from GPS if available - 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', // Focus origin if empty - selectedPlace: null, - }) - }, - - // Legacy route setter (for 3+ stop Valhalla optimization) - setRoute: (route) => set({ route, routeError: null }), - setRouteError: (err) => set({ routeError: err, route: 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 - - 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 }), - - // ── UI state ── - sheetState: 'half', // 'collapsed' | 'half' | 'full' - panelOpen: true, - autocompleteOpen: false, - directionsMode: false, // true when directions panel is active - activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for input focus styling) - pickingRouteField: null, // 'origin' | 'destination' | null (explicit pick-from-map mode) - theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) - themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) - viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem('navi-view-mode', mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - 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', // '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.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" +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 + + addIntermediateStop: () => { + const { stops } = get() + if (stops.length >= 8) return false // Max 8 intermediate stops + const newStop = { + id: crypto.randomUUID(), + lat: null, + lon: null, + 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" + }) +} From 0942b10b270652a0f850a6a9b61a33db3eed6c87 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 15:14:08 +0000 Subject: [PATCH 51/56] fix: swap button layout and add stop reorder buttons - Swap button now inline on origin row (not absolute positioned) - Swap button no longer overlaps intermediate stop controls - Added up/down chevron buttons on each intermediate stop row - Reordering stops triggers route recalculation - Destination row has spacer to align with origin row Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 123 ++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 38 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index 2900e75..44c0f98 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,5 +1,5 @@ import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2 } from "lucide-react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, ChevronUp, ChevronDown } from "lucide-react" import { useStore } from "../store" import LocationInput from "./LocationInput" import ManeuverList from "./ManeuverList" @@ -40,6 +40,7 @@ export default function DirectionsPanel({ onClose }) { const addIntermediateStop = useStore((s) => s.addIntermediateStop) const updateStop = useStore((s) => s.updateStop) const removeStop = useStore((s) => s.removeStop) + const setStops = useStore((s) => s.setStops) // Auto-fill origin with GPS if available and origin is empty useEffect(() => { @@ -74,10 +75,29 @@ export default function DirectionsPanel({ onClose }) { } const handleAddStop = () => { - // Simply add a new empty intermediate stop addIntermediateStop() } + const handleMoveStopUp = (idx) => { + if (idx === 0) return + const newStops = [...stops] + const temp = newStops[idx] + newStops[idx] = newStops[idx - 1] + newStops[idx - 1] = temp + setStops(newStops) + computeRoute() + } + + const handleMoveStopDown = (idx) => { + if (idx >= stops.length - 1) return + const newStops = [...stops] + const temp = newStops[idx] + newStops[idx] = newStops[idx + 1] + newStops[idx + 1] = temp + setStops(newStops) + computeRoute() + } + // Check if route has wilderness segments const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 @@ -97,21 +117,37 @@ export default function DirectionsPanel({ onClose }) {
- {/* Origin/Destination inputs with swap button */} -
- {/* Origin */} - + {/* Origin/Destination inputs */} +
+ {/* Origin row with swap button on right */} +
+
+ +
+ {/* Swap button - only on origin row, swaps origin and destination */} + +
{/* Intermediate stops - rendered between origin and destination */} {stops.map((stop, idx) => ( -
+
+ {/* Reorder buttons */} +
+ + +
+ {/* Remove button */}
))} - {/* Swap button - positioned between origin and destination (or after stops) */} - - - {/* Destination */} - + {/* Destination row */} +
+
+ +
+ {/* Spacer to align with origin row swap button */} +
+
{/* Add stop button - only show when route exists */} {routeStart && routeEnd && stops.length < 8 && ( From bc453ff375a79a57795ce4496008e13d3c4440f9 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 15:40:12 +0000 Subject: [PATCH 52/56] feat: drag-and-drop stop reordering and fix radial add-stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - addIntermediateStop() now accepts optional place parameter - Radial menu add-stop wedge uses addIntermediateStop with coordinates - Replaced up/down chevron buttons with @dnd-kit drag-and-drop - All rows (origin, stops, destination) can be reordered by dragging - GripVertical drag handle on left of each row - On drag end: first item → origin, last → destination, middle → stops Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 320 +++++++++++++++++------------ src/components/MapView.jsx | 18 +- src/store.js | 11 +- 3 files changed, 208 insertions(+), 141 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index 44c0f98..a01f1c9 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,5 +1,8 @@ -import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, ChevronUp, ChevronDown } from "lucide-react" +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" @@ -18,6 +21,40 @@ const BOUNDARY_MODES = [ { 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) @@ -42,6 +79,36 @@ export default function DirectionsPanel({ onClose }) { 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) { @@ -61,13 +128,6 @@ export default function DirectionsPanel({ onClose }) { } }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) - const handleSwap = () => { - const tempStart = routeStart - const tempEnd = routeEnd - setRouteStart(tempEnd) - setRouteEnd(tempStart) - } - const handleClose = () => { clearRoute() setDirectionsMode(false) @@ -78,24 +138,56 @@ export default function DirectionsPanel({ onClose }) { addIntermediateStop() } - const handleMoveStopUp = (idx) => { - if (idx === 0) return - const newStops = [...stops] - const temp = newStops[idx] - newStops[idx] = newStops[idx - 1] - newStops[idx - 1] = temp - setStops(newStops) - computeRoute() - } + // Handle drag end - reorder the unified list + const handleDragEnd = (event) => { + const { active, over } = event + if (!over || active.id === over.id) return - const handleMoveStopDown = (idx) => { - if (idx >= stops.length - 1) return - const newStops = [...stops] - const temp = newStops[idx] - newStops[idx] = newStops[idx + 1] - newStops[idx + 1] = temp + 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) - computeRoute() + + // Trigger route recalculation + setTimeout(() => computeRoute(), 0) } // Check if route has wilderness segments @@ -117,113 +209,87 @@ export default function DirectionsPanel({ onClose }) {
- {/* Origin/Destination inputs */} -
- {/* Origin row with swap button on right */} -
-
- -
- {/* Swap button - only on origin row, swaps origin and destination */} - -
+ {/* 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" && ( +
+ )} + + ))} - {/* Intermediate stops - rendered between origin and destination */} - {stops.map((stop, idx) => ( -
-
- { - if (place) { - updateStop(stop.id, place) - } + {/* Add stop button - only show when route exists */} + {routeStart && routeEnd && stops.length < 8 && ( +
- {/* Reorder buttons */} -
- - -
- {/* Remove button */} - + )}
- ))} - - {/* Destination row */} -
-
- -
- {/* Spacer to align with origin row swap button */} -
-
- - {/* Add stop button - only show when route exists */} - {routeStart && routeEnd && stops.length < 8 && ( - - )} -
+ + {/* Travel mode selector */}
diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 0f13fd1..6f6d26b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1721,22 +1721,20 @@ const MapView = forwardRef(function MapView(_, ref) { icon: Plus, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) - const { stops, addStop } = useStore.getState() + const { addIntermediateStop, computeRoute, routeStart, routeEnd } = useStore.getState() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, } - if (stops.length === 0) { - addStop(place) - useStore.setState({ gpsOrigin: false }) - } else { - const success = addStop(place) - if (!success) { - toast("Maximum 10 stops reached") + const success = addIntermediateStop(place) + if (success) { + // If we have both origin and destination, recalculate route + if (routeStart && routeEnd) { + computeRoute() } + } else { + toast("Maximum 8 intermediate stops reached") } }, }, diff --git a/src/store.js b/src/store.js index 474163f..069be9f 100644 --- a/src/store.js +++ b/src/store.js @@ -73,14 +73,17 @@ export const useStore = create((set, get) => ({ // ── INTERMEDIATE STOPS MANAGEMENT ── // stops[] contains ONLY intermediate waypoints, not origin/destination - addIntermediateStop: () => { + // 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: null, - lon: null, - name: "", + lat: place?.lat ?? null, + lon: place?.lon ?? null, + name: place?.name ?? "", } set({ stops: [...stops, newStop] }) return true From 41ea028d48349dbf8c4ab8eb41700ca4c8fef042 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 20 May 2026 17:03:35 +0000 Subject: [PATCH 53/56] public-lands: filter "Unknown " PAD-US label artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PAD-US v4.0 ships many small sub-polygons whose unit_nm is literally "Unknown " (e.g. "Unknown Idaho Department of Lands"). The PMTiles build maps unit_nm -> feature name, so the label layer rendered these spurious labels next to/over the legitimate umbrella label. Filter them out at the PUBLIC_LANDS_LABEL (symbol) layer only via a name prefix test. Fill and line layers are untouched — the polygon geometry still renders, just without the bogus label. Evidence: /api/landclass at (42.619853, -114.462106) returns a 12-acre "Unknown Idaho Department of Lands" overlapping the 1.98M-acre "Idaho Department of Lands" umbrella. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/MapView.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 6f6d26b..e9131b3 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -528,6 +528,8 @@ function addPublicLands(map, themeId) { type: 'symbol', source: PUBLIC_LANDS_SOURCE, 'source-layer': 'public_lands', + // Exclude PAD-US sub-polygons whose unit_nm is "Unknown " — USGS source artifact, not real label. + filter: ['!', ['==', ['slice', ['coalesce', ['get', 'name'], ''], 0, 8], 'Unknown ']], minzoom: 10, layout: { 'text-field': ['get', 'name'], From fcc9101239ae3efe59e18c40b6972f659fcc5d47 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 21:42:41 +0000 Subject: [PATCH 54/56] fix: apply theme overlay config to contour layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addContours() was using hardcoded black/white colors instead of theme-specific overlay config. Now uses getOverlayConfig(themeId, "contours") like other overlay layers (hillshade, publicLands, etc). Also updates cyberpunk contours from dark purple to cyan (#1a5566 → #3a99aa) to contrast with purple roads. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 71 ++-- src/themes/cyberpunk.js | 806 ++++++++++++++++++------------------- 2 files changed, 447 insertions(+), 430 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index e9131b3..876bac2 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -560,9 +560,11 @@ function removePublicLands(map) { } /** Add topographic contours via maplibre-contour */ -function addContours(map) { - console.log('[CONTOUR] addContours called, source exists:', !!map?.getSource(CONTOUR_SOURCE), 'demSource:', !!demSourceInstance) +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 + + const c = getOverlayConfig(themeId, "contours") const contourThresholds = { 3: [5000, 25000], 4: [2500, 10000], @@ -579,54 +581,69 @@ function addContours(map) { 15: [20, 100], } map.addSource(CONTOUR_SOURCE, { - type: 'vector', + type: "vector", tiles: [demSourceInstance.contourProtocolUrl({ multiplier: 3.28084, thresholds: contourThresholds, })], maxzoom: 16, }) - console.log('[CONTOUR] protocol URL:', demSourceInstance.contourProtocolUrl({ + console.log("[CONTOUR] protocol URL:", demSourceInstance.contourProtocolUrl({ multiplier: 3.28084, thresholds: contourThresholds, })) - console.log('[CONTOUR] source added:', !!map.getSource(CONTOUR_SOURCE)) + console.log("[CONTOUR] source added:", !!map.getSource(CONTOUR_SOURCE)) let beforeId = undefined for (const layer of map.getStyle().layers) { - if (layer.type === 'symbol') { beforeId = layer.id; break } + if (layer.type === "symbol") { beforeId = layer.id; break } } - const isDark = document.documentElement.getAttribute('data-theme') === 'dark' + + // Line layer with theme-aware colors + // maplibre-contour level: 0 = minor, 1 = index (major) + const opacityMod = c.opacityMod ?? 1 map.addLayer({ - id: CONTOUR_LINE, type: 'line', source: CONTOUR_SOURCE, - 'source-layer': 'contours', + id: CONTOUR_LINE, type: "line", source: CONTOUR_SOURCE, + "source-layer": "contours", paint: { - 'line-color': 'rgba(0,0,0,0.35)', - 'line-width': [ - 'interpolate', ['linear'], ['zoom'], - 7, ['match', ['get', 'level'], 1, 1, 0.3], - 11, ['match', ['get', 'level'], 1, 1.5, 0.6], - 14, ['match', ['get', 'level'], 1, 2, 0.8], + "line-color": [ + "match", ["get", "level"], + 1, c.indexColor, + c.minorColor + ], + "line-opacity": [ + "match", ["get", "level"], + 1, c.indexOpacity * opacityMod, + c.minorOpacity * opacityMod + ], + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 7, ["match", ["get", "level"], 1, c.indexWidth?.z4 ?? 1.2, c.minorWidth?.z11 ?? 0.5], + 11, ["match", ["get", "level"], 1, ((c.indexWidth?.z4 ?? 1.2) + (c.indexWidth?.z14 ?? 1.8)) / 2, ((c.minorWidth?.z11 ?? 0.5) + (c.minorWidth?.z14 ?? 1.0)) / 2], + 14, ["match", ["get", "level"], 1, c.indexWidth?.z14 ?? 1.8, c.minorWidth?.z14 ?? 1.0], ], }, }, beforeId) + + // Label layer for index contours (level > 0) map.addLayer({ - id: CONTOUR_LABEL, type: 'symbol', source: CONTOUR_SOURCE, - 'source-layer': 'contours', - filter: ['>', ['get', 'level'], 0], + id: CONTOUR_LABEL, type: "symbol", source: CONTOUR_SOURCE, + "source-layer": "contours", + filter: [">", ["get", "level"], 0], layout: { - 'symbol-placement': 'line', - 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 9, 11, 11, 14, 13], - 'text-field': ['concat', ['number-format', ['get', 'ele'], {}], "'"], - 'text-font': ['Noto Sans Medium'], - 'text-max-angle': 25, + "symbol-placement": "line", + "text-size": c.labelSize ?? 10, + "text-field": ["concat", ["number-format", ["get", "ele"], {}], "'"], + "text-font": c.labelFont ?? ["Noto Sans Regular"], + "text-max-angle": 25, }, paint: { - 'text-color': 'rgba(0,0,0,0.7)', - 'text-halo-color': 'rgba(255,255,255,0.9)', - 'text-halo-width': 1.5, + "text-color": c.labelColor, + "text-halo-color": c.labelHaloColor, + "text-halo-width": c.labelHaloWidth ?? 1.5, + "text-opacity": (c.labelOpacity ?? 0.85) * opacityMod, }, }) - console.log('[CONTOUR] layers added:', !!map.getLayer(CONTOUR_LINE), !!map.getLayer(CONTOUR_LABEL)) + console.log("[CONTOUR] layers added:", !!map.getLayer(CONTOUR_LINE), !!map.getLayer(CONTOUR_LABEL)) } /** Remove contour layers + source */ diff --git a/src/themes/cyberpunk.js b/src/themes/cyberpunk.js index 86a8e92..8abaadb 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: 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 +/** + * 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 From 38d11bea990379b649085d93bce49c9ec0593cda Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Fri, 22 May 2026 09:09:24 -0600 Subject: [PATCH 55/56] navi: read auth login/logout URLs from /api/config (extraction #2 PR-C) Panel.jsx now reads the login/logout URLs from the deployment config (cfg.auth.login_url / cfg.auth.logout_url) instead of hardcoding them, with the current home-profile literals kept as fallback. This completes the extraction #2 trio: - PR-A (recon #6): adds the `auth` block to the deployment profile YAMLs - PR-B (navi-backend #1): navi-config service serving /api/config on :8422 - PR-C (this): frontend consumes auth.* from /api/config Behavior is unchanged in every case: the fallback literals are used if the backend doesn't yet serve `auth` (older recon) or when FALLBACK_CONFIG is in effect (offline / config API unreachable). useConfig() returns the cached deployment config (null until loaded); the optional-chaining + ?? fallback keeps clicks working before config loads. Also adds the same `auth` block to src/config.js FALLBACK_CONFIG so offline mode has working URLs. No test added: navi has no test infrastructure today; flagged with a TODO(navi) at the change site for when test infra lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/Panel.jsx | 10 ++++++++-- src/config.js | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index b89c661..6f0a0ac 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -3,6 +3,7 @@ import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, Ma import ThemePicker from './ThemePicker' import { useStore, usePanelState } from '../store' import { hasFeature } from '../config' +import { useConfig } from '../hooks/useConfig' import SearchBar from './SearchBar' import ManeuverList from './ManeuverList' import ContactList from './ContactList' @@ -54,6 +55,7 @@ export default function Panel({ onClearRoute }) { const dragStartState = useRef('half') const showContacts = hasFeature('has_contacts') && auth.authenticated + const cfg = useConfig() useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) @@ -62,8 +64,12 @@ export default function Panel({ onClearRoute }) { return () => window.removeEventListener('resize', check) }, []) - const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } - const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } + // Auth URLs come from /api/config (config.auth.*); the literals are the + // current home-profile values, kept as fallback for an older backend that + // doesn't yet serve `auth`, or when FALLBACK_CONFIG is in use (offline). + // TODO(navi): add tests when test infra lands — see extraction #2 PR-C + const handleLogin = () => { window.location.href = cfg?.auth?.login_url ?? '/outpost.goauthentik.io/start?rd=%2F' } + const handleLogout = () => { window.location.href = cfg?.auth?.logout_url ?? 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } const handleTouchStart = useCallback((e) => { dragStartY.current = e.touches[0].clientY diff --git a/src/config.js b/src/config.js index 97af6a1..b5da749 100644 --- a/src/config.js +++ b/src/config.js @@ -21,6 +21,10 @@ const FALLBACK_CONFIG = { address_book: '/api/address_book', valhalla: '/valhalla', }, + auth: { + login_url: '/outpost.goauthentik.io/start?rd=%2F', + logout_url: 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/', + }, features: { has_nominatim_details: false, has_kiwix_wiki: false, From c1a000c28539757c90e9f2d4f66a6ec316f419b7 Mon Sep 17 00:00:00 2001 From: malice Date: Fri, 22 May 2026 16:40:49 -0600 Subject: [PATCH 56/56] Gate Traffic toggle on auth.authenticated (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: /api/traffic is on Caddy's @authed_api, so when logged out MapLibre's raster tile fetches receive a 302 to the Authentik login (HTML), which it can't decode as an image and retries on every map move — console spam and a stuck-feeling Traffic toggle. Fix (frontend-only; /api/traffic stays auth-gated in Caddy): - LayerControl: the Traffic toggle is always rendered but disabled (greyed, "Sign in to enable traffic" tooltip) until auth has loaded AND the user is authenticated — mirroring Panel.jsx's contacts gating. The add-traffic apply effect now also requires auth.authenticated (and lists it in deps), and the mount init only restores saved traffic=true when authenticated. - Teardown on session -> anonymous: an effect flips traffic:false once auth has loaded and the user is not authenticated, which drives the apply effect to removeTrafficLayer (no further tile requests). - MapView: the style-reload re-apply (which re-adds layers from localStorage on theme/style changes) now also checks auth.authenticated for traffic, so it can't re-add the source for an anonymous session — the second add path that would otherwise reintroduce the 302 retry loop. - localStorage hydration: LayerControl now subscribes via useConfig() and its init effect depends on [config] instead of [], so saved layer prefs hydrate correctly once /api/config resolves (previously, mounting before config loaded left toggles stuck off and never re-initialized). Shown-but-disabled (not hidden) so logged-in users see no flicker on reload during the brief pre-whoami window. Tests: the navi repo has no test infrastructure (no vitest/jest); bootstrapping is out of scope. Follow-up: seed a vitest + RTL test asserting the Traffic toggle is disabled when !auth.authenticated. Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.7 (1M context) --- src/components/LayerControl.jsx | 32 ++++++++++++++++++++++++++------ src/components/MapView.jsx | 4 +++- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/components/LayerControl.jsx b/src/components/LayerControl.jsx index ee41ccb..31dcbc2 100644 --- a/src/components/LayerControl.jsx +++ b/src/components/LayerControl.jsx @@ -1,6 +1,7 @@ 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' @@ -33,7 +34,14 @@ export default function LayerControl({ mapRef }) { const viewMode = useStore((s) => s.viewMode) const setViewMode = useStore((s) => s.setViewMode) - // Initialize from localStorage or defaults on mount + // 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') @@ -47,7 +55,7 @@ export default function LayerControl({ mapRef }) { if (saved) { setHillshade(hsAvailable && (saved.hillshade ?? true)) - setTraffic(trAvailable && (saved.traffic ?? false)) + setTraffic(trAvailable && auth.authenticated && (saved.traffic ?? false)) setPublicLands(plAvailable && (saved.publicLands ?? false)) setContours(ctAvailable && (saved.contours ?? false)) setContoursTest(ctTestAvailable && (saved.contoursTest ?? false)) @@ -64,7 +72,14 @@ export default function LayerControl({ mapRef }) { 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(() => { @@ -97,7 +112,7 @@ export default function LayerControl({ mapRef }) { if (!map) return const apply = () => { - if (traffic && hasFeature('has_traffic_overlay')) { + if (traffic && hasFeature('has_traffic_overlay') && auth.authenticated) { mapView.addTrafficLayer?.() } else { mapView.removeTrafficLayer?.() @@ -111,7 +126,7 @@ export default function LayerControl({ mapRef }) { } savePrefs({ hillshade, traffic, publicLands, contours, contoursTest, contoursTest10ft, usfsTrails, blmTrails }) return () => map.off('style.load', apply) - }, [traffic, mapRef]) + }, [traffic, mapRef, auth.authenticated]) useEffect(() => { const mapView = mapRef?.current @@ -343,12 +358,17 @@ export default function LayerControl({ mapRef }) { )} {showTraffic && ( -