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

View file

@ -1,107 +1,481 @@
/**
* 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|null - reserved for future overlay-specific customizations
*/
import { namedTheme } from 'protomaps-themes-base'
/**
* 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: null,
},
dark: {
id: 'dark',
name: 'Dark',
dark: true,
colors: null, // Use namedTheme('dark')
satellite: null,
overlay: null,
},
// 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: null,
// },
}
/**
* 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 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
/**
* 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
*/
import { namedTheme } from 'protomaps-themes-base'
// ═══════════════════════════════════════════════════════════════════════════
// 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,
},
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