doxa-maps/plugin-designs/pplr-data-maps/src/components/MapboxGeocoder.vue
doxa-maps-88655c70 17931ed031 PPLR data maps: every view loads under MapLibre, and the legend has accessible names
Under provider=maplibre (the keyless gallery) four of the seven map views never
finished loading. They built `new mapboxgl.NavigationControl` in the load
handler, and that global does not exist under MapLibre, so they sat on
"Loading…". Popups and the pin marker had the same fault. Every such call now
goes through glFor(map), the namespace of the engine that built the map. The
views' styles and the Dark/Satellite/Light switch now use registry names (ink,
hybrid, paper), which are the same Mapbox styles under Mapbox and OpenFreeMap
under MapLibre.

The search box loads its geocoder by engine, as GeocoderComponent does. Under
MapLibre it is MaplibreGeocoder over this map's own local index, so the keyless
gallery makes no request to api.mapbox.com at all.

The legend's row caret, clear chip and collapse/reopen buttons carry accessible
names and aria-expanded from legend.a11y.* and aria.* (optional i18n, English
fallback), the keys the library legend uses. check:shared-reached now counts
pplr-data-maps as carrying legend-a11y-keys.

window.__pplrMap() reports what the active view's map did (engine, load fired,
style layers, this bundle's data sources), so "loaded" is asserted, not inferred.

Measured on a served built tree, host page on another origin: all ten views
loaded under MapLibre and under Mapbox (load fired, style layers and data
sources present, canvas not blank, 0 errors), with 0 api.mapbox.com requests
under MapLibre. The same check on the parent build fails four map views under
MapLibre. A control with no provider makes 13 Mapbox requests.
2026-09-25 09:57:17 +00:00

327 lines
13 KiB
Vue

<script setup>
/**
* MapboxGeocoder.vue — wraps mapbox-gl-geocoder npm plugin (loaded via CDN).
*
* DOXA's pattern, ported per user request 2026-05-02 ("your competitor is
* beating you in that area. Their geocoder's directly mapped to language-
* family legend"). Replaces the bespoke PplrGeocoder.vue dropdown with
* Mapbox's vetted UI/UX (keyboard nav, debouncing, ARIA, mobile-friendly)
* while keeping local-data search via the localGeocoder callback.
*
* Search results funnel into either:
* - emit('select', node) — when the user picks a local item from props.localIndex
* - emit('place', place) — when the user picks a Mapbox geographic place
*
* Both events forward to the parent profile, which already wires `select`
* into instance.selection (legend highlight + map filter via setFilter) and
* `place` into a flyTo / fitBounds.
*
* The `/` keyboard shortcut from the original PplrGeocoder is preserved.
*/
import { ref, watch, onMounted, onBeforeUnmount, computed, inject } from 'vue'
import { ensureGeocoder, GEOCODER } from '../utils/pplrData.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({
/** Pre-flattened searchable items: [{id, label, kind, depth, filter, color?, count?, pop?, ...}] */
localIndex: { type: Array, default: () => [] },
/** Placeholder text — parameterized per map */
placeholder: { type: String, default: 'Search…' },
/** Mapbox access token (required for the geographic Places search) */
mapboxToken: { type: String, default: '' },
/** Min query length before search fires */
minChars: { type: Number, default: 2 },
/** Kind labels for hint text — { family: 'Lang Family', bloc: 'Affinity Bloc', ... } */
kindLabels: { type: Object, default: () => ({}) },
/** Max local results in the dropdown */
localMax: { type: Number, default: 8 },
})
const emit = defineEmits(['select', 'place'])
const instance = useInstance()
const wrapEl = ref(null)
const slotEl = ref(null)
let geocoder = null
// Same engine the map uses. Under maplibre (the keyless gallery) search runs on MaplibreGeocoder
// over this map's own local index only, so it makes no Mapbox request at all.
const provider = resolveMapProvider(inject('profileConfig', null))
const isMaplibre = provider === 'maplibre'
// Build the localGeocoder callback. Mapbox-gl-geocoder calls this on every
// keystroke; we return Carmen-GeoJSON-shaped features for matching local
// items. The `__pplr_local` property carries the original node so onResult
// can route it back to emit('select').
function buildLocalGeocoder() {
return (q) => {
if (!q || q.length < props.minChars) return []
const ql = q.toLowerCase()
const out = []
for (const item of props.localIndex) {
const label = (item.label || '').toLowerCase()
if (!label.includes(ql)) continue
out.push({
type: 'Feature',
// Carmen-GeoJSON shape required by mapbox-gl-geocoder
place_name: item.label,
place_type: ['poi'],
text: item.label,
properties: {
// Carry a hint of what KIND of result this is (shows in dropdown)
__pplr_kind: props.kindLabels[item.kind] || item.kind || '',
__pplr_local: item,
},
// Geometry is required by Carmen-GeoJSON schema. Use a sentinel
// [0,0] centroid; the parent ignores geometry for local results.
geometry: { type: 'Point', coordinates: [0, 0] },
center: [0, 0],
})
if (out.length >= props.localMax) break
}
// Sort priority: BROADER scope first (lower depth = bigger bucket), then
// prefix matches, then population/count desc. Per user 2026-05-02:
// searching "Arabic" was selecting the narrow "Arabic" language node
// (depth 1) instead of the family "Arab (Arabic)" (depth 0) — only some
// Arabic pins showed. Depth-first means picking the top result now
// filters the broadest reasonable bucket (whole family).
out.sort((a, b) => {
const ai = a.properties.__pplr_local
const bi = b.properties.__pplr_local
const ad = ai?.depth ?? 99
const bd = bi?.depth ?? 99
if (ad !== bd) return ad - bd
const ap = a.place_name.toLowerCase().startsWith(ql) ? 0 : 1
const bp = b.place_name.toLowerCase().startsWith(ql) ? 0 : 1
if (ap !== bp) return ap - bp
return ((bi?.pop || 0) - (ai?.pop || 0)) || ((bi?.count || 0) - (ai?.count || 0))
})
return out
}
}
// Custom suggestion renderer — shows the local kind chip beside each row
function renderSuggestion(item) {
const kind = item?.properties?.__pplr_kind || ''
const local = item?.properties?.__pplr_local
const dotColor = local?.color || ''
const dot = dotColor
? `<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${dotColor};margin-right:8px;vertical-align:middle"></span>`
: ''
const tag = kind
? `<span style="font:600 9px ui-monospace,monospace;color:#73A17F;text-transform:uppercase;letter-spacing:0.06em;margin-left:auto">${kind}</span>`
: ''
return `<div class="mapboxgl-ctrl-geocoder--suggestion-title" style="display:flex;align-items:center;gap:4px;width:100%">
${dot}<span style="flex:1">${item.place_name}</span>${tag}
</div>`
}
// mapbox-gl-geocoder CSS lives in the regular document's <head> via index.html.
// Shadow DOM is sealed from regular-document styles, so we must inject the same
// stylesheet into each shadow root that hosts a geocoder. Idempotent — checks
// if already present.
const GEOCODER_CSS_URL = (GEOCODER[provider] || GEOCODER.mapbox).css
function ensureGeocoderCss(root) {
if (!root || root === document) return
if (root.querySelector('link[data-pplr-geocoder-css]')) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = GEOCODER_CSS_URL
link.setAttribute('data-pplr-geocoder-css', '1')
root.prepend(link)
}
function setup() {
// Wrap entire setup in try/catch — a geocoder failure must NEVER bubble
// out of onMounted, since Vue's "Unhandled error during execution of
// mounted hook" cascades and can prevent sibling components (the map!)
// from mounting. Per user 2026-05-02: map went completely blank when
// the geocoder threw on shadow-DOM addTo().
try {
if (isMaplibre) {
if (!window.MaplibreGeocoder || !window.maplibregl) {
console.warn('[MapboxGeocoder] maplibre-gl-geocoder or maplibre-gl not on window — search disabled')
return
}
// Local-only: the PG/family index this map already holds. No place search, no key.
geocoder = new window.MaplibreGeocoder({ forwardGeocode: async () => ({ type: 'FeatureCollection', features: [] }) }, {
maplibregl: window.maplibregl,
placeholder: props.placeholder,
localGeocoder: buildLocalGeocoder(),
localGeocoderOnly: true,
minLength: props.minChars,
showResultsWhileTyping: true,
render: renderSuggestion,
})
} else {
if (!window.MapboxGeocoder || !window.mapboxgl) {
console.warn('[MapboxGeocoder] mapbox-gl-geocoder or mapbox-gl not on window — check CDN <script> tags')
return
}
if (!props.mapboxToken) {
console.warn('[MapboxGeocoder] mapboxToken prop is empty — geographic Places search disabled')
}
geocoder = new window.MapboxGeocoder({
accessToken: props.mapboxToken,
mapboxgl: window.mapboxgl,
placeholder: props.placeholder,
localGeocoder: buildLocalGeocoder(),
localGeocoderOnly: false,
minLength: props.minChars,
types: 'country,region,district,place',
limit: 5,
render: renderSuggestion,
})
}
geocoder.on('result', (ev) => {
const f = ev.result
const local = f?.properties?.__pplr_local
if (local) {
if (instance) {
instance.selection.value = local
if (typeof local.depth === 'number') instance.activeTab.value = local.depth
}
emit('select', local)
} else {
emit('place', {
id: f.id,
label: f.place_name,
place: f.text,
kind: 'place',
bbox: f.bbox,
center: f.center,
})
}
})
geocoder.on('clear', () => {
if (instance) instance.selection.value = null
emit('select', null)
})
// mapbox-gl-geocoder v5's addTo() does `document.body.contains(el)`
// which returns false for shadow-DOM elements (our case — each profile
// mounts inside <doxa-map>'s shadow root). Workaround: stage in body,
// then move the rendered element into our shadow-DOM slot. References
// inside the geocoder are by element reference so the move is safe.
if (!slotEl.value) return
// Inject geocoder CSS into the shadow root so the rendered geocoder is
// actually styled (regular-document <head> styles don't cross the shadow).
ensureGeocoderCss(slotEl.value.getRootNode())
const stage = document.createElement('div')
stage.style.cssText = 'position:absolute;left:-9999px;top:-9999px;visibility:hidden;pointer-events:none;'
document.body.appendChild(stage)
try {
geocoder.addTo(stage)
const rendered = stage.firstElementChild
if (rendered) slotEl.value.appendChild(rendered)
} finally {
if (stage.parentNode) stage.parentNode.removeChild(stage)
}
} catch (e) {
console.warn('[MapboxGeocoder] setup failed — geocoder disabled:', e)
geocoder = null
}
}
function teardown() {
try { geocoder?.onRemove() } catch {}
geocoder = null
if (slotEl.value) slotEl.value.innerHTML = ''
}
// Rebuild localGeocoder callback when localIndex changes (the closure captures
// props.localIndex by reference via getter; Mapbox calls our function fresh
// each query so this is automatic — no rebuild needed).
// Global "/" shortcut to focus geocoder (preserved from PplrGeocoder)
function onGlobalKey(e) {
const tgt = document.activeElement
const inField = tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)
const isSlash = e.key === '/' && !inField
const isCmdK = (e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')
if (isSlash || isCmdK) {
e.preventDefault()
const input = slotEl.value?.querySelector('input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input')
input?.focus()
input?.select?.()
}
}
onMounted(() => {
// setup() swallows its own errors; a failed script load leaves the map working without search.
ensureGeocoder(provider).then(setup, (e) => console.warn('[MapboxGeocoder]', e.message))
document.addEventListener('keydown', onGlobalKey)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onGlobalKey)
teardown()
})
</script>
<template>
<div ref="wrapEl" class="mgc-wrap" :data-theme="instance?.theme.value || 'dark'">
<div ref="slotEl" class="mgc-slot" />
</div>
</template>
<style scoped>
.mgc-wrap { width: 100%; position: relative; }
/* mapbox-gl-geocoder injects its own root .mapboxgl-ctrl-geocoder; we just
need a slot element to host it. Style overrides target the deep selectors
from the plugin's stylesheet (loaded via index.html CDN). */
.mgc-slot :deep(.mapboxgl-ctrl-geocoder) {
width: 100%; max-width: 100%;
background: rgba(13,17,23,0.96);
border: 1px solid #30363d;
border-radius: 999px;
box-shadow: 0 4px 14px rgba(0,0,0,0.25);
font: 13px system-ui, sans-serif;
min-width: 0;
}
.mgc-slot :deep(.mapboxgl-ctrl-geocoder--input) {
color: #c9d1d9;
height: 36px;
padding: 6px 36px 6px 38px;
font: 13px system-ui, sans-serif;
}
.mgc-slot :deep(.mapboxgl-ctrl-geocoder--input::placeholder) { color: #6e7681; }
.mgc-slot :deep(.mapboxgl-ctrl-geocoder--icon-search) {
fill: #6e7681; top: 8px; left: 9px;
}
.mgc-slot :deep(.mapboxgl-ctrl-geocoder--button) {
background: transparent; top: 6px;
}
.mgc-slot :deep(.suggestions) {
background: rgba(13,17,23,0.97);
border: 1px solid #30363d; border-radius: 8px;
margin-top: 4px;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
}
.mgc-slot :deep(.suggestions > li > a) {
color: #c9d1d9; padding: 8px 12px;
}
.mgc-slot :deep(.suggestions > .active > a),
.mgc-slot :deep(.suggestions > li > a:hover) {
background: rgba(115,161,127,0.12);
color: #c9d1d9;
}
/* Light theme overrides */
.mgc-wrap[data-theme="light"] :deep(.mapboxgl-ctrl-geocoder) {
background: rgba(255,255,255,0.97); border-color: #d0d7de;
box-shadow: 0 4px 14px rgba(31,35,40,0.15);
}
.mgc-wrap[data-theme="light"] :deep(.mapboxgl-ctrl-geocoder--input) { color: #1f2328; }
.mgc-wrap[data-theme="light"] :deep(.mapboxgl-ctrl-geocoder--input::placeholder) { color: #57606a; }
.mgc-wrap[data-theme="light"] :deep(.suggestions) {
background: #ffffff; border-color: #d0d7de; color: #1f2328;
}
.mgc-wrap[data-theme="light"] :deep(.suggestions > li > a) { color: #1f2328; }
.mgc-wrap[data-theme="light"] :deep(.suggestions > .active > a),
.mgc-wrap[data-theme="light"] :deep(.suggestions > li > a:hover) {
background: #f6f8fa; color: #1f2328;
}
</style>