fix(map): boundary fill opacity and highlight text duplication

Two fixes for rendering at close zoom (z14+):

1. Boundary rendering:
   - Add subtle fill layer with 0.05 opacity (barely visible tint)
   - Insert fill and outline layers BELOW symbol layers using
     firstSymbolId so labels render on top
   - Dashed outline remains primary indicator

2. Highlight text duplication:
   - Remove separate hover-hl-* and selected-hl-* symbol layers
     that were creating duplicate/ghost text
   - Instead, modify ORIGINAL layer paint properties directly using
     setPaintProperty on text-color, text-halo-color, text-halo-width
   - Store original paint values for restoration on clear
   - Single symbol layer per label = no duplication

Test at z14+ on small places like Rock Creek Park - no text ghosting.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-04-30 03:03:18 +00:00
commit 39996fdafe

View file

@ -43,44 +43,78 @@ const MEASURE_SOURCE = 'measure-source'
const MEASURE_LINE_LAYER = 'measure-line-layer' const MEASURE_LINE_LAYER = 'measure-line-layer'
const MEASURE_POINT_LAYER = 'measure-point-layer' const MEASURE_POINT_LAYER = 'measure-point-layer'
// Highlight layers (filter-based for PMTiles compatibility) // Highlight state - modify original layer paint properties (no duplicate layers)
const HIGHLIGHT_SOURCE_LAYERS = ['places', 'pois'] const INTERACTIVE_LABEL_LAYERS = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country']
const EMPTY_FILTER = ['==', ['get', 'name'], '___NOMATCH___'] let originalPaintValues = {} // Store original paint values for restoration
let currentHighlightedLayer = null
let currentHoveredLayer = null
function setupHighlightLayers(map, isDark) { function storeOriginalPaint(map, layerId) {
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#7a9a6b' if (originalPaintValues[layerId]) return // Already stored
HIGHLIGHT_SOURCE_LAYERS.forEach(sl => { if (!map.getLayer(layerId)) return
if (map.getLayer('hover-hl-' + sl)) map.removeLayer('hover-hl-' + sl) originalPaintValues[layerId] = {
if (map.getLayer('selected-hl-' + sl)) map.removeLayer('selected-hl-' + sl) 'text-color': map.getPaintProperty(layerId, 'text-color'),
}) 'text-halo-color': map.getPaintProperty(layerId, 'text-halo-color'),
HIGHLIGHT_SOURCE_LAYERS.forEach(sourceLayer => { 'text-halo-width': map.getPaintProperty(layerId, 'text-halo-width'),
map.addLayer({ }
id: 'hover-hl-' + sourceLayer, type: 'symbol', source: 'protomaps', 'source-layer': sourceLayer, }
filter: EMPTY_FILTER,
layout: { 'text-field': ['coalesce', ['get', 'name:en'], ['get', 'name']], 'text-font': ['Noto Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 10, 10, 14, 16, 18], 'text-allow-overlap': true, 'text-ignore-placement': true }, function restoreOriginalPaint(map, layerId) {
paint: { 'text-color': isDark ? '#ffffff' : '#000000', 'text-halo-color': isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)', 'text-halo-width': 2.5 }, if (!originalPaintValues[layerId] || !map.getLayer(layerId)) return
}) const orig = originalPaintValues[layerId]
map.addLayer({ if (orig['text-color'] !== undefined) map.setPaintProperty(layerId, 'text-color', orig['text-color'])
id: 'selected-hl-' + sourceLayer, type: 'symbol', source: 'protomaps', 'source-layer': sourceLayer, if (orig['text-halo-color'] !== undefined) map.setPaintProperty(layerId, 'text-halo-color', orig['text-halo-color'])
filter: EMPTY_FILTER, if (orig['text-halo-width'] !== undefined) map.setPaintProperty(layerId, 'text-halo-width', orig['text-halo-width'])
layout: { 'text-field': ['coalesce', ['get', 'name:en'], ['get', 'name']], 'text-font': ['Noto Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 10, 10, 14, 16, 18], 'text-allow-overlap': true, 'text-ignore-placement': true },
paint: { 'text-color': accentColor, 'text-halo-color': isDark ? 'rgba(122,154,107,0.5)' : 'rgba(122,154,107,0.3)', 'text-halo-width': 3 },
})
})
} }
function setHoverHighlight(map, feature) { function setHoverHighlight(map, feature) {
HIGHLIGHT_SOURCE_LAYERS.forEach(sl => { if (map.getLayer('hover-hl-' + sl)) map.setFilter('hover-hl-' + sl, EMPTY_FILTER) }) // Restore previous hovered layer
if (currentHoveredLayer && currentHoveredLayer !== currentHighlightedLayer) {
restoreOriginalPaint(map, currentHoveredLayer)
}
currentHoveredLayer = null
if (!feature) return if (!feature) return
const name = feature.properties?.name, sourceLayer = feature.sourceLayer const layerId = feature.layer?.id
if (name && sourceLayer && map.getLayer('hover-hl-' + sourceLayer)) map.setFilter('hover-hl-' + sourceLayer, ['==', ['get', 'name'], name]) if (!layerId || !map.getLayer(layerId)) return
if (layerId === currentHighlightedLayer) return // Don't hover over selected
storeOriginalPaint(map, layerId)
currentHoveredLayer = layerId
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
map.setPaintProperty(layerId, 'text-color', isDark ? '#ffffff' : '#000000')
map.setPaintProperty(layerId, 'text-halo-color', isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.25)')
map.setPaintProperty(layerId, 'text-halo-width', 2.5)
} }
function setSelectedHighlight(map, feature) { function setSelectedHighlight(map, feature) {
HIGHLIGHT_SOURCE_LAYERS.forEach(sl => { if (map.getLayer('selected-hl-' + sl)) map.setFilter('selected-hl-' + sl, EMPTY_FILTER) }) // Restore previous highlighted layer
if (currentHighlightedLayer) {
restoreOriginalPaint(map, currentHighlightedLayer)
}
currentHighlightedLayer = null
currentHoveredLayer = null // Also clear hover
if (!feature) return if (!feature) return
const name = feature.properties?.name, sourceLayer = feature.sourceLayer const layerId = feature.layer?.id
if (name && sourceLayer && map.getLayer('selected-hl-' + sourceLayer)) map.setFilter('selected-hl-' + sourceLayer, ['==', ['get', 'name'], name]) if (!layerId || !map.getLayer(layerId)) return
storeOriginalPaint(map, layerId)
currentHighlightedLayer = layerId
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#7a9a6b'
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
map.setPaintProperty(layerId, 'text-color', accentColor)
map.setPaintProperty(layerId, 'text-halo-color', isDark ? 'rgba(122,154,107,0.6)' : 'rgba(122,154,107,0.4)')
map.setPaintProperty(layerId, 'text-halo-width', 3)
}
function clearAllHighlights(map) {
if (currentHoveredLayer) restoreOriginalPaint(map, currentHoveredLayer)
if (currentHighlightedLayer) restoreOriginalPaint(map, currentHighlightedLayer)
currentHoveredLayer = null
currentHighlightedLayer = null
} }
/** Build a full MapLibre style object for the given theme */ /** Build a full MapLibre style object for the given theme */
@ -656,7 +690,9 @@ function removeContoursTest10ft(map) {
if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR) if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR)
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 boundary polygon layer with computed accent color (MapLibre rejects CSS vars in paint) */ /** Add boundary polygon layers with computed accent color (MapLibre rejects CSS vars in paint) */
const BOUNDARY_FILL_LAYER = 'boundary-fill-layer'
function addBoundaryLayer(map) { function addBoundaryLayer(map) {
if (!map || map.getLayer(BOUNDARY_LAYER)) return if (!map || map.getLayer(BOUNDARY_LAYER)) return
if (!map.getSource(BOUNDARY_SOURCE)) { if (!map.getSource(BOUNDARY_SOURCE)) {
@ -666,6 +702,29 @@ function addBoundaryLayer(map) {
}) })
} }
const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b" const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b"
// Find first symbol layer to insert boundary layers below labels
const layers = map.getStyle().layers
let firstSymbolId = null
for (const layer of layers) {
if (layer.type === 'symbol') {
firstSymbolId = layer.id
break
}
}
// Add subtle fill layer (barely visible tint)
map.addLayer({
id: BOUNDARY_FILL_LAYER,
type: "fill",
source: BOUNDARY_SOURCE,
paint: {
"fill-color": accentColor,
"fill-opacity": 0.05,
},
}, firstSymbolId)
// Add dashed outline layer
map.addLayer({ map.addLayer({
id: BOUNDARY_LAYER, id: BOUNDARY_LAYER,
type: "line", type: "line",
@ -676,7 +735,7 @@ function addBoundaryLayer(map) {
"line-opacity": 0.7, "line-opacity": 0.7,
"line-dasharray": [3, 2], "line-dasharray": [3, 2],
}, },
}) }, firstSymbolId)
} }
const MapView = forwardRef(function MapView(_, ref) { const MapView = forwardRef(function MapView(_, ref) {
@ -1434,8 +1493,6 @@ const MapView = forwardRef(function MapView(_, ref) {
} }
} catch {} } catch {}
// Set up highlight layers
setupHighlightLayers(map, document.documentElement.getAttribute('data-theme') === 'dark')
// Register updateBoundary function - called directly when boundary data arrives // Register updateBoundary function - called directly when boundary data arrives
const updateBoundaryFn = (boundaryGeometry) => { const updateBoundaryFn = (boundaryGeometry) => {
@ -1620,8 +1677,9 @@ const MapView = forwardRef(function MapView(_, ref) {
if (activeLayersRef.current.publicLands) addPublicLands(map) if (activeLayersRef.current.publicLands) addPublicLands(map)
if (activeLayersRef.current.contours) addContours(map) if (activeLayersRef.current.contours) addContours(map)
// Re-setup highlight layers // Clear highlights on theme change (paint values will be re-stored on next interaction)
setupHighlightLayers(map, theme === 'dark') clearAllHighlights(map)
originalPaintValues = {}
// Restore view // Restore view
map.jumpTo({ center, zoom, bearing, pitch }) map.jumpTo({ center, zoom, bearing, pitch })