Wire overlay layers to read styling from theme registry

All overlay layer add functions now read colors, opacities, and widths
from the theme registry instead of hardcoded dark/light branches.

registry.js changes:
- Add complete darkOverlay and lightOverlay config objects
- Each overlay layer has its own config section:
  - hillshade: exaggeration, illuminationDirection, shadowColor, highlightColor
  - traffic: opacity
  - contours: colors, opacities, widths (with opacityMod), label styling
  - contoursTest: cascades from contours, overrides colors
  - contoursTest10ft: cascades from contours, overrides colors
  - publicLands: per-category fill/outline colors and opacities, labels
  - usfsTrails: roads/trails colors by use type, labels
  - blmTrails: route colors by use class, labels
- Add getOverlayConfig(themeId, layerKey) function
- Contour variants cascade missing keys from same theme's contours
- Width values use self-documenting object format: { z11: 0.5, z14: 1.0 }

MapView.jsx changes:
- All 8 overlay add functions now take themeId parameter
- Functions call getOverlayConfig() to get merged config
- No hardcoded color/opacity/width values remain in overlay functions
- Theme switching re-adds all active overlays with new theme config

This is a refactor - light and dark themes render identically to before.
Custom themes can now override individual overlay styling values.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-01 15:48:12 +00:00
commit 9530fbbf76
2 changed files with 666 additions and 291 deletions

View file

@ -3,7 +3,7 @@ import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css' import 'maplibre-gl/dist/maplibre-gl.css'
import { Protocol } from 'pmtiles' import { Protocol } from 'pmtiles'
import { layers } from 'protomaps-themes-base' import { layers } from 'protomaps-themes-base'
import { getTheme, getThemeColors, getThemeSprite } from '../themes/registry' import { getTheme, getThemeColors, getThemeSprite, getOverlayConfig } from '../themes/registry'
import { useStore } from '../store' import { useStore } from '../store'
import { decodePolyline } from '../utils/decode' import { decodePolyline } from '../utils/decode'
import { fetchReverse } from '../api' import { fetchReverse } from '../api'
@ -311,12 +311,14 @@ const CHEVRON_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http
</svg>` </svg>`
/** Add hillshade raster-dem source + layer to the map */ /** Add hillshade raster-dem source + layer to the map */
function addHillshade(map) { function addHillshade(map, themeId) {
if (!map || map.getSource(HILLSHADE_SOURCE)) return if (!map || map.getSource(HILLSHADE_SOURCE)) return
const config = getConfig() const config = getConfig()
const hs = config?.tileset_hillshade const hs = config?.tileset_hillshade
if (!hs?.url) return if (!hs?.url) return
const c = getOverlayConfig(themeId, 'hillshade')
map.addSource(HILLSHADE_SOURCE, { map.addSource(HILLSHADE_SOURCE, {
type: 'raster-dem', type: 'raster-dem',
url: `pmtiles://${hs.url}`, url: `pmtiles://${hs.url}`,
@ -339,10 +341,10 @@ function addHillshade(map) {
type: 'hillshade', type: 'hillshade',
source: HILLSHADE_SOURCE, source: HILLSHADE_SOURCE,
paint: { paint: {
'hillshade-exaggeration': 0.5, 'hillshade-exaggeration': c.exaggeration,
'hillshade-illumination-direction': 315, 'hillshade-illumination-direction': c.illuminationDirection,
'hillshade-shadow-color': '#000000', 'hillshade-shadow-color': c.shadowColor,
'hillshade-highlight-color': '#ffffff', 'hillshade-highlight-color': c.highlightColor,
}, },
}, beforeId) }, beforeId)
} }
@ -355,12 +357,13 @@ function removeHillshade(map) {
} }
/** Add traffic raster tile source + layer */ /** Add traffic raster tile source + layer */
function addTraffic(map) { function addTraffic(map, themeId) {
if (!map || map.getSource(TRAFFIC_SOURCE)) return if (!map || map.getSource(TRAFFIC_SOURCE)) return
const config = getConfig() const config = getConfig()
const tr = config?.traffic const tr = config?.traffic
if (!tr?.proxy_url) return if (!tr?.proxy_url) return
const c = getOverlayConfig(themeId, 'traffic')
const tileUrl = tr.proxy_url.replace('{z}', '{z}').replace('{x}', '{x}').replace('{y}', '{y}') const tileUrl = tr.proxy_url.replace('{z}', '{z}').replace('{x}', '{x}').replace('{y}', '{y}')
map.addSource(TRAFFIC_SOURCE, { map.addSource(TRAFFIC_SOURCE, {
@ -375,7 +378,7 @@ function addTraffic(map) {
type: 'raster', type: 'raster',
source: TRAFFIC_SOURCE, source: TRAFFIC_SOURCE,
paint: { paint: {
'raster-opacity': 0.6, 'raster-opacity': c.opacity,
}, },
}) })
} }
@ -388,9 +391,11 @@ function removeTraffic(map) {
} }
/** Add public lands vector tile overlay (PAD-US) */ /** Add public lands vector tile overlay (PAD-US) */
function addPublicLands(map) { function addPublicLands(map, themeId) {
if (!map || map.getSource(PUBLIC_LANDS_SOURCE)) return if (!map || map.getSource(PUBLIC_LANDS_SOURCE)) return
const c = getOverlayConfig(themeId, 'publicLands')
map.addSource(PUBLIC_LANDS_SOURCE, { map.addSource(PUBLIC_LANDS_SOURCE, {
type: 'vector', type: 'vector',
url: 'pmtiles:///tiles/public-lands.pmtiles', url: 'pmtiles:///tiles/public-lands.pmtiles',
@ -405,9 +410,6 @@ function addPublicLands(map) {
} }
} }
const isDark = isCurrentThemeDark()
const opacityMod = isDark ? 0.7 : 1.0
// Fill layer data-driven color by agency + designation // Fill layer data-driven color by agency + designation
map.addLayer({ map.addLayer({
id: PUBLIC_LANDS_FILL, id: PUBLIC_LANDS_FILL,
@ -417,40 +419,40 @@ function addPublicLands(map) {
paint: { paint: {
'fill-color': [ 'fill-color': [
'case', 'case',
['==', ['get', 'designation'], 'WA'], '#7c6b2f', ['==', ['get', 'designation'], 'WA'], c.fillWA,
['==', ['get', 'designation'], 'WSA'], '#7c6b2f', ['==', ['get', 'designation'], 'WSA'], c.fillWA,
['==', ['get', 'agency'], 'NPS'], '#3d6b1f', ['==', ['get', 'agency'], 'NPS'], c.fillNPS,
['==', ['get', 'agency'], 'USFS'], '#5a7c2f', ['==', ['get', 'agency'], 'USFS'], c.fillUSFS,
['==', ['get', 'agency'], 'BLM'], '#c4a672', ['==', ['get', 'agency'], 'BLM'], c.fillBLM,
['==', ['get', 'agency'], 'FWS'], '#4a7a5a', ['==', ['get', 'agency'], 'FWS'], c.fillFWS,
['any', ['any',
['==', ['get', 'manager_type'], 'STAT'], ['==', ['get', 'manager_type'], 'STAT'],
['==', ['get', 'agency'], 'SPR'], ['==', ['get', 'agency'], 'SPR'],
['==', ['get', 'agency'], 'SDC'], ['==', ['get', 'agency'], 'SDC'],
['==', ['get', 'agency'], 'SLB'] ['==', ['get', 'agency'], 'SLB']
], '#5a8c7c', ], c.fillSTAT,
['any', ['any',
['==', ['get', 'manager_type'], 'LOC'], ['==', ['get', 'manager_type'], 'LOC'],
['==', ['get', 'manager_type'], 'DIST'] ['==', ['get', 'manager_type'], 'DIST']
], '#8ca694', ], c.fillLOC,
'#a0a0a0' c.fillDefault
], ],
'fill-opacity': [ 'fill-opacity': [
'case', 'case',
['==', ['get', 'designation'], 'WA'], 0.30 * opacityMod, ['==', ['get', 'designation'], 'WA'], c.fillOpacityWA * c.opacityMod,
['==', ['get', 'designation'], 'WSA'], 0.30 * opacityMod, ['==', ['get', 'designation'], 'WSA'], c.fillOpacityWA * c.opacityMod,
['==', ['get', 'agency'], 'NPS'], 0.30 * opacityMod, ['==', ['get', 'agency'], 'NPS'], c.fillOpacityNPS * c.opacityMod,
['==', ['get', 'agency'], 'USFS'], 0.25 * opacityMod, ['==', ['get', 'agency'], 'USFS'], c.fillOpacityUSFS * c.opacityMod,
['==', ['get', 'agency'], 'BLM'], 0.20 * opacityMod, ['==', ['get', 'agency'], 'BLM'], c.fillOpacityBLM * c.opacityMod,
['any', ['any',
['==', ['get', 'manager_type'], 'STAT'], ['==', ['get', 'manager_type'], 'STAT'],
['==', ['get', 'agency'], 'SPR'] ['==', ['get', 'agency'], 'SPR']
], 0.25 * opacityMod, ], c.fillOpacitySTAT * c.opacityMod,
['any', ['any',
['==', ['get', 'manager_type'], 'LOC'], ['==', ['get', 'manager_type'], 'LOC'],
['==', ['get', 'manager_type'], 'DIST'] ['==', ['get', 'manager_type'], 'DIST']
], 0.20 * opacityMod, ], c.fillOpacityLOC * c.opacityMod,
0.15 * opacityMod c.fillOpacityDefault * c.opacityMod
], ],
}, },
}, beforeId) }, beforeId)
@ -464,34 +466,34 @@ function addPublicLands(map) {
paint: { paint: {
'line-color': [ 'line-color': [
'case', 'case',
['==', ['get', 'designation'], 'WA'], '#5a4d20', ['==', ['get', 'designation'], 'WA'], c.outlineWA,
['==', ['get', 'designation'], 'WSA'], '#5a4d20', ['==', ['get', 'designation'], 'WSA'], c.outlineWA,
['==', ['get', 'agency'], 'NPS'], '#2a4a15', ['==', ['get', 'agency'], 'NPS'], c.outlineNPS,
['==', ['get', 'agency'], 'USFS'], '#3d5520', ['==', ['get', 'agency'], 'USFS'], c.outlineUSFS,
['==', ['get', 'agency'], 'BLM'], '#8a7343', ['==', ['get', 'agency'], 'BLM'], c.outlineBLM,
['==', ['get', 'agency'], 'FWS'], '#2d5a3a', ['==', ['get', 'agency'], 'FWS'], c.outlineFWS,
['any', ['any',
['==', ['get', 'manager_type'], 'STAT'], ['==', ['get', 'manager_type'], 'STAT'],
['==', ['get', 'agency'], 'SPR'] ['==', ['get', 'agency'], 'SPR']
], '#3d6055', ], c.outlineSTAT,
['any', ['any',
['==', ['get', 'manager_type'], 'LOC'], ['==', ['get', 'manager_type'], 'LOC'],
['==', ['get', 'manager_type'], 'DIST'] ['==', ['get', 'manager_type'], 'DIST']
], '#5c6e66', ], c.outlineLOC,
'#707070' c.outlineDefault
], ],
'line-opacity': [ 'line-opacity': [
'case', 'case',
['==', ['get', 'agency'], 'NPS'], 0.7, ['==', ['get', 'agency'], 'NPS'], c.outlineOpacityNPS,
['==', ['get', 'agency'], 'USFS'], 0.6, ['==', ['get', 'agency'], 'USFS'], c.outlineOpacityUSFS,
['==', ['get', 'agency'], 'BLM'], 0.5, ['==', ['get', 'agency'], 'BLM'], c.outlineOpacityDefault,
0.5 c.outlineOpacityDefault
], ],
'line-width': [ 'line-width': [
'interpolate', ['linear'], ['zoom'], 'interpolate', ['linear'], ['zoom'],
4, 0.3, 4, c.outlineWidth.z4,
8, 0.8, 8, c.outlineWidth.z8,
12, 1.2 12, c.outlineWidth.z12
], ],
}, },
}, beforeId) }, beforeId)
@ -505,8 +507,8 @@ function addPublicLands(map) {
minzoom: 10, minzoom: 10,
layout: { layout: {
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 10, 14, 13], 'text-size': ['interpolate', ['linear'], ['zoom'], 10, c.labelSize.z10, 14, c.labelSize.z14],
'text-font': ['Noto Sans Regular'], 'text-font': c.labelFont,
'symbol-placement': 'point', 'symbol-placement': 'point',
'text-anchor': 'center', 'text-anchor': 'center',
'text-max-width': 8, 'text-max-width': 8,
@ -514,10 +516,10 @@ function addPublicLands(map) {
'text-ignore-placement': false, 'text-ignore-placement': false,
}, },
paint: { paint: {
'text-color': isDark ? '#c0c8b8' : '#3a4a30', 'text-color': c.labelColor,
'text-halo-color': isDark ? '#1a1a1a' : '#ffffff', 'text-halo-color': c.labelHaloColor,
'text-halo-width': 1.5, 'text-halo-width': c.labelHaloWidth,
'text-opacity': 0.85, 'text-opacity': c.labelOpacity,
}, },
}) })
} }
@ -532,9 +534,11 @@ function removePublicLands(map) {
} }
/** Add topographic contour vector tile overlay */ /** Add topographic contour vector tile overlay */
function addContours(map) { function addContours(map, themeId) {
if (!map || map.getSource(CONTOUR_SOURCE)) return if (!map || map.getSource(CONTOUR_SOURCE)) return
const c = getOverlayConfig(themeId, 'contours')
map.addSource(CONTOUR_SOURCE, { map.addSource(CONTOUR_SOURCE, {
type: 'vector', type: 'vector',
url: 'pmtiles:///tiles/contours-na.pmtiles', url: 'pmtiles:///tiles/contours-na.pmtiles',
@ -549,9 +553,6 @@ function addContours(map) {
} }
} }
const isDark = isCurrentThemeDark()
const opMod = isDark ? 0.8 : 1.0
// Minor contours (40ft) visible z11+ // Minor contours (40ft) visible z11+
map.addLayer({ map.addLayer({
id: CONTOUR_MINOR, id: CONTOUR_MINOR,
@ -561,9 +562,9 @@ function addContours(map) {
minzoom: 11, minzoom: 11,
filter: ['==', ['get', 'tier'], 'minor'], filter: ['==', ['get', 'tier'], 'minor'],
paint: { paint: {
'line-color': '#8b6f47', 'line-color': c.minorColor,
'line-opacity': 0.4 * opMod, 'line-opacity': c.minorOpacity * c.opacityMod,
'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.5, 14, 1.0], 'line-width': ['interpolate', ['linear'], ['zoom'], 11, c.minorWidth.z11, 14, c.minorWidth.z14],
}, },
}, beforeId) }, beforeId)
@ -576,9 +577,9 @@ function addContours(map) {
minzoom: 8, minzoom: 8,
filter: ['==', ['get', 'tier'], 'intermediate'], filter: ['==', ['get', 'tier'], 'intermediate'],
paint: { paint: {
'line-color': '#8b6f47', 'line-color': c.intermediateColor,
'line-opacity': 0.7 * opMod, 'line-opacity': c.intermediateOpacity * c.opacityMod,
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 14, 1.2], 'line-width': ['interpolate', ['linear'], ['zoom'], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14],
}, },
}, beforeId) }, beforeId)
@ -591,9 +592,9 @@ function addContours(map) {
minzoom: 4, minzoom: 4,
filter: ['==', ['get', 'tier'], 'index'], filter: ['==', ['get', 'tier'], 'index'],
paint: { paint: {
'line-color': '#6b4f2a', 'line-color': c.indexColor,
'line-opacity': 0.9 * opMod, 'line-opacity': c.indexOpacity * c.opacityMod,
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 1.2, 14, 1.8], 'line-width': ['interpolate', ['linear'], ['zoom'], 4, c.indexWidth.z4, 14, c.indexWidth.z14],
}, },
}, beforeId) }, beforeId)
@ -607,8 +608,8 @@ function addContours(map) {
filter: ['==', ['get', 'tier'], 'index'], filter: ['==', ['get', 'tier'], 'index'],
layout: { layout: {
'text-field': ['concat', ['to-string', ['get', 'elevation_ft']], "'"], 'text-field': ['concat', ['to-string', ['get', 'elevation_ft']], "'"],
'text-size': 10, 'text-size': c.labelSize,
'text-font': ['Noto Sans Regular'], 'text-font': c.labelFont,
'symbol-placement': 'line', 'symbol-placement': 'line',
'text-anchor': 'center', 'text-anchor': 'center',
'symbol-spacing': 400, 'symbol-spacing': 400,
@ -616,10 +617,10 @@ function addContours(map) {
'text-allow-overlap': false, 'text-allow-overlap': false,
}, },
paint: { paint: {
'text-color': isDark ? '#c0b898' : '#5a4020', 'text-color': c.labelColor,
'text-halo-color': isDark ? '#1a1a1a' : '#ffffff', 'text-halo-color': c.labelHaloColor,
'text-halo-width': 1.5, 'text-halo-width': c.labelHaloWidth,
'text-opacity': 0.85, 'text-opacity': c.labelOpacity,
}, },
}) })
} }
@ -635,9 +636,11 @@ function removeContours(map) {
} }
/** Add TEST topographic contour overlay (blue color scheme) */ /** Add TEST topographic contour overlay (blue color scheme) */
function addContoursTest(map) { function addContoursTest(map, themeId) {
if (!map || map.getSource(CONTOUR_TEST_SOURCE)) return if (!map || map.getSource(CONTOUR_TEST_SOURCE)) return
const c = getOverlayConfig(themeId, 'contoursTest')
map.addSource(CONTOUR_TEST_SOURCE, { map.addSource(CONTOUR_TEST_SOURCE, {
type: "vector", type: "vector",
url: "pmtiles:///tiles/contours-test.pmtiles", url: "pmtiles:///tiles/contours-test.pmtiles",
@ -651,9 +654,6 @@ function addContoursTest(map) {
} }
} }
const isDark = isCurrentThemeDark()
const opMod = isDark ? 0.8 : 1.0
// Minor contours (40ft) blue scheme // Minor contours (40ft) blue scheme
map.addLayer({ map.addLayer({
id: CONTOUR_TEST_MINOR, id: CONTOUR_TEST_MINOR,
@ -663,9 +663,9 @@ function addContoursTest(map) {
minzoom: 11, minzoom: 11,
filter: ["==", ["get", "tier"], "minor"], filter: ["==", ["get", "tier"], "minor"],
paint: { paint: {
"line-color": "#4a7c9b", "line-color": c.minorColor,
"line-opacity": 0.4 * opMod, "line-opacity": c.minorOpacity * c.opacityMod,
"line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.5, 14, 1.0], "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14],
}, },
}, beforeId) }, beforeId)
@ -678,9 +678,9 @@ function addContoursTest(map) {
minzoom: 8, minzoom: 8,
filter: ["==", ["get", "tier"], "intermediate"], filter: ["==", ["get", "tier"], "intermediate"],
paint: { paint: {
"line-color": "#4a7c9b", "line-color": c.intermediateColor,
"line-opacity": 0.7 * opMod, "line-opacity": c.intermediateOpacity * c.opacityMod,
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 14, 1.2], "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14],
}, },
}, beforeId) }, beforeId)
@ -693,9 +693,9 @@ function addContoursTest(map) {
minzoom: 4, minzoom: 4,
filter: ["==", ["get", "tier"], "index"], filter: ["==", ["get", "tier"], "index"],
paint: { paint: {
"line-color": "#2a5a7c", "line-color": c.indexColor,
"line-opacity": 0.9 * opMod, "line-opacity": c.indexOpacity * c.opacityMod,
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 1.2, 14, 1.8], "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14],
}, },
}, beforeId) }, beforeId)
@ -709,8 +709,8 @@ function addContoursTest(map) {
filter: ["==", ["get", "tier"], "index"], filter: ["==", ["get", "tier"], "index"],
layout: { layout: {
"text-field": ["concat", ["to-string", ["get", "elevation_ft"]], ""], "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], ""],
"text-size": 10, "text-size": c.labelSize,
"text-font": ["Noto Sans Regular"], "text-font": c.labelFont,
"symbol-placement": "line", "symbol-placement": "line",
"text-anchor": "center", "text-anchor": "center",
"symbol-spacing": 400, "symbol-spacing": 400,
@ -718,10 +718,10 @@ function addContoursTest(map) {
"text-allow-overlap": false, "text-allow-overlap": false,
}, },
paint: { paint: {
"text-color": isDark ? "#98b8d0" : "#205080", "text-color": c.labelColor,
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff", "text-halo-color": c.labelHaloColor,
"text-halo-width": 1.5, "text-halo-width": c.labelHaloWidth,
"text-opacity": 0.85, "text-opacity": c.labelOpacity,
}, },
}) })
} }
@ -737,9 +737,11 @@ function removeContoursTest(map) {
} }
/** Add TEST 10ft topographic contour overlay (green color scheme) */ /** Add TEST 10ft topographic contour overlay (green color scheme) */
function addContoursTest10ft(map) { function addContoursTest10ft(map, themeId) {
if (!map || map.getSource(CONTOUR_TEST_10FT_SOURCE)) return if (!map || map.getSource(CONTOUR_TEST_10FT_SOURCE)) return
const c = getOverlayConfig(themeId, 'contoursTest10ft')
map.addSource(CONTOUR_TEST_10FT_SOURCE, { map.addSource(CONTOUR_TEST_10FT_SOURCE, {
type: "vector", type: "vector",
url: "pmtiles:///tiles/contours-test-10ft.pmtiles", url: "pmtiles:///tiles/contours-test-10ft.pmtiles",
@ -753,9 +755,6 @@ function addContoursTest10ft(map) {
} }
} }
const isDark = isCurrentThemeDark()
const opMod = isDark ? 0.8 : 1.0
// Minor contours (10ft) green scheme // Minor contours (10ft) green scheme
map.addLayer({ map.addLayer({
id: CONTOUR_TEST_10FT_MINOR, id: CONTOUR_TEST_10FT_MINOR,
@ -765,9 +764,9 @@ function addContoursTest10ft(map) {
minzoom: 11, minzoom: 11,
filter: ["==", ["get", "tier"], "minor"], filter: ["==", ["get", "tier"], "minor"],
paint: { paint: {
"line-color": "#3a7c4f", "line-color": c.minorColor,
"line-opacity": 0.4 * opMod, "line-opacity": c.minorOpacity * c.opacityMod,
"line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.5, 14, 1.0], "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14],
}, },
}, beforeId) }, beforeId)
@ -780,9 +779,9 @@ function addContoursTest10ft(map) {
minzoom: 8, minzoom: 8,
filter: ["==", ["get", "tier"], "intermediate"], filter: ["==", ["get", "tier"], "intermediate"],
paint: { paint: {
"line-color": "#3a7c4f", "line-color": c.intermediateColor,
"line-opacity": 0.7 * opMod, "line-opacity": c.intermediateOpacity * c.opacityMod,
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 14, 1.2], "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14],
}, },
}, beforeId) }, beforeId)
@ -795,9 +794,9 @@ function addContoursTest10ft(map) {
minzoom: 4, minzoom: 4,
filter: ["==", ["get", "tier"], "index"], filter: ["==", ["get", "tier"], "index"],
paint: { paint: {
"line-color": "#2a5c3a", "line-color": c.indexColor,
"line-opacity": 0.9 * opMod, "line-opacity": c.indexOpacity * c.opacityMod,
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 1.2, 14, 1.8], "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14],
}, },
}, beforeId) }, beforeId)
@ -811,8 +810,8 @@ function addContoursTest10ft(map) {
filter: ["==", ["get", "tier"], "index"], filter: ["==", ["get", "tier"], "index"],
layout: { layout: {
"text-field": ["concat", ["to-string", ["get", "elevation_ft"]], "'"], "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], "'"],
"text-size": 10, "text-size": c.labelSize,
"text-font": ["Noto Sans Regular"], "text-font": c.labelFont,
"symbol-placement": "line", "symbol-placement": "line",
"text-anchor": "center", "text-anchor": "center",
"symbol-spacing": 400, "symbol-spacing": 400,
@ -820,10 +819,10 @@ function addContoursTest10ft(map) {
"text-allow-overlap": false, "text-allow-overlap": false,
}, },
paint: { paint: {
"text-color": isDark ? "#98c0a8" : "#2a4030", "text-color": c.labelColor,
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff", "text-halo-color": c.labelHaloColor,
"text-halo-width": 1.5, "text-halo-width": c.labelHaloWidth,
"text-opacity": 0.85, "text-opacity": c.labelOpacity,
}, },
}) })
} }
@ -838,10 +837,11 @@ function removeContoursTest10ft(map) {
if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE) if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE)
} }
/** Add USFS trails and roads vector tile overlay */ /** Add USFS trails and roads vector tile overlay */
/** Add USFS trails and roads vector tile overlay */ function addUsfsTrails(map, themeId) {
function addUsfsTrails(map) {
if (!map || map.getSource(USFS_SOURCE)) return if (!map || map.getSource(USFS_SOURCE)) return
const c = getOverlayConfig(themeId, 'usfsTrails')
map.addSource(USFS_SOURCE, { map.addSource(USFS_SOURCE, {
type: "vector", type: "vector",
url: "pmtiles:///tiles/usfs-trails-roads.pmtiles", url: "pmtiles:///tiles/usfs-trails-roads.pmtiles",
@ -856,8 +856,6 @@ function addUsfsTrails(map) {
} }
} }
const isDark = isCurrentThemeDark()
// Invisible hit-area layers for easier clicking // Invisible hit-area layers for easier clicking
map.addLayer({ map.addLayer({
id: USFS_ROADS_HIT, id: USFS_ROADS_HIT,
@ -868,7 +866,7 @@ function addUsfsTrails(map) {
paint: { paint: {
"line-color": "#000000", "line-color": "#000000",
"line-opacity": 0, "line-opacity": 0,
"line-width": 14, "line-width": c.hitWidth,
}, },
}, beforeId) }, beforeId)
@ -881,7 +879,7 @@ function addUsfsTrails(map) {
paint: { paint: {
"line-color": "#000000", "line-color": "#000000",
"line-opacity": 0, "line-opacity": 0,
"line-width": 14, "line-width": c.hitWidth,
}, },
}, beforeId) }, beforeId)
@ -893,9 +891,9 @@ function addUsfsTrails(map) {
"source-layer": "roads", "source-layer": "roads",
minzoom: 10, minzoom: 10,
paint: { paint: {
"line-color": isDark ? "#d0a060" : "#c09050", "line-color": c.roadsColor,
"line-opacity": 0.9, "line-opacity": c.roadsOpacity,
"line-width": ["interpolate", ["linear"], ["zoom"], 10, 1.5, 14, 2.5, 16, 3.5], "line-width": ["interpolate", ["linear"], ["zoom"], 10, c.roadsWidth.z10, 14, c.roadsWidth.z14, 16, c.roadsWidth.z16],
}, },
}, beforeId) }, beforeId)
@ -913,21 +911,21 @@ function addUsfsTrails(map) {
["any", ["any",
["==", ["slice", ["get", "MOTORCYCLE"], 0, 1], "0"], ["==", ["slice", ["get", "MOTORCYCLE"], 0, 1], "0"],
["==", ["slice", ["get", "ATV_MANAGE"], 0, 1], "0"] ["==", ["slice", ["get", "ATV_MANAGE"], 0, 1], "0"]
], isDark ? "#f08040" : "#e07030", ], c.trailsMotorized,
// Bike trails - amber // Bike trails - amber
["==", ["slice", ["get", "BICYCLE_MA"], 0, 1], "0"], ["==", ["slice", ["get", "BICYCLE_MA"], 0, 1], "0"],
isDark ? "#e0b040" : "#d0a030", c.trailsBicycle,
// Hiker/Horse only - green // Hiker/Horse only - green
["any", ["any",
["==", ["slice", ["get", "HIKER_PEDE"], 0, 1], "0"], ["==", ["slice", ["get", "HIKER_PEDE"], 0, 1], "0"],
["==", ["slice", ["get", "HORSE_MANA"], 0, 1], "0"] ["==", ["slice", ["get", "HORSE_MANA"], 0, 1], "0"]
], isDark ? "#60c050" : "#50b040", ], c.trailsHiker,
// Default - tan // Default - tan
isDark ? "#c0a060" : "#b09050" c.trailsDefault
], ],
"line-opacity": 0.9, "line-opacity": c.trailsOpacity,
"line-width": ["interpolate", ["linear"], ["zoom"], 10, 2.0, 14, 3.0, 16, 4.0], "line-width": ["interpolate", ["linear"], ["zoom"], 10, c.trailsWidth.z10, 14, c.trailsWidth.z14, 16, c.trailsWidth.z16],
"line-dasharray": [2, 1.5], "line-dasharray": c.trailsDash,
}, },
}, beforeId) }, beforeId)
@ -941,8 +939,8 @@ function addUsfsTrails(map) {
filter: ["has", "NAME"], filter: ["has", "NAME"],
layout: { layout: {
"text-field": ["get", "NAME"], "text-field": ["get", "NAME"],
"text-size": 11, "text-size": c.roadsLabelSize,
"text-font": ["Noto Sans Regular"], "text-font": c.labelFont,
"symbol-placement": "line", "symbol-placement": "line",
"text-anchor": "center", "text-anchor": "center",
"symbol-spacing": 300, "symbol-spacing": 300,
@ -950,10 +948,10 @@ function addUsfsTrails(map) {
"text-allow-overlap": false, "text-allow-overlap": false,
}, },
paint: { paint: {
"text-color": isDark ? "#d0c0a0" : "#6a5a40", "text-color": c.roadsLabelColor,
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff", "text-halo-color": c.roadsLabelHaloColor,
"text-halo-width": 1.5, "text-halo-width": c.roadsLabelHaloWidth,
"text-opacity": 0.9, "text-opacity": c.roadsLabelOpacity,
}, },
}) })
@ -967,8 +965,8 @@ function addUsfsTrails(map) {
filter: ["has", "TRAIL_NAME"], filter: ["has", "TRAIL_NAME"],
layout: { layout: {
"text-field": ["get", "TRAIL_NAME"], "text-field": ["get", "TRAIL_NAME"],
"text-size": 11, "text-size": c.trailsLabelSize,
"text-font": ["Noto Sans Regular"], "text-font": c.labelFont,
"symbol-placement": "line", "symbol-placement": "line",
"text-anchor": "center", "text-anchor": "center",
"symbol-spacing": 300, "symbol-spacing": 300,
@ -976,10 +974,10 @@ function addUsfsTrails(map) {
"text-allow-overlap": false, "text-allow-overlap": false,
}, },
paint: { paint: {
"text-color": isDark ? "#d0b090" : "#5a4a30", "text-color": c.trailsLabelColor,
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff", "text-halo-color": c.trailsLabelHaloColor,
"text-halo-width": 1.5, "text-halo-width": c.trailsLabelHaloWidth,
"text-opacity": 0.9, "text-opacity": c.trailsLabelOpacity,
}, },
}) })
@ -1003,12 +1001,12 @@ function removeUsfsTrails(map) {
if (map.getLayer(USFS_ROADS_HIT)) map.removeLayer(USFS_ROADS_HIT) if (map.getLayer(USFS_ROADS_HIT)) map.removeLayer(USFS_ROADS_HIT)
if (map.getSource(USFS_SOURCE)) map.removeSource(USFS_SOURCE) if (map.getSource(USFS_SOURCE)) map.removeSource(USFS_SOURCE)
} }
/** Add BLM trails/roads vector tile overlay */
/** Add BLM trails/roads vector tile overlay with surface-type styling */ /** Add BLM trails/roads vector tile overlay with surface-type styling */
/** Add BLM trails/roads vector tile overlay with surface-type styling */ function addBlmTrails(map, themeId) {
function addBlmTrails(map) {
if (!map || map.getSource(BLM_SOURCE)) return if (!map || map.getSource(BLM_SOURCE)) return
const c = getOverlayConfig(themeId, 'blmTrails')
map.addSource(BLM_SOURCE, { map.addSource(BLM_SOURCE, {
type: "vector", type: "vector",
url: "pmtiles:///tiles/blm-trails-roads.pmtiles", url: "pmtiles:///tiles/blm-trails-roads.pmtiles",
@ -1023,40 +1021,38 @@ function addBlmTrails(map) {
} }
} }
const isDark = isCurrentThemeDark() // Color expression based on route use class
// Color expression based on route use class - brighter palette
const colorExpr = [ const colorExpr = [
"case", "case",
["any", ["any",
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD HIGH CLEARANCE / SPECIALIZED"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD HIGH CLEARANCE / SPECIALIZED"],
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD High Clearance/Specialized"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD High Clearance/Specialized"],
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4wd High Clearance / Specialized"] ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4wd High Clearance / Specialized"]
], isDark ? "#f08040" : "#e07030", ], c.color4wdHigh,
["any", ["any",
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD LOW"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD LOW"],
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD Low"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4WD Low"],
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4wd Low"] ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "4wd Low"]
], isDark ? "#e0b040" : "#d0a030", ], c.color4wdLow,
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "ATV"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "ATV"],
isDark ? "#e04040" : "#d03030", c.colorAtv,
["any", ["any",
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "MOTORIZED SINGLE TRACK"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "MOTORIZED SINGLE TRACK"],
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "Motorized Single Track"] ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "Motorized Single Track"]
], isDark ? "#b070c0" : "#a060b0", ], c.colorMotoSingle,
["any", ["any",
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "2WD LOW"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "2WD LOW"],
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "2WD Low"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "2WD Low"],
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "2wd Low"] ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "2wd Low"]
], isDark ? "#f0d070" : "#e0c060", ], c.color2wdLow,
["any", ["any",
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "NON-MECHANIZED"], ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "NON-MECHANIZED"],
["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "Non-Mechanized"] ["==", ["get", "OBSRVE_ROUTE_USE_CLASS"], "Non-Mechanized"]
], isDark ? "#60c050" : "#50b040", ], c.colorNonMech,
isDark ? "#c0a060" : "#b09050" c.colorDefault
] ]
const lineWidth = ["interpolate", ["linear"], ["zoom"], 10, 2.0, 14, 3.0, 16, 4.0] const lineWidth = ["interpolate", ["linear"], ["zoom"], 10, c.lineWidth.z10, 14, c.lineWidth.z14, 16, c.lineWidth.z16]
// Filter out paved, arterial, collector, local, and highways // Filter out paved, arterial, collector, local, and highways
const excludeUrban = [ const excludeUrban = [
@ -1088,7 +1084,7 @@ function addBlmTrails(map) {
paint: { paint: {
"line-color": "#000000", "line-color": "#000000",
"line-opacity": 0, "line-opacity": 0,
"line-width": 14, "line-width": c.hitWidth,
}, },
}, beforeId) }, beforeId)
@ -1107,7 +1103,7 @@ function addBlmTrails(map) {
], ],
paint: { paint: {
"line-color": colorExpr, "line-color": colorExpr,
"line-opacity": 0.9, "line-opacity": c.lineOpacity,
"line-width": lineWidth, "line-width": lineWidth,
}, },
}, beforeId) }, beforeId)
@ -1127,9 +1123,9 @@ function addBlmTrails(map) {
], ],
paint: { paint: {
"line-color": colorExpr, "line-color": colorExpr,
"line-opacity": 0.9, "line-opacity": c.lineOpacity,
"line-width": lineWidth, "line-width": lineWidth,
"line-dasharray": [4, 2], "line-dasharray": c.dashImproved,
}, },
}, beforeId) }, beforeId)
@ -1148,9 +1144,9 @@ function addBlmTrails(map) {
], ],
paint: { paint: {
"line-color": colorExpr, "line-color": colorExpr,
"line-opacity": 0.9, "line-opacity": c.lineOpacity,
"line-width": lineWidth, "line-width": lineWidth,
"line-dasharray": [1, 2], "line-dasharray": c.dashAggregate,
}, },
}, beforeId) }, beforeId)
@ -1168,10 +1164,10 @@ function addBlmTrails(map) {
] ]
], ],
paint: { paint: {
"line-color": isDark ? "#80b0e0" : "#6090c0", "line-color": c.colorSnow,
"line-opacity": 0.9, "line-opacity": c.lineOpacity,
"line-width": lineWidth, "line-width": lineWidth,
"line-dasharray": [4, 2, 1, 2], "line-dasharray": c.dashSnow,
}, },
}, beforeId) }, beforeId)
@ -1196,9 +1192,9 @@ function addBlmTrails(map) {
], ],
paint: { paint: {
"line-color": colorExpr, "line-color": colorExpr,
"line-opacity": 0.85, "line-opacity": c.lineOpacityOther,
"line-width": lineWidth, "line-width": lineWidth,
"line-dasharray": [4, 2, 1, 2, 1, 2], "line-dasharray": c.dashOther,
}, },
}, beforeId) }, beforeId)
@ -1212,8 +1208,8 @@ function addBlmTrails(map) {
filter: ["all", excludeUrban, ["has", "ROUTE_PRMRY_NM"]], filter: ["all", excludeUrban, ["has", "ROUTE_PRMRY_NM"]],
layout: { layout: {
"text-field": ["get", "ROUTE_PRMRY_NM"], "text-field": ["get", "ROUTE_PRMRY_NM"],
"text-size": 11, "text-size": c.labelSize,
"text-font": ["Noto Sans Regular"], "text-font": c.labelFont,
"symbol-placement": "line", "symbol-placement": "line",
"text-anchor": "center", "text-anchor": "center",
"symbol-spacing": 300, "symbol-spacing": 300,
@ -1221,10 +1217,10 @@ function addBlmTrails(map) {
"text-allow-overlap": false, "text-allow-overlap": false,
}, },
paint: { paint: {
"text-color": isDark ? "#d0c0a0" : "#5a4a30", "text-color": c.labelColor,
"text-halo-color": isDark ? "#1a1a1a" : "#ffffff", "text-halo-color": c.labelHaloColor,
"text-halo-width": 1.5, "text-halo-width": c.labelHaloWidth,
"text-opacity": 0.9, "text-opacity": c.labelOpacity,
}, },
}) })
@ -1236,6 +1232,7 @@ function addBlmTrails(map) {
map.getCanvas().style.cursor = "" map.getCanvas().style.cursor = ""
}) })
} }
/** Remove BLM trails/roads layers and source */ /** Remove BLM trails/roads layers and source */
function removeBlmTrails(map) { function removeBlmTrails(map) {
if (!map) return if (!map) return
@ -1685,7 +1682,7 @@ const MapView = forwardRef(function MapView(_, ref) {
addHillshadeLayer() { addHillshadeLayer() {
const map = mapInstance.current const map = mapInstance.current
if (!map) return if (!map) return
addHillshade(map) addHillshade(map, currentThemeRef.current)
activeLayersRef.current.hillshade = true activeLayersRef.current.hillshade = true
}, },
removeHillshadeLayer() { removeHillshadeLayer() {
@ -1697,7 +1694,7 @@ const MapView = forwardRef(function MapView(_, ref) {
addTrafficLayer() { addTrafficLayer() {
const map = mapInstance.current const map = mapInstance.current
if (!map) return if (!map) return
addTraffic(map) addTraffic(map, currentThemeRef.current)
activeLayersRef.current.traffic = true activeLayersRef.current.traffic = true
}, },
removeTrafficLayer() { removeTrafficLayer() {
@ -1709,7 +1706,7 @@ const MapView = forwardRef(function MapView(_, ref) {
addPublicLandsLayer() { addPublicLandsLayer() {
const map = mapInstance.current const map = mapInstance.current
if (!map) return if (!map) return
addPublicLands(map) addPublicLands(map, currentThemeRef.current)
activeLayersRef.current.publicLands = true activeLayersRef.current.publicLands = true
}, },
removePublicLandsLayer() { removePublicLandsLayer() {
@ -1721,7 +1718,7 @@ const MapView = forwardRef(function MapView(_, ref) {
addContoursLayer() { addContoursLayer() {
const map = mapInstance.current const map = mapInstance.current
if (!map) return if (!map) return
addContours(map) addContours(map, currentThemeRef.current)
activeLayersRef.current.contours = true activeLayersRef.current.contours = true
}, },
removeContoursLayer() { removeContoursLayer() {
@ -1733,7 +1730,7 @@ const MapView = forwardRef(function MapView(_, ref) {
addContoursTestLayer() { addContoursTestLayer() {
const map = mapInstance.current const map = mapInstance.current
if (!map) return if (!map) return
addContoursTest(map) addContoursTest(map, currentThemeRef.current)
activeLayersRef.current.contoursTest = true activeLayersRef.current.contoursTest = true
}, },
removeContoursTestLayer() { removeContoursTestLayer() {
@ -1745,7 +1742,7 @@ const MapView = forwardRef(function MapView(_, ref) {
addContoursTest10ftLayer() { addContoursTest10ftLayer() {
const map = mapInstance.current const map = mapInstance.current
if (!map) return if (!map) return
addContoursTest10ft(map) addContoursTest10ft(map, currentThemeRef.current)
activeLayersRef.current.contoursTest10ft = true activeLayersRef.current.contoursTest10ft = true
}, },
removeContoursTest10ftLayer() { removeContoursTest10ftLayer() {
@ -1757,7 +1754,7 @@ const MapView = forwardRef(function MapView(_, ref) {
addUsfsTrailsLayer() { addUsfsTrailsLayer() {
const map = mapInstance.current const map = mapInstance.current
if (!map) return if (!map) return
addUsfsTrails(map) addUsfsTrails(map, currentThemeRef.current)
activeLayersRef.current.usfsTrails = true activeLayersRef.current.usfsTrails = true
}, },
removeUsfsTrailsLayer() { removeUsfsTrailsLayer() {
@ -1769,7 +1766,7 @@ const MapView = forwardRef(function MapView(_, ref) {
addBlmTrailsLayer() { addBlmTrailsLayer() {
const map = mapInstance.current const map = mapInstance.current
if (!map) return if (!map) return
addBlmTrails(map) addBlmTrails(map, currentThemeRef.current)
activeLayersRef.current.blmTrails = true activeLayersRef.current.blmTrails = true
}, },
removeBlmTrailsLayer() { removeBlmTrailsLayer() {
@ -2139,24 +2136,24 @@ const MapView = forwardRef(function MapView(_, ref) {
if (raw) { if (raw) {
const prefs = JSON.parse(raw) const prefs = JSON.parse(raw)
if (prefs.hillshade && hasFeature('has_hillshade')) { if (prefs.hillshade && hasFeature('has_hillshade')) {
addHillshade(map) addHillshade(map, currentThemeRef.current)
activeLayersRef.current.hillshade = true activeLayersRef.current.hillshade = true
} }
if (prefs.traffic && hasFeature('has_traffic_overlay')) { if (prefs.traffic && hasFeature('has_traffic_overlay')) {
addTraffic(map) addTraffic(map, currentThemeRef.current)
activeLayersRef.current.traffic = true activeLayersRef.current.traffic = true
} }
if (prefs.publicLands && hasFeature('has_public_lands_layer')) { if (prefs.publicLands && hasFeature('has_public_lands_layer')) {
addPublicLands(map) addPublicLands(map, currentThemeRef.current)
activeLayersRef.current.publicLands = true activeLayersRef.current.publicLands = true
} }
if (prefs.contours && hasFeature('has_contours')) { if (prefs.contours && hasFeature('has_contours')) {
addContours(map) addContours(map, currentThemeRef.current)
activeLayersRef.current.contours = true activeLayersRef.current.contours = true
} }
} else if (hasFeature('has_hillshade')) { } else if (hasFeature('has_hillshade')) {
// Default: hillshade ON if available // Default: hillshade ON if available
addHillshade(map) addHillshade(map, currentThemeRef.current)
activeLayersRef.current.hillshade = true activeLayersRef.current.hillshade = true
} }
} catch {} } catch {}
@ -2343,10 +2340,14 @@ const MapView = forwardRef(function MapView(_, ref) {
applyBaseLabelStyling(map) applyBaseLabelStyling(map)
// Re-add active overlay layers // Re-add active overlay layers
if (activeLayersRef.current.hillshade) addHillshade(map) if (activeLayersRef.current.hillshade) addHillshade(map, currentThemeRef.current)
if (activeLayersRef.current.traffic) addTraffic(map) if (activeLayersRef.current.traffic) addTraffic(map, currentThemeRef.current)
if (activeLayersRef.current.publicLands) addPublicLands(map) if (activeLayersRef.current.publicLands) addPublicLands(map, currentThemeRef.current)
if (activeLayersRef.current.contours) addContours(map) 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)
// Clear highlights on theme change (paint values will be re-stored on next interaction) // Clear highlights on theme change (paint values will be re-stored on next interaction)
clearAllHighlights(map) clearAllHighlights(map)

View file

@ -1,107 +1,481 @@
/** /**
* Theme Registry for Navi * Theme Registry for Navi
* *
* Provides a centralized registry for map themes, supporting both built-in * Provides a centralized registry for map themes, supporting both built-in
* protomaps themes (light/dark) and custom themes with full flavor objects. * protomaps themes (light/dark) and custom themes with full flavor objects.
* *
* Theme config structure: * Theme config structure:
* id: string - unique identifier (used in store, data-theme attr) * id: string - unique identifier (used in store, data-theme attr)
* name: string - display name for UI * name: string - display name for UI
* dark: boolean - true if dark theme (affects overlay styling, sprite fallback) * dark: boolean - true if dark theme (affects overlay styling, sprite fallback)
* colors: object|null - null for built-in themes, full flavor object for custom * colors: object|null - null for built-in themes, full flavor object for custom
* satellite: object|null - raster adjustments when satellite layer is present * satellite: object|null - raster adjustments when satellite layer is present
* overlay: object|null - reserved for future overlay-specific customizations * overlay: object - overlay layer styling configuration
*/ */
import { namedTheme } from 'protomaps-themes-base' import { namedTheme } from 'protomaps-themes-base'
/** // ═══════════════════════════════════════════════════════════════════════════
* Theme registry - maps theme IDs to theme configurations // OVERLAY 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. /**
*/ * Dark theme overlay configuration
const themes = { * All hardcoded values from overlay add functions extracted here
light: { */
id: 'light', const darkOverlay = {
name: 'Light', // ── Hillshade ─────────────────────────────────────────────────────────────
dark: false, hillshade: {
colors: null, // Use namedTheme('light') exaggeration: 0.5,
satellite: null, illuminationDirection: 315,
overlay: null, shadowColor: '#000000',
}, highlightColor: '#ffffff',
dark: { },
id: 'dark',
name: 'Dark', // ── Traffic ───────────────────────────────────────────────────────────────
dark: true, traffic: {
colors: null, // Use namedTheme('dark') opacity: 0.6,
satellite: null, },
overlay: null,
}, // ── Contours (main, brown/tan scheme) ─────────────────────────────────────
// Custom themes go here. Example: contours: {
// 'midnight': { opacityMod: 0.8,
// id: 'midnight', minorColor: '#8b6f47',
// name: 'Midnight', minorOpacity: 0.4,
// dark: true, minorWidth: { z11: 0.5, z14: 1.0 },
// colors: { /* full flavor object matching dark-flavor-reference.json schema */ }, intermediateColor: '#8b6f47',
// satellite: { opacity: 0.8, brightnessMin: 0.1 }, intermediateOpacity: 0.7,
// overlay: null, intermediateWidth: { z8: 0.8, z14: 1.2 },
// }, indexColor: '#6b4f2a',
} indexOpacity: 0.9,
indexWidth: { z4: 1.2, z14: 1.8 },
/** labelColor: '#c0b898',
* Get a theme configuration by ID labelHaloColor: '#1a1a1a',
* @param {string} id - Theme ID labelHaloWidth: 1.5,
* @returns {object} Theme config, falls back to 'dark' if not found labelOpacity: 0.85,
*/ labelSize: 10,
export function getTheme(id) { labelFont: ['Noto Sans Regular'],
return themes[id] || themes.dark },
}
// ── Contours Test (blue scheme) ───────────────────────────────────────────
/** // Missing keys cascade from contours
* Get the color flavor for a theme contoursTest: {
* For built-in themes, calls namedTheme(). For custom themes, returns colors directly. minorColor: '#4a7c9b',
* @param {string} id - Theme ID intermediateColor: '#4a7c9b',
* @returns {object} Flavor object for use with protomaps layers() indexColor: '#2a5a7c',
*/ labelColor: '#98b8d0',
export function getThemeColors(id) { },
const theme = getTheme(id)
if (theme.colors === null) { // ── Contours Test 10ft (green scheme) ─────────────────────────────────────
// Built-in theme - use namedTheme from protomaps-themes-base // Missing keys cascade from contours
return namedTheme(id) contoursTest10ft: {
} minorColor: '#3a7c4f',
return theme.colors intermediateColor: '#3a7c4f',
} indexColor: '#2a5c3a',
labelColor: '#98c0a8',
/** },
* Get the sprite URL for a theme
* Built-in themes use their own sprites. Custom themes fall back to // ── Public Lands (PAD-US) ─────────────────────────────────────────────────
* dark or light sprite based on the theme's dark flag. publicLands: {
* @param {string} id - Theme ID opacityMod: 0.7,
* @returns {string} Full sprite URL // Fill colors per category
*/ fillWA: '#7c6b2f',
export function getThemeSprite(id) { fillNPS: '#3d6b1f',
const theme = getTheme(id) fillUSFS: '#5a7c2f',
// Custom themes don't have matching sprites on CDN - fall back based on dark flag fillBLM: '#c4a672',
const spriteTheme = theme.colors === null ? id : (theme.dark ? 'dark' : 'light') fillFWS: '#4a7a5a',
return `https://protomaps.github.io/basemaps-assets/sprites/v4/${spriteTheme}` fillSTAT: '#5a8c7c',
} fillLOC: '#8ca694',
fillDefault: '#a0a0a0',
/** // Fill base opacities (multiplied by opacityMod)
* Get list of available themes for UI display fillOpacityWA: 0.30,
* @returns {Array<{id: string, name: string, dark: boolean}>} fillOpacityNPS: 0.30,
*/ fillOpacityUSFS: 0.25,
export function themeList() { fillOpacityBLM: 0.20,
return Object.values(themes).map(({ id, name, dark }) => ({ id, name, dark })) fillOpacitySTAT: 0.25,
} fillOpacityLOC: 0.20,
fillOpacityDefault: 0.15,
/** // Outline colors per category
* Check if a theme ID is valid/registered outlineWA: '#5a4d20',
* @param {string} id - Theme ID to check outlineNPS: '#2a4a15',
* @returns {boolean} outlineUSFS: '#3d5520',
*/ outlineBLM: '#8a7343',
export function isValidTheme(id) { outlineFWS: '#2d5a3a',
return id in themes outlineSTAT: '#3d6055',
} outlineLOC: '#5c6e66',
outlineDefault: '#707070',
export default themes // 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,
},
dark: {
id: 'dark',
name: 'Dark',
dark: true,
colors: null, // Use namedTheme('dark')
satellite: null,
overlay: darkOverlay,
},
// 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 */ },
// },
}
// ═══════════════════════════════════════════════════════════════════════════
// 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
}
/**
* Get list of available themes for UI display
* @returns {Array<{id: string, name: string, dark: boolean}>}
*/
export function themeList() {
return Object.values(themes).map(({ id, name, dark }) => ({ id, name, dark }))
}
/**
* 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