fix(highlight): use data-driven expressions to target specific features

Instead of changing entire layer paint properties (which highlights all
labels in the layer), use MapLibre case expressions to target only the
specific feature by name. This prevents highlighting ALL labels when
hovering/selecting one.

Expression format:
  ["case", ["==", ["get", "name"], featureName], highlightColor, originalColor]

Fixes text duplication at z14+ on small places.
This commit is contained in:
Matt 2026-04-30 03:07:43 +00:00
commit 5010b45a7c

View file

@ -43,14 +43,18 @@ 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 state - modify original layer paint properties (no duplicate layers) // Highlight state - use data-driven expressions to target specific features
const INTERACTIVE_LABEL_LAYERS = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country'] const INTERACTIVE_LABEL_LAYERS = ['pois', 'places_subplace', 'places_locality', 'places_region', 'places_country']
let originalPaintValues = {} // Store original paint values for restoration let originalPaintValues = {} // Store original paint values for restoration
let currentHighlightedLayer = null let highlightState = {
let currentHoveredLayer = null hoveredLayer: null,
hoveredName: null,
selectedLayer: null,
selectedName: null,
}
function storeOriginalPaint(map, layerId) { function storeOriginalPaint(map, layerId) {
if (originalPaintValues[layerId]) return // Already stored if (originalPaintValues[layerId]) return
if (!map.getLayer(layerId)) return if (!map.getLayer(layerId)) return
originalPaintValues[layerId] = { originalPaintValues[layerId] = {
'text-color': map.getPaintProperty(layerId, 'text-color'), 'text-color': map.getPaintProperty(layerId, 'text-color'),
@ -67,54 +71,149 @@ function restoreOriginalPaint(map, layerId) {
if (orig['text-halo-width'] !== undefined) map.setPaintProperty(layerId, 'text-halo-width', orig['text-halo-width']) if (orig['text-halo-width'] !== undefined) map.setPaintProperty(layerId, 'text-halo-width', orig['text-halo-width'])
} }
function setHoverHighlight(map, feature) { function applyHighlightExpression(map, layerId) {
// Restore previous hovered layer if (!map.getLayer(layerId)) return
if (currentHoveredLayer && currentHoveredLayer !== currentHighlightedLayer) {
restoreOriginalPaint(map, currentHoveredLayer)
}
currentHoveredLayer = null
if (!feature) return
const layerId = feature.layer?.id
if (!layerId || !map.getLayer(layerId)) return
if (layerId === currentHighlightedLayer) return // Don't hover over selected
storeOriginalPaint(map, layerId) storeOriginalPaint(map, layerId)
currentHoveredLayer = layerId
const orig = originalPaintValues[layerId]
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
map.setPaintProperty(layerId, 'text-color', isDark ? '#ffffff' : '#000000') const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#7a9a6b'
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) 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 isHovered = highlightState.hoveredLayer === layerId && highlightState.hoveredName
const isSelected = highlightState.selectedLayer === layerId && highlightState.selectedName
// Build case expressions for each paint property
// Priority: selected > hover > original
if (isSelected && isHovered && highlightState.selectedName !== highlightState.hoveredName) {
// Both selected and hover active on different features
map.setPaintProperty(layerId, 'text-color', [
'case',
['==', ['get', 'name'], highlightState.selectedName], accentColor,
['==', ['get', 'name'], highlightState.hoveredName], hoverColor,
orig['text-color'] || (isDark ? '#c0c0c0' : '#333333')
])
map.setPaintProperty(layerId, 'text-halo-color', [
'case',
['==', ['get', 'name'], highlightState.selectedName], selectedHaloColor,
['==', ['get', 'name'], highlightState.hoveredName], hoverHaloColor,
orig['text-halo-color'] || (isDark ? '#1a1a1a' : '#ffffff')
])
map.setPaintProperty(layerId, 'text-halo-width', [
'case',
['==', ['get', 'name'], highlightState.selectedName], 3,
['==', ['get', 'name'], highlightState.hoveredName], 2.5,
orig['text-halo-width'] || 1.5
])
} else if (isSelected) {
// Only selected
map.setPaintProperty(layerId, 'text-color', [
'case',
['==', ['get', 'name'], highlightState.selectedName], accentColor,
orig['text-color'] || (isDark ? '#c0c0c0' : '#333333')
])
map.setPaintProperty(layerId, 'text-halo-color', [
'case',
['==', ['get', 'name'], highlightState.selectedName], selectedHaloColor,
orig['text-halo-color'] || (isDark ? '#1a1a1a' : '#ffffff')
])
map.setPaintProperty(layerId, 'text-halo-width', [
'case',
['==', ['get', 'name'], highlightState.selectedName], 3,
orig['text-halo-width'] || 1.5
])
} else if (isHovered) {
// Only hovered
map.setPaintProperty(layerId, 'text-color', [
'case',
['==', ['get', 'name'], highlightState.hoveredName], hoverColor,
orig['text-color'] || (isDark ? '#c0c0c0' : '#333333')
])
map.setPaintProperty(layerId, 'text-halo-color', [
'case',
['==', ['get', 'name'], highlightState.hoveredName], hoverHaloColor,
orig['text-halo-color'] || (isDark ? '#1a1a1a' : '#ffffff')
])
map.setPaintProperty(layerId, 'text-halo-width', [
'case',
['==', ['get', 'name'], highlightState.hoveredName], 2.5,
orig['text-halo-width'] || 1.5
])
} else {
// No highlight on this layer - restore original
restoreOriginalPaint(map, layerId)
}
}
function setHoverHighlight(map, feature) {
const prevLayer = highlightState.hoveredLayer
if (!feature) {
highlightState.hoveredLayer = null
highlightState.hoveredName = null
if (prevLayer) applyHighlightExpression(map, prevLayer)
return
}
const layerId = feature.layer?.id
const name = feature.properties?.name
if (!layerId || !name || !map.getLayer(layerId)) return
// Don't hover the selected feature
if (layerId === highlightState.selectedLayer && name === highlightState.selectedName) return
highlightState.hoveredLayer = layerId
highlightState.hoveredName = name
// Update previous layer if different
if (prevLayer && prevLayer !== layerId) {
applyHighlightExpression(map, prevLayer)
}
// Update current layer
applyHighlightExpression(map, layerId)
} }
function setSelectedHighlight(map, feature) { function setSelectedHighlight(map, feature) {
// Restore previous highlighted layer const prevLayer = highlightState.selectedLayer
if (currentHighlightedLayer) {
restoreOriginalPaint(map, currentHighlightedLayer) if (!feature) {
highlightState.selectedLayer = null
highlightState.selectedName = null
highlightState.hoveredLayer = null
highlightState.hoveredName = null
if (prevLayer) applyHighlightExpression(map, prevLayer)
return
} }
currentHighlightedLayer = null
currentHoveredLayer = null // Also clear hover
if (!feature) return
const layerId = feature.layer?.id const layerId = feature.layer?.id
if (!layerId || !map.getLayer(layerId)) return const name = feature.properties?.name
if (!layerId || !name || !map.getLayer(layerId)) return
storeOriginalPaint(map, layerId) highlightState.selectedLayer = layerId
currentHighlightedLayer = layerId highlightState.selectedName = name
// Clear hover when selecting
highlightState.hoveredLayer = null
highlightState.hoveredName = null
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#7a9a6b' // Update previous layer if different
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' if (prevLayer && prevLayer !== layerId) {
map.setPaintProperty(layerId, 'text-color', accentColor) applyHighlightExpression(map, prevLayer)
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) // Update current layer
applyHighlightExpression(map, layerId)
} }
function clearAllHighlights(map) { function clearAllHighlights(map) {
if (currentHoveredLayer) restoreOriginalPaint(map, currentHoveredLayer) const layers = [highlightState.hoveredLayer, highlightState.selectedLayer].filter(Boolean)
if (currentHighlightedLayer) restoreOriginalPaint(map, currentHighlightedLayer) highlightState.hoveredLayer = null
currentHoveredLayer = null highlightState.hoveredName = null
currentHighlightedLayer = null highlightState.selectedLayer = null
highlightState.selectedName = null
layers.forEach(layerId => restoreOriginalPaint(map, layerId))
} }
/** Build a full MapLibre style object for the given theme */ /** Build a full MapLibre style object for the given theme */