From 09369011eeebb748485b895de8deb9759de02a49 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 21:59:04 +0000 Subject: [PATCH] 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