diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 0bd2507..6d6b3e9 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2,7 +2,8 @@ import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from 're import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' -import { layers, namedTheme } from 'protomaps-themes-base' +import { layers } from 'protomaps-themes-base' +import { getTheme, getThemeColors, getThemeSprite } from '../themes/registry' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' import { fetchReverse } from '../api' @@ -12,6 +13,13 @@ import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' + +/** Check if current theme is dark based on registry */ +function isCurrentThemeDark() { + const themeId = document.documentElement.getAttribute('data-theme') || 'dark' + return getTheme(themeId).dark +} + const ROUTE_SOURCE = 'route-source' const BOUNDARY_SOURCE = 'boundary-source' const BOUNDARY_LAYER = 'boundary-layer' @@ -92,7 +100,7 @@ function applyHighlightExpression(map, layerId) { storeOriginalPaint(map, layerId) const orig = originalPaintValues[layerId] - const isDark = document.documentElement.getAttribute('data-theme') === 'dark' + const isDark = isCurrentThemeDark() const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#7a9a6b' // Hover: darken text slightly, bump halo to full opacity for focus effect @@ -236,7 +244,7 @@ function clearAllHighlights(map) { /** Apply improved base label styling for readability (Google Maps style) */ function applyBaseLabelStyling(map) { - const isDark = document.documentElement.getAttribute('data-theme') === 'dark' + const isDark = isCurrentThemeDark() INTERACTIVE_LABEL_LAYERS.forEach(layerId => { if (!map.getLayer(layerId)) return @@ -265,7 +273,7 @@ function buildStyle(themeName) { return { version: 8, glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf', - sprite: `https://protomaps.github.io/basemaps-assets/sprites/v4/${themeName}`, + sprite: getThemeSprite(themeName), sources: { protomaps: { type: 'vector', @@ -273,7 +281,7 @@ function buildStyle(themeName) { attribution, }, }, - layers: layers('protomaps', namedTheme(themeName), { lang: 'en' }), + layers: layers('protomaps', getThemeColors(themeName), { lang: 'en' }), } } @@ -397,7 +405,7 @@ function addPublicLands(map) { } } - const isDark = document.documentElement.getAttribute('data-theme') === 'dark' + const isDark = isCurrentThemeDark() const opacityMod = isDark ? 0.7 : 1.0 // Fill layer — data-driven color by agency + designation @@ -541,7 +549,7 @@ function addContours(map) { } } - const isDark = document.documentElement.getAttribute('data-theme') === 'dark' + const isDark = isCurrentThemeDark() const opMod = isDark ? 0.8 : 1.0 // Minor contours (40ft) — visible z11+ @@ -643,7 +651,7 @@ function addContoursTest(map) { } } - const isDark = document.documentElement.getAttribute("data-theme") === "dark" + const isDark = isCurrentThemeDark() const opMod = isDark ? 0.8 : 1.0 // Minor contours (40ft) — blue scheme @@ -745,7 +753,7 @@ function addContoursTest10ft(map) { } } - const isDark = document.documentElement.getAttribute("data-theme") === "dark" + const isDark = isCurrentThemeDark() const opMod = isDark ? 0.8 : 1.0 // Minor contours (10ft) — green scheme @@ -848,7 +856,7 @@ function addUsfsTrails(map) { } } - const isDark = document.documentElement.getAttribute("data-theme") === "dark" + const isDark = isCurrentThemeDark() // Invisible hit-area layers for easier clicking map.addLayer({ @@ -1015,7 +1023,7 @@ function addBlmTrails(map) { } } - const isDark = document.documentElement.getAttribute("data-theme") === "dark" + const isDark = isCurrentThemeDark() // Color expression based on route use class - brighter palette const colorExpr = [ diff --git a/src/themes/README.md b/src/themes/README.md new file mode 100644 index 0000000..e110d59 --- /dev/null +++ b/src/themes/README.md @@ -0,0 +1,170 @@ +# Navi Theme System + +This directory contains the theme registry and reference files for creating custom map themes. + +## Files + +- **registry.js** - Theme registry with getTheme(), getThemeColors(), getThemeSprite(), themeList() +- **dark-flavor-reference.json** - Full namedTheme('dark') output for reference +- **light-flavor-reference.json** - Full namedTheme('light') output for reference + +## Creating Custom Themes + +Custom themes must provide a complete `colors` object matching the flavor schema from protomaps-themes-base. + +### Required Structure + +The flavor object has **73 flat color keys** plus **2 nested objects**: + +```javascript +{ + // === FLAT COLOR KEYS (73 total) === + + // Background & earth + "background": "#34373d", + "earth": "#1f1f1f", + + // Land use areas + "park_a": "#1c2421", + "park_b": "#192a24", + "hospital": "#252424", + "industrial": "#222222", + "school": "#262323", + "wood_a": "#202121", + "wood_b": "#202121", + "pedestrian": "#1e1e1e", + "scrub_a": "#222323", + "scrub_b": "#222323", + "glacier": "#1c1c1c", + "sand": "#212123", + "beach": "#28282a", + "aerodrome": "#1e1e1e", + "runway": "#333333", + "water": "#31353f", + "zoo": "#222323", + "military": "#242323", + "pier": "#333333", + "buildings": "#111111", + + // Tunnels + "tunnel_other_casing": "#141414", + "tunnel_minor_casing": "#141414", + "tunnel_link_casing": "#141414", + "tunnel_major_casing": "#141414", + "tunnel_highway_casing": "#141414", + "tunnel_other": "#292929", + "tunnel_minor": "#292929", + "tunnel_link": "#292929", + "tunnel_major": "#292929", + "tunnel_highway": "#292929", + + // Roads & casings + "minor_service_casing": "#1f1f1f", + "minor_casing": "#1f1f1f", + "link_casing": "#1f1f1f", + "major_casing_late": "#1f1f1f", + "highway_casing_late": "#1f1f1f", + "major_casing_early": "#1f1f1f", + "highway_casing_early": "#1f1f1f", + "other": "#333333", + "minor_service": "#333333", + "minor_a": "#3d3d3d", + "minor_b": "#333333", + "link": "#3d3d3d", + "major": "#3d3d3d", + "highway": "#474747", + "railway": "#000000", + "boundaries": "#5b6374", + + // Bridges + "bridges_other_casing": "#2b2b2b", + "bridges_minor_casing": "#1f1f1f", + "bridges_link_casing": "#1f1f1f", + "bridges_major_casing": "#1f1f1f", + "bridges_highway_casing": "#1f1f1f", + "bridges_other": "#333333", + "bridges_minor": "#333333", + "bridges_link": "#3d3d3d", + "bridges_major": "#3d3d3d", + "bridges_highway": "#474747", + + // Labels + "waterway_label": "#717784", + "roads_label_minor": "#525252", + "roads_label_minor_halo": "#1f1f1f", + "roads_label_major": "#666666", + "roads_label_major_halo": "#1f1f1f", + "ocean_label": "#717784", + "peak_label": "#898080", + "subplace_label": "#525252", + "subplace_label_halo": "#1f1f1f", + "city_label": "#7a7a7a", + "city_label_halo": "#212121", + "state_label": "#3d3d3d", + "state_label_halo": "#1f1f1f", + "country_label": "#5c5c5c", + "address_label": "#525252", + "address_label_halo": "#1f1f1f", + + // === NESTED OBJECTS (REQUIRED) === + + // POI icon colors - all 8 keys required + "pois": { + "blue": "#4299BB", + "green": "#30C573", + "lapis": "#2B5CEA", + "pink": "#EF56BA", + "red": "#F2567A", + "slategray": "#93939F", + "tangerine": "#F19B6E", + "turquoise": "#00C3D4" + }, + + // Landcover fill colors - all 7 keys required + "landcover": { + "grassland": "rgba(30, 41, 31, 1)", + "barren": "rgba(38, 38, 36, 1)", + "urban_area": "rgba(28, 28, 28, 1)", + "farmland": "rgba(31, 36, 32, 1)", + "glacier": "rgba(43, 43, 43, 1)", + "scrub": "rgba(34, 36, 30, 1)", + "forest": "rgba(28, 41, 37, 1)" + } +} +``` + +### Theme Config + +Add custom themes to `registry.js`: + +```javascript +const themes = { + // ... existing themes ... + + 'sepia': { + id: 'sepia', + name: 'Sepia', + dark: false, // Affects overlay styling and sprite fallback + colors: { + // Full flavor object (all 73 flat keys + pois + landcover) + }, + satellite: { + // Optional: raster adjustments for satellite layer + opacity: 1.0, + brightnessMin: 0, + brightnessMax: 1, + contrast: 0, + saturation: 0, + hueRotate: 0, + }, + overlay: null, // Reserved for future use + }, +} +``` + +### Important Notes + +1. **All keys are required** - protomaps-themes-base expects every key +2. **Nested objects matter** - `pois` and `landcover` are objects, not flat keys +3. **Sprite fallback** - Custom themes fall back to dark/light sprite based on `dark` flag +4. **CSS vars separate** - Map flavor colors are separate from UI CSS custom properties diff --git a/src/themes/dark-flavor-reference.json b/src/themes/dark-flavor-reference.json new file mode 100644 index 0000000..27d2180 --- /dev/null +++ b/src/themes/dark-flavor-reference.json @@ -0,0 +1,95 @@ +{ + "background": "#34373d", + "earth": "#1f1f1f", + "park_a": "#1c2421", + "park_b": "#192a24", + "hospital": "#252424", + "industrial": "#222222", + "school": "#262323", + "wood_a": "#202121", + "wood_b": "#202121", + "pedestrian": "#1e1e1e", + "scrub_a": "#222323", + "scrub_b": "#222323", + "glacier": "#1c1c1c", + "sand": "#212123", + "beach": "#28282a", + "aerodrome": "#1e1e1e", + "runway": "#333333", + "water": "#31353f", + "zoo": "#222323", + "military": "#242323", + "tunnel_other_casing": "#141414", + "tunnel_minor_casing": "#141414", + "tunnel_link_casing": "#141414", + "tunnel_major_casing": "#141414", + "tunnel_highway_casing": "#141414", + "tunnel_other": "#292929", + "tunnel_minor": "#292929", + "tunnel_link": "#292929", + "tunnel_major": "#292929", + "tunnel_highway": "#292929", + "pier": "#333333", + "buildings": "#111111", + "minor_service_casing": "#1f1f1f", + "minor_casing": "#1f1f1f", + "link_casing": "#1f1f1f", + "major_casing_late": "#1f1f1f", + "highway_casing_late": "#1f1f1f", + "other": "#333333", + "minor_service": "#333333", + "minor_a": "#3d3d3d", + "minor_b": "#333333", + "link": "#3d3d3d", + "major_casing_early": "#1f1f1f", + "major": "#3d3d3d", + "highway_casing_early": "#1f1f1f", + "highway": "#474747", + "railway": "#000000", + "boundaries": "#5b6374", + "waterway_label": "#717784", + "bridges_other_casing": "#2b2b2b", + "bridges_minor_casing": "#1f1f1f", + "bridges_link_casing": "#1f1f1f", + "bridges_major_casing": "#1f1f1f", + "bridges_highway_casing": "#1f1f1f", + "bridges_other": "#333333", + "bridges_minor": "#333333", + "bridges_link": "#3d3d3d", + "bridges_major": "#3d3d3d", + "bridges_highway": "#474747", + "roads_label_minor": "#525252", + "roads_label_minor_halo": "#1f1f1f", + "roads_label_major": "#666666", + "roads_label_major_halo": "#1f1f1f", + "ocean_label": "#717784", + "peak_label": "#898080", + "subplace_label": "#525252", + "subplace_label_halo": "#1f1f1f", + "city_label": "#7a7a7a", + "city_label_halo": "#212121", + "state_label": "#3d3d3d", + "state_label_halo": "#1f1f1f", + "country_label": "#5c5c5c", + "address_label": "#525252", + "address_label_halo": "#1f1f1f", + "pois": { + "blue": "#4299BB", + "green": "#30C573", + "lapis": "#2B5CEA", + "pink": "#EF56BA", + "red": "#F2567A", + "slategray": "#93939F", + "tangerine": "#F19B6E", + "turquoise": "#00C3D4" + }, + "landcover": { + "grassland": "rgba(30, 41, 31, 1)", + "barren": "rgba(38, 38, 36, 1)", + "urban_area": "rgba(28, 28, 28, 1)", + "farmland": "rgba(31, 36, 32, 1)", + "glacier": "rgba(43, 43, 43, 1)", + "scrub": "rgba(34, 36, 30, 1)", + "forest": "rgba(28, 41, 37, 1)" + } +} \ No newline at end of file diff --git a/src/themes/light-flavor-reference.json b/src/themes/light-flavor-reference.json new file mode 100644 index 0000000..fdd8bbd --- /dev/null +++ b/src/themes/light-flavor-reference.json @@ -0,0 +1,95 @@ +{ + "background": "#cccccc", + "earth": "#e2dfda", + "park_a": "#cfddd5", + "park_b": "#9cd3b4", + "hospital": "#e4dad9", + "industrial": "#d1dde1", + "school": "#e4ded7", + "wood_a": "#d0ded0", + "wood_b": "#a0d9a0", + "pedestrian": "#e3e0d4", + "scrub_a": "#cedcd7", + "scrub_b": "#99d2bb", + "glacier": "#e7e7e7", + "sand": "#e2e0d7", + "beach": "#e8e4d0", + "aerodrome": "#dadbdf", + "runway": "#e9e9ed", + "water": "#80deea", + "zoo": "#c6dcdc", + "military": "#dcdcdc", + "tunnel_other_casing": "#e0e0e0", + "tunnel_minor_casing": "#e0e0e0", + "tunnel_link_casing": "#e0e0e0", + "tunnel_major_casing": "#e0e0e0", + "tunnel_highway_casing": "#e0e0e0", + "tunnel_other": "#d5d5d5", + "tunnel_minor": "#d5d5d5", + "tunnel_link": "#d5d5d5", + "tunnel_major": "#d5d5d5", + "tunnel_highway": "#d5d5d5", + "pier": "#e0e0e0", + "buildings": "#cccccc", + "minor_service_casing": "#e0e0e0", + "minor_casing": "#e0e0e0", + "link_casing": "#e0e0e0", + "major_casing_late": "#e0e0e0", + "highway_casing_late": "#e0e0e0", + "other": "#ebebeb", + "minor_service": "#ebebeb", + "minor_a": "#ebebeb", + "minor_b": "#ffffff", + "link": "#ffffff", + "major_casing_early": "#e0e0e0", + "major": "#ffffff", + "highway_casing_early": "#e0e0e0", + "highway": "#ffffff", + "railway": "#a7b1b3", + "boundaries": "#adadad", + "waterway_label": "#ffffff", + "bridges_other_casing": "#e0e0e0", + "bridges_minor_casing": "#e0e0e0", + "bridges_link_casing": "#e0e0e0", + "bridges_major_casing": "#e0e0e0", + "bridges_highway_casing": "#e0e0e0", + "bridges_other": "#ebebeb", + "bridges_minor": "#ffffff", + "bridges_link": "#ffffff", + "bridges_major": "#f5f5f5", + "bridges_highway": "#ffffff", + "roads_label_minor": "#91888b", + "roads_label_minor_halo": "#ffffff", + "roads_label_major": "#938a8d", + "roads_label_major_halo": "#ffffff", + "ocean_label": "#728dd4", + "peak_label": "#7e9aa0", + "subplace_label": "#8f8f8f", + "subplace_label_halo": "#e0e0e0", + "city_label": "#5c5c5c", + "city_label_halo": "#e0e0e0", + "state_label": "#b3b3b3", + "state_label_halo": "#e0e0e0", + "country_label": "#a3a3a3", + "address_label": "#91888b", + "address_label_halo": "#ffffff", + "pois": { + "blue": "#1A8CBD", + "green": "#20834D", + "lapis": "#315BCF", + "pink": "#EF56BA", + "red": "#F2567A", + "slategray": "#6A5B8F", + "tangerine": "#CB6704", + "turquoise": "#00C3D4" + }, + "landcover": { + "grassland": "rgba(210, 239, 207, 1)", + "barren": "rgba(255, 243, 215, 1)", + "urban_area": "rgba(230, 230, 230, 1)", + "farmland": "rgba(216, 239, 210, 1)", + "glacier": "rgba(255, 255, 255, 1)", + "scrub": "rgba(234, 239, 210, 1)", + "forest": "rgba(196, 231, 210, 1)" + } +} \ No newline at end of file diff --git a/src/themes/registry.js b/src/themes/registry.js new file mode 100644 index 0000000..80741d5 --- /dev/null +++ b/src/themes/registry.js @@ -0,0 +1,107 @@ +/** + * 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