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.
This commit is contained in:
Matt 2026-04-30 03:37:04 +00:00
commit a1a499de07

View file

@ -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)