From a1a499de0722a89fa43a05b9dfa4c651046348fe Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 30 Apr 2026 03:37:04 +0000 Subject: [PATCH] style(labels): improve label readability with Google Maps-style halos Apply solid opaque halos to interactive label layers for clean knockout against any background (parks, water, terrain): BASE (unhighlighted): - Text: #2a2a2a (light) / #e0e0e0 (dark) - Halo: solid white/black, 0.9 opacity, 1.8px width - Acts as background knockout, not decoration HOVER: - Text: pure black/white for focus - Halo: full opacity (1.0), 2px width - Subtle emphasis without glowing effect SELECTED: - Text: accent color (theme green) - Halo: solid white/black, full opacity, 2.2px width - Clear visual distinction for clicked item Key insight: halo is a readability tool, not visual effect. Keep it tight, opaque, and matching background intent. --- src/components/MapView.jsx | 58 +++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 81959ea..45996fe 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -79,9 +79,11 @@ function applyHighlightExpression(map, layerId) { const isDark = document.documentElement.getAttribute('data-theme') === 'dark' const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#7a9a6b' + // Hover: darken text slightly, bump halo to full opacity for focus effect const hoverColor = isDark ? '#ffffff' : '#000000' - const hoverHaloColor = isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.25)' - const selectedHaloColor = isDark ? 'rgba(122,154,107,0.6)' : 'rgba(122,154,107,0.4)' + const hoverHaloColor = isDark ? 'rgba(30,30,30,1)' : 'rgba(255,255,255,1)' + // Selected: accent color text with solid white halo at full opacity + const selectedHaloColor = isDark ? 'rgba(30,30,30,1)' : 'rgba(255,255,255,1)' const isHovered = highlightState.hoveredLayer === layerId && highlightState.hoveredName const isSelected = highlightState.selectedLayer === layerId && highlightState.selectedName @@ -95,18 +97,18 @@ function applyHighlightExpression(map, layerId) { 'case', ['==', ['get', 'name'], highlightState.selectedName], accentColor, ['==', ['get', 'name'], highlightState.hoveredName], hoverColor, - orig['text-color'] || (isDark ? '#c0c0c0' : '#333333') + orig['text-color'] || (isDark ? '#e0e0e0' : '#2a2a2a') ]) map.setPaintProperty(layerId, 'text-halo-color', [ 'case', ['==', ['get', 'name'], highlightState.selectedName], selectedHaloColor, ['==', ['get', 'name'], highlightState.hoveredName], hoverHaloColor, - orig['text-halo-color'] || (isDark ? '#1a1a1a' : '#ffffff') + orig['text-halo-color'] || (isDark ? 'rgba(20,20,20,0.9)' : 'rgba(255,255,255,0.9)') ]) map.setPaintProperty(layerId, 'text-halo-width', [ 'case', - ['==', ['get', 'name'], highlightState.selectedName], 3, - ['==', ['get', 'name'], highlightState.hoveredName], 2.5, + ['==', ['get', 'name'], highlightState.selectedName], 2.2, + ['==', ['get', 'name'], highlightState.hoveredName], 2, orig['text-halo-width'] || 1.5 ]) } else if (isSelected) { @@ -114,34 +116,34 @@ function applyHighlightExpression(map, layerId) { map.setPaintProperty(layerId, 'text-color', [ 'case', ['==', ['get', 'name'], highlightState.selectedName], accentColor, - orig['text-color'] || (isDark ? '#c0c0c0' : '#333333') + orig['text-color'] || (isDark ? '#e0e0e0' : '#2a2a2a') ]) map.setPaintProperty(layerId, 'text-halo-color', [ 'case', ['==', ['get', 'name'], highlightState.selectedName], selectedHaloColor, - orig['text-halo-color'] || (isDark ? '#1a1a1a' : '#ffffff') + orig['text-halo-color'] || (isDark ? 'rgba(20,20,20,0.9)' : 'rgba(255,255,255,0.9)') ]) map.setPaintProperty(layerId, 'text-halo-width', [ 'case', - ['==', ['get', 'name'], highlightState.selectedName], 3, - orig['text-halo-width'] || 1.5 + ['==', ['get', 'name'], highlightState.selectedName], 2.2, + orig['text-halo-width'] || 1.8 ]) } else if (isHovered) { // Only hovered map.setPaintProperty(layerId, 'text-color', [ 'case', ['==', ['get', 'name'], highlightState.hoveredName], hoverColor, - orig['text-color'] || (isDark ? '#c0c0c0' : '#333333') + orig['text-color'] || (isDark ? '#e0e0e0' : '#2a2a2a') ]) map.setPaintProperty(layerId, 'text-halo-color', [ 'case', ['==', ['get', 'name'], highlightState.hoveredName], hoverHaloColor, - orig['text-halo-color'] || (isDark ? '#1a1a1a' : '#ffffff') + orig['text-halo-color'] || (isDark ? 'rgba(20,20,20,0.9)' : 'rgba(255,255,255,0.9)') ]) map.setPaintProperty(layerId, 'text-halo-width', [ 'case', - ['==', ['get', 'name'], highlightState.hoveredName], 2.5, - orig['text-halo-width'] || 1.5 + ['==', ['get', 'name'], highlightState.hoveredName], 2, + orig['text-halo-width'] || 1.8 ]) } else { // No highlight on this layer - restore original @@ -216,6 +218,28 @@ function clearAllHighlights(map) { layers.forEach(layerId => restoreOriginalPaint(map, layerId)) } +/** Apply improved base label styling for readability (Google Maps style) */ +function applyBaseLabelStyling(map) { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark' + + INTERACTIVE_LABEL_LAYERS.forEach(layerId => { + if (!map.getLayer(layerId)) return + + // Base styling: dark text with solid opaque white halo for knockout effect + // This ensures labels read cleanly over any background (parks, water, terrain) + map.setPaintProperty(layerId, 'text-color', isDark ? '#e0e0e0' : '#2a2a2a') + map.setPaintProperty(layerId, 'text-halo-color', isDark ? 'rgba(20,20,20,0.9)' : 'rgba(255,255,255,0.9)') + map.setPaintProperty(layerId, 'text-halo-width', 1.8) + + // Store these as the original values for highlight restoration + originalPaintValues[layerId] = { + 'text-color': isDark ? '#e0e0e0' : '#2a2a2a', + 'text-halo-color': isDark ? 'rgba(20,20,20,0.9)' : 'rgba(255,255,255,0.9)', + 'text-halo-width': 1.8, + } + }) +} + /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() @@ -1564,6 +1588,9 @@ const MapView = forwardRef(function MapView(_, ref) { addBoundaryLayer(map) } + // Apply improved base label styling for readability + applyBaseLabelStyling(map) + // Restore overlay layers from localStorage prefs try { const raw = localStorage.getItem('navi-layer-prefs') @@ -1770,6 +1797,9 @@ const MapView = forwardRef(function MapView(_, ref) { addBoundaryLayer(map) } + // Apply improved base label styling for readability + applyBaseLabelStyling(map) + // Re-add active overlay layers if (activeLayersRef.current.hillshade) addHillshade(map) if (activeLayersRef.current.traffic) addTraffic(map)