From c7260faaa381673d8066f1176bead1b8a6966beb Mon Sep 17 00:00:00 2001 From: doxa-maps Date: Thu, 10 Sep 2026 20:03:24 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20map=20provider=20swap=20=E2=80=94=20map?= =?UTF-8?q?box|maplibre=20engine=20+=20hybrid=20geocoder=20+=20provider=20?= =?UTF-8?q?badge;=20staging=20title=20doxa-maps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app-profiles/doxa-research-map/index.html | 18 +- .../doxa-research-map/research-map.vue | 14 ++ bun.lock | Bin 48105 -> 55161 bytes internal/generate-tree.mjs | 12 +- internal/staging-forge.css | 2 +- internal/staging.js | 2 +- .../map-controls/GeocoderComponent.vue | 167 +++++++++++++--- library/composables/useMapInstance.js | 187 ++++++++++++++---- library/composables/useMapProvider.js | 47 +++++ package.json | 2 + public/doxa-maps/cities-internet/index.html | 6 +- .../country-posters/country-posters.js | 108 +++++----- public/doxa-maps/country-posters/index.html | 6 +- public/doxa-maps/demo-map/demo-map.js | 48 ++--- public/doxa-maps/demo-map/index.html | 6 +- .../doxa-research-map/doxa-research-map.js | 113 ++++++----- public/doxa-maps/doxa-research-map/index.html | 6 +- .../doxa-simple-map/doxa-simple-map.js | 70 +++---- public/doxa-maps/doxa-simple-map/index.html | 6 +- public/doxa-maps/gospel-resources/index.html | 6 +- public/doxa-maps/index.html | 6 +- public/doxa-maps/key-cities/index.html | 6 +- public/doxa-maps/my-upg-100-list/index.html | 6 +- .../my-upg-100-list/my-upg-100-list.js | 46 ++--- public/doxa-maps/template-bundle/index.html | 6 +- 25 files changed, 589 insertions(+), 307 deletions(-) create mode 100644 library/composables/useMapProvider.js diff --git a/app-profiles/doxa-research-map/index.html b/app-profiles/doxa-research-map/index.html index 678da270b..13812a7c1 100644 --- a/app-profiles/doxa-research-map/index.html +++ b/app-profiles/doxa-research-map/index.html @@ -191,15 +191,23 @@ // with the value at request time; the .env file is gitignored. window.DOXA_TOKEN = '%VITE_MAPBOX_TOKEN%'; + // Map engine override for slots WITHOUT an explicit config.provider: + // ?provider=maplibre flips the default engine for those slots. A slot that + // sets config.provider always wins (per-map). Default stays 'mapbox'. + window.MAP_PROVIDER = new URLSearchParams(location.search).get('provider') || undefined; + window.DOXA_SLOTS = [ // ── research-map: full 4-tab build (Prayer / Engagement / Adoption / Language Families) ── - { profile: 'research-map', label: 'Research — pray-tools', gem: 'amethyst', - config: { dataSource: 'pray-tools', instanceId: 'dev-research-1' } }, + // Slot 1 renders with the default engine (Mapbox, unless ?provider= flips it). + { profile: 'research-map', label: 'Research — Mapbox engine', gem: 'amethyst', + config: { dataSource: 'pray-tools', instanceId: 'dev-research-1', provider: 'mapbox' } }, - { profile: 'research-map', label: 'Research — second instance', gem: 'sapphire', - config: { dataSource: 'pray-tools', instanceId: 'dev-research-2' } }, + // Slot 2 is pinned to MapLibre so both engines render side-by-side on one page. + { profile: 'research-map', label: 'Research — MapLibre engine', gem: 'emerald', + config: { dataSource: 'pray-tools', instanceId: 'dev-research-2', provider: 'maplibre' } }, - { profile: 'research-map', label: 'Research — third instance', gem: 'emerald', + // Slot 3 follows the page default (?provider= or Mapbox). + { profile: 'research-map', label: 'Research — default engine', gem: 'sapphire', config: { dataSource: 'pray-tools', instanceId: 'dev-research-3' } } ]; diff --git a/app-profiles/doxa-research-map/research-map.vue b/app-profiles/doxa-research-map/research-map.vue index 8312ee4fb..2878e03b2 100644 --- a/app-profiles/doxa-research-map/research-map.vue +++ b/app-profiles/doxa-research-map/research-map.vue @@ -32,6 +32,7 @@ import { inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, mark // ─── Composables (framework) ───────────────────────────────────────────────── import { useMapInstance } from '@map/composables/useMapInstance.js' +import { resolveMapProvider, providerLabel } from '@map/composables/useMapProvider.js' import { useMapLayers, PIN_STROKE_WIDTH_LIGHT, buildDarkRingWidth } from '@map/composables/useMapLayers.js' import { useMapTheme, THEME_TOGGLE, FIXED_BASEMAP } from '@map/composables/useMapTheme.js' import { useMapEvents } from '@map/composables/useMapEvents.js' @@ -322,6 +323,11 @@ useShadowStyles(` failure reads as "slow" and the user just waits; this says the map is gone. */ .rm-error { position:absolute;top:50%;left:12px;right:12px;z-index:7;transform:translateY(-50%);text-align:center;font:13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#fecaca;background:rgba(15,18,22,.95);border:1px solid rgba(254,202,202,.28);border-radius:10px;padding:12px 14px;box-shadow:0 2px 10px rgba(0,0,0,.4); } .rm-badge { position:absolute;top:10px;right:10px;background:rgba(124,140,248,0.12);color:#7c8cf8;border:1px solid rgba(124,140,248,0.3);padding:3px 9px;border-radius:4px;font-size:11px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;pointer-events:none;z-index:50; } + /* Engine badge — bottom-left, unobtrusive. Colour differs per engine so a + side-by-side comparison is readable at a glance. */ + .rm-provider-badge { position:absolute;bottom:8px;left:8px;z-index:50;pointer-events:none;padding:2px 8px;border-radius:999px;font:600 10px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;letter-spacing:.04em;color:#fff;background:rgba(30,41,59,0.72);border:1px solid rgba(255,255,255,0.18);backdrop-filter:blur(2px); } + .rm-provider-badge[data-provider="mapbox"] { background:rgba(37,99,235,0.78);border-color:rgba(147,197,253,0.5); } + .rm-provider-badge[data-provider="maplibre"] { background:rgba(5,150,105,0.80);border-color:rgba(110,231,183,0.5); } /* Mobile-only legend slot — desktop hides via @media */ .rm-legend-mobile-slot { display:none; } @media(max-width:767px){ @@ -336,6 +342,9 @@ useShadowStyles(` // ─── Config from ProfileLoader (provide/inject — never direct imports) ────── const profileConfig = inject('profileConfig') const mapboxToken = inject('mapboxToken') +// Map engine: profile-config.provider → window.MAP_PROVIDER → 'mapbox' (default). +const mapProvider = resolveMapProvider(profileConfig) +const mapProviderName = providerLabel(mapProvider) const dataSource = inject('dataSource') const colorSet = inject('colorSet') const injectedInstanceId = inject('instanceId', null) @@ -516,6 +525,7 @@ const RESEARCH_MAX_ZOOM = 22 const { map, isMapReady, initializeMap, destroy } = useMapInstance({ containerRef: mapContainer, accessToken: mapboxToken.value, + provider: mapProvider, // 'mapbox' (default) | 'maplibre' style: profileConfig?.value?.style ?? mapTheme.bootStyle(), // persisted-theme boot (shared) center: profileConfig?.value?.center ?? mapDefaults.center ?? [20, 10], zoom: profileConfig?.value?.zoom ?? mapDefaults.zoom ?? 1.8, @@ -2016,6 +2026,9 @@ onBeforeUnmount(() => {
+ +
{{ mapProviderName }}
+ @@ -2026,6 +2039,7 @@ onBeforeUnmount(() => { v-if="appReady && map" :map-instance="map" :access-token="mapboxToken" + :provider="mapProvider" :data-source-id="dataSource" :is-dark="isDark" :placeholder="geocoderPlaceholder" diff --git a/bun.lock b/bun.lock index dffa927b4a55d3eb1aea451f87b2c8a2a2d934ac..a2a0575e584add8f1e322b1ad2530ac3cd411f5f 100644 GIT binary patch delta 6165 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 delta 111 zcmV-#0FeLrt^?`!0+5K2yc@CjP%V=rrya8&IfxgtM^Q2$vjuYJ2D5m1w*#|Feys+x zvV~6qlZ}Xqv$ct80h9HO$Foe2$^o;mkp%{`k(jgwlhdEfv*V#)0<$WmMiR5uxMKvf R%e}J@vk1?20<$pFNd%TdH5&i` diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index a7ad5b1cc..7fa2dd9fb 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -91,7 +91,7 @@ function assetsBaseFor(depth) { // title is the PRODUCT title (config displayTitle, default org-free), never the deployment TREE_NAME // (TREE_NAME stays in URLs/paths where it is a folder name, not branding). const DISPLAY_TITLE = (typeof DEST_CONFIG.displayTitle === 'string' && DEST_CONFIG.displayTitle.trim()) - ? DEST_CONFIG.displayTitle.trim() : 'Embeddable JAMstack Maps' + ? DEST_CONFIG.displayTitle.trim() : 'doxa-maps' // Base URL every intra-tree ref (manifest iframeUrl/scriptUrl) is built against. // With embedBaseUrl set (e.g. "https://doxa.life/js"), refs are ABSOLUTE — the // build-time override for known hosts / partner embeds. WITHOUT it, refs are @@ -233,7 +233,7 @@ header.forge .crumb a { color: var(--cy); } const GEMS = ['#46d4ff', '#b9a6f5', '#5ee0a0', '#ffc24b', '#f472b6', '#f59e0b', '#34d399'] const hexRgb = (h) => { const n = parseInt(h.slice(1), 16); return (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) } -// ── The "Embeddable JAMstack Maps" staging shell ──────────────────────────────────────────── +// ── The "doxa-maps" staging shell ──────────────────────────────────────────── // ONE staging design, emitted at every tree level (root + each bundle). It is a // thin HTML shell that loads the shared, org-agnostic renderer (staging-forge.js, // inlined at build time below) with a `window.FORGE` config telling it which tree @@ -253,7 +253,7 @@ function readForgeAsset(rel) { } /** - * The shared Embeddable JAMstack Maps staging page. Self-contained: CSS + renderer are + * The shared doxa-maps staging page. Self-contained: CSS + renderer are * INLINED (no external fetch that could 404 on a static host), except the * manifest which the renderer fetches relative to the page. * @@ -312,7 +312,7 @@ function forgeHtml(o) { // ── Page templates ─────────────────────────────────────────────────────────── -/** Root staging page — the Embeddable JAMstack Maps showing ALL bundles (scope='root'). */ +/** Root staging page — the doxa-maps showing ALL bundles (scope='root'). */ function rootHtml(bundles, manifest) { return forgeHtml({ inlineManifest: manifest, @@ -322,13 +322,13 @@ function rootHtml(bundles, manifest) { }) } -/** Bundle staging page — the Embeddable JAMstack Maps showing THIS bundle's profiles (scope='bundle'). */ +/** Bundle staging page — the doxa-maps showing THIS bundle's profiles (scope='bundle'). */ function bundleHtml(b, index, manifest) { return forgeHtml({ inlineManifest: manifest, scope: 'bundle', bundle: b.bundle, - title: `${DISPLAY_TITLE} · ${b.bundle}`, + title: `${DISPLAY_TITLE}`, subtitle: `${b.bundle} — one bundle, its profiles as paste-anywhere maps`, crumbHref: '../index.html', crumbLabel: 'all bundles', diff --git a/internal/staging-forge.css b/internal/staging-forge.css index a2a1d1837..b86e6e1d1 100644 --- a/internal/staging-forge.css +++ b/internal/staging-forge.css @@ -1,7 +1,7 @@ /* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages). design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet - architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */ + architectural dark, zero folder-hopping. doxa-maps design language. */ :root { --bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047; --ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787; diff --git a/internal/staging.js b/internal/staging.js index 07a9b66ff..56340e939 100644 --- a/internal/staging.js +++ b/internal/staging.js @@ -1,4 +1,4 @@ -// staging.js — doxa-maps "Embeddable JAMstack Maps" staging showcase (dev only, no build step). +// staging.js — doxa-maps "doxa-maps" staging showcase (dev only, no build step). // // UX: the header showcases the single-build-process architecture (one // shared core → one build → JS bundles → element tags → maps). A horizontal-scroll strip lists diff --git a/library/components/map-controls/GeocoderComponent.vue b/library/components/map-controls/GeocoderComponent.vue index 09c3d330f..c0e10a372 100644 --- a/library/components/map-controls/GeocoderComponent.vue +++ b/library/components/map-controls/GeocoderComponent.vue @@ -67,17 +67,24 @@ import { useI18n } from 'vue-i18n' import { useDoxaSearch } from '../../composables/useDoxaSearch.js' import { useShadowStyles } from '../../composables/useShadowStyles.js' +// The Mapbox and MapLibre geocoders emit identical DOM under different class +// prefixes (.mapboxgl-ctrl-geocoder* vs .maplibregl-ctrl-geocoder*). dualEngine +// duplicates every rule for both so one skin covers whichever engine is active. +function dualEngine(css) { + return css + css.replace(/mapboxgl/g, 'maplibregl') +} + // iOS Safari auto-zooms the page whenever a text input with computed // font-size < 16px receives focus, and the "Done" keyboard button re-fires // the same zoom on blur. We override the Mapbox geocoder input to 16px in // the shadow root so the auto-zoom never triggers. 16px is the documented // threshold; setting it explicitly here also stops the page from getting // stuck zoomed-in on devices that disallow pinch-zoom recovery. -useShadowStyles( +useShadowStyles(dualEngine( '.mapboxgl-ctrl-geocoder--input,' + '.mapboxgl-ctrl-geocoder input[type="text"],' + '.mapboxgl-ctrl-geocoder input[type="search"]' - + '{font-size:16px !important;line-height:1.3;}', + + '{font-size:16px !important;line-height:1.3;}'), 'geocoder-ios-zoom-fix' ) @@ -85,8 +92,8 @@ useShadowStyles( // slot but is NOT vertically centered with the clear (×) button, so it reads as a // visual glitch while a query is in flight. The local search is fast enough that // the spinner adds no value (coder: "the loading indicator needs to not show up"). -useShadowStyles( - '.mapboxgl-ctrl-geocoder--icon-loading{display:none !important;}', +useShadowStyles(dualEngine( + '.mapboxgl-ctrl-geocoder--icon-loading{display:none !important;}'), 'geocoder-hide-loading' ) @@ -98,11 +105,11 @@ useShadowStyles( // search icon is re-centered for the shorter box (the clear/× button is already centered // at top:50% by each profile's base rule). Shared here so every profile inherits it — // this REPLACES the per-profile @media mobile-height hack (bug-searchbar-mobile-height). -useShadowStyles( +useShadowStyles(dualEngine( '.mapboxgl-ctrl-geocoder{height:35px!important;min-height:35px!important;box-sizing:border-box!important;}' + '.mapboxgl-ctrl-geocoder--input{height:35px!important;min-height:35px!important;box-sizing:border-box!important;' + 'padding-top:0!important;padding-bottom:0!important;line-height:35px!important;}' - + '.mapboxgl-ctrl-geocoder--icon-search{top:50%!important;transform:translateY(-50%)!important;margin-top:0!important;left:7px!important;}', + + '.mapboxgl-ctrl-geocoder--icon-search{top:50%!important;transform:translateY(-50%)!important;margin-top:0!important;left:7px!important;}'), 'geocoder-height-35' ) @@ -119,7 +126,7 @@ useShadowStyles( // layout); they must not re-declare colour, radius, shadow or focus ring. // // Palette is research-map's — the reference implementation. -useShadowStyles( +useShadowStyles(dualEngine( // FILL — the pill always fills whatever container it is placed in, and the // container is what a profile sizes. This is the shared search-bar CONTRACT, not // a per-map preference: every map wants the pill to fill its slot. Sizing the @@ -161,7 +168,7 @@ useShadowStyles( + 'color:rgba(243,243,241,0.65)!important;}' + '.geocoder-dark .mapboxgl-ctrl-geocoder--icon,.dsm-dark .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--icon{' + 'fill:rgba(243,243,241,0.75)!important;}' - + '.geocoder-dark .mapboxgl-ctrl-geocoder--button,.dsm-dark .mapboxgl-ctrl-geocoder--button{background:transparent!important;}', + + '.geocoder-dark .mapboxgl-ctrl-geocoder--button,.dsm-dark .mapboxgl-ctrl-geocoder--button{background:transparent!important;}'), 'geocoder-skin' ) @@ -174,6 +181,12 @@ const props = defineProps({ // pass a live instance, so their behavior is unchanged. mapInstance: { type: Object, required: false, default: null }, accessToken: { type: String, required: true }, + /** + * Map engine: 'mapbox' (default — MapboxGeocoder) | 'maplibre' + * (MaplibreGeocoder + a Mapbox REST forwardGeocode, so place search still + * works on a MapLibre map). The local people-group geocoder is shared. + */ + provider: { type: String, default: 'mapbox' }, isDark: { type: Boolean, default: false }, /** * When true, render the geocoder input into THIS component's own container @@ -351,8 +364,17 @@ const standaloneContainer = ref(null) // The geocoder stylesheet must live inside the shadow root; injecting it into // the document head has no effect because the widget renders inside a // ShadowRoot. We target the closest ShadowRoot of the map container. -function injectGeocoderCSS(shadowRoot) { - const HREF = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css' +const GEOCODER_CSS = { + mapbox: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css', + maplibre: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css', +} +// MaplibreGeocoder ships as a CDN global (UMD). Mapbox's is preloaded via +// index.html; MapLibre's is injected on demand so a profile can flip provider +// with no host change. +const MAPLIBRE_GEOCODER_JS = 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js' + +function injectGeocoderCSS(shadowRoot, prov) { + const HREF = GEOCODER_CSS[prov] || GEOCODER_CSS.mapbox if (shadowRoot.querySelector(`link[href="${HREF}"]`)) return // already injected const link = document.createElement('link') link.rel = 'stylesheet' @@ -360,9 +382,72 @@ function injectGeocoderCSS(shadowRoot) { shadowRoot.prepend(link) } +/** Load the MaplibreGeocoder UMD global on demand (idempotent). */ +async function ensureMaplibreGeocoder() { + if (typeof window !== 'undefined' && window.MaplibreGeocoder) return + if (typeof document === 'undefined') return + const existing = document.querySelector(`script[src="${MAPLIBRE_GEOCODER_JS}"]`) + if (existing) { + if (window.MaplibreGeocoder) return + await new Promise(resolve => { + existing.addEventListener('load', resolve, { once: true }) + existing.addEventListener('error', resolve, { once: true }) + }) + return + } + await new Promise((resolve, reject) => { + const s = document.createElement('script') + s.src = MAPLIBRE_GEOCODER_JS + s.async = false + s.addEventListener('load', resolve, { once: true }) + s.addEventListener('error', () => reject(new Error('[GeocoderComponent] Failed to load MapLibre geocoder')), { once: true }) + document.head.appendChild(s) + }) +} + +/** + * Builds a MaplibreGeocoder `geocoderApi.forwardGeocode` backed by the Mapbox + * Geocoding REST API — so place search still works on a MapLibre map (hybrid: + * MapLibre basemap + Mapbox geocoding). Returns Carmen GeoJSON, which the + * geocoder consumes directly. Empty result (no token / network error) leaves the + * local people-group search fully functional. + */ +function makeMapboxForwardGeocode(token, getLang) { + return async (config) => { + const empty = { features: [] } + if (!token || !config?.query) return empty + try { + const lang = Array.isArray(config.language) ? config.language[0] : (config.language || getLang() || 'en') + const params = new URLSearchParams({ + access_token: token, + autocomplete: 'true', + limit: String(config.limit || 5), + }) + if (lang) params.set('language', lang) + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(config.query)}.json?${params.toString()}` + const res = await fetch(url) + if (!res.ok) return empty + const geojson = await res.json() + return (geojson && Array.isArray(geojson.features)) ? geojson : empty + } catch (e) { + return empty + } + } +} + // ── Geocoder lifecycle ──────────────────────────────────────────────────────── -onMounted(() => { - if (typeof MapboxGeocoder === 'undefined') { +onMounted(async () => { + const isMaplibre = props.provider === 'maplibre' + + // Ensure the right geocoder global is present. Mapbox's is preloaded via + // index.html; MapLibre's is injected on demand. + if (isMaplibre) { + await ensureMaplibreGeocoder() + if (typeof MaplibreGeocoder === 'undefined') { + console.error('[GeocoderComponent] MaplibreGeocoder global unavailable.') + return + } + } else if (typeof MapboxGeocoder === 'undefined') { return } @@ -378,7 +463,7 @@ onMounted(() => { ? standaloneContainer.value?.getRootNode?.() : map.getContainer?.()?.getRootNode?.() if (shadowRoot instanceof ShadowRoot) { - injectGeocoderCSS(shadowRoot) + injectGeocoderCSS(shadowRoot, props.provider) } // Create geocoder instance. @@ -393,11 +478,11 @@ onMounted(() => { // category tag the `render` callback (renderSuggestion) prepends. // // `localGeocoderOnly` is intentionally LEFT OUT — we want BOTH sources - // (Mapbox places AND DOXA records) visible in the suggestions list. - // eslint-disable-next-line no-undef - geocoder.value = new MapboxGeocoder({ - accessToken: props.accessToken, - mapboxgl: mapboxgl, // CDN global — same reference used by the map instance + // (place results AND DOXA records) visible in the suggestions list. + // + // Shared options — the two geocoders share almost the entire option surface; + // only the constructor shape + the geocoding backend differ (see below). + const sharedOpts = { marker: false, // Standalone (header) bars are search-only: results drive the selection bus, // not a map flyTo — so disable flyTo (no map). Map-control mode keeps the @@ -409,24 +494,42 @@ onMounted(() => { // mode keeps the default (byte-identical: !standalone === true). trackProximity: !props.standalone, placeholder: effectivePlaceholder.value, - // Localize Mapbox's remote place results AND the geocoder's own UI strings. - // Mapbox accepts ISO-639-1 codes (comma-separated); the active i18n locale - // is set once at mount from profile-config.lang (default 'en'). See loc-002. + // Localize remote place results AND the geocoder's own UI strings. Mapbox + // accepts ISO-639-1 codes; the active i18n locale is set once at mount from + // profile-config.lang (default 'en'). See loc-002. language: locale.value || 'en', localGeocoder: doxaLocalGeocoder, - // Raise suggestion limit well above Mapbox's default (5) so a query like - // "India" can surface many people-groups. CSS caps the dropdown height - // and adds overflow-y:auto so the user can scroll through them. + // Raise suggestion limit well above the default (5) so a query like "India" + // can surface many people-groups. CSS caps the dropdown height + overflow. limit: 20, - // Ensure our local results show even when Mapbox has remote matches. - // (Some versions default to localGeocoder results appearing AFTER the - // remote ones; keeping default order is fine — the category tag makes - // our rows unambiguous.) zoom: 6, - // Custom row renderer — injects structured, bold-labeled metadata - // for DOXA results. See renderSuggestion() above. - render: renderSuggestion - }) + // Custom row renderer — injects structured, bold-labeled metadata for DOXA + // results. See renderSuggestion() above. + render: renderSuggestion, + } + + if (isMaplibre) { + // MapLibre map + Mapbox geocoding (hybrid). forwardGeocode hits the Mapbox + // REST API with the same public token; the local people-group search is + // unchanged. Requires the geocoderApi as the FIRST constructor arg. + // eslint-disable-next-line no-undef + const gl = typeof maplibregl !== 'undefined' ? maplibregl : undefined + const geocoderApi = { forwardGeocode: makeMapboxForwardGeocode(props.accessToken, () => locale.value) } + // eslint-disable-next-line no-undef + geocoder.value = new MaplibreGeocoder(geocoderApi, { + ...sharedOpts, + maplibregl: gl, + showResultsWhileTyping: true, + reverseGeocode: false, + }) + } else { + // eslint-disable-next-line no-undef + geocoder.value = new MapboxGeocoder({ + accessToken: props.accessToken, + mapboxgl: mapboxgl, // CDN global — same reference used by the map instance + ...sharedOpts, + }) + } // Forward geocoder events as Vue emits. When a DOXA people-group result // is chosen we additionally emit `people-group-result` carrying the diff --git a/library/composables/useMapInstance.js b/library/composables/useMapInstance.js index 19a4a963d..90f89de1e 100644 --- a/library/composables/useMapInstance.js +++ b/library/composables/useMapInstance.js @@ -28,24 +28,87 @@ import { ref } from 'vue' import { useEdgeScrollAffordance } from './useEdgeScrollAffordance.js' -const MAPBOX_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css' +// ─── Engine registry (provider → CDN script + css + global) ────────────────── +// The map engine is loaded as a page global (CDN