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 dffa927b4..a2a0575e5 100644 Binary files a/bun.lock and b/bun.lock differ 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