Some checks are pending
build-gate / build (push) Waiting to run
The switch existed and did not reach. resolveMapProvider has long read profile-config.provider -> ?provider= -> window.MAP_PROVIDER, but useMapInstance defaulted `provider` to the LITERAL 'mapbox', so the page switch only reached a map whose bundle happened to pass provider through. MEASURED: 3 of the 11 bundles that mount a map. The other 8 pinned Mapbox whatever the page asked — it LOOKED global and was not. The factory now resolves it when a caller leaves it unset, which moves the decision to the one place every map already goes through. An explicit caller still wins (prayer-walk-planner pins maplibre deliberately), and with no switch set resolveMapProvider returns 'mapbox', so the default is unchanged. DashboardMapPanel collapsed anything that was not the literal 'maplibre' to 'mapbox', which outranked the page switch. But dashboard.config.js pins Mapbox DELIBERATELY — the Driver's ruling for Mapbox Standard's native starfield globe — so that default had to survive. Both hold by putting the page switch BETWEEN the per-element config and the bundle default: 1. profile-config.provider this embed 2. ?provider= / MAP_PROVIDER the page — "swap every map on this site" 3. dashboard.config.js this bundle's shipped default (Mapbox) MEASURED across seven map leaves, default vs ?provider=maplibre, counting requests to api.mapbox.com: doxa-simple-map 21 -> 4 engine swaps country-posters 24 -> 4 engine swaps research-dashboard 25 -> 4 engine swaps (was pinned) doxa-research-map 16 -> 4 engine swaps research-map-plus-grid 16 -> 0 fully keyless my-upg-100-list 19 -> 19 NOT swapped — see backlog regions-dashboard 4 -> 4 no map on that leaf Simple Research reaches ZERO Mapbox requests, which is the case that matters most: it is the one carrying the search bar, and the geocoder is the quota risk (GeocoderComponent's maplibre path still geocodes through Mapbox, so a keyless map with a search bar is not keyless). Two gaps left, both filed rather than hidden: my-upg-100-list bypasses useMapInstance entirely and cannot see the switch, and generated leaves still preload mapbox-gl.js/.css + the geocoder from api.mapbox.com even under maplibre (library downloads, no token, but still a CDN dependency — the composite leaf proves 0 is reachable). The second belongs to the tree verb, which is not mine to change. 22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout consumers and all seven studio dashboards.
623 lines
33 KiB
JavaScript
623 lines
33 KiB
JavaScript
/**
|
||
* useMapInstance.js — Map Initialization Composable
|
||
*
|
||
* Manages a single Mapbox GL map instance lifecycle.
|
||
* Scoped by mapId — safe to use multiple times on the same page.
|
||
*
|
||
* SHADOW DOM NOTES:
|
||
*
|
||
* 1. Container ID: Mapbox's `new mapboxgl.Map({ container: 'id' })` uses
|
||
* document.getElementById() which CANNOT cross shadow boundaries.
|
||
* Always pass the actual DOM element via a Vue template ref.
|
||
*
|
||
* 2. CSS timing: The Mapbox GL CSS <link> loads asynchronously. If Mapbox
|
||
* initializes before the CSS is applied, _detectMissingCSS fires and the
|
||
* canvas is sized to 0. This composable injects the CSS into the shadow
|
||
* root and waits for it to load before creating the Map instance.
|
||
*
|
||
* 3. Canvas resize: Even with correct CSS, the canvas may init at the wrong
|
||
* size if the container layout settles after initialization. map.resize()
|
||
* is called on the load event to correct this.
|
||
*
|
||
* Usage:
|
||
* const containerRef = ref(null)
|
||
* const { map, isMapReady, initializeMap, destroy } = useMapInstance({ containerRef, accessToken, ... })
|
||
* onMounted(() => initializeMap())
|
||
*/
|
||
|
||
import { ref, shallowRef, inject } from 'vue'
|
||
import { useEdgeScrollAffordance } from './useEdgeScrollAffordance.js'
|
||
|
||
// ─── Engine registry (provider → CDN script + css + global) ──────────────────
|
||
// The map engine is loaded as a page global (CDN <script>). Profiles / index.html
|
||
// may preload it; if absent we inject it here. Mapbox is the default — its URLs
|
||
// match the existing <script> tags so nothing double-loads or changes.
|
||
const ENGINES = {
|
||
mapbox: {
|
||
global: 'mapboxgl',
|
||
js: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js',
|
||
css: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css',
|
||
},
|
||
maplibre: {
|
||
global: 'maplibregl',
|
||
js: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js',
|
||
css: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css',
|
||
},
|
||
}
|
||
|
||
// ONE MAPBOX WORDMARK PER PAGE: every map instance renders its own logo, so a
|
||
// page composing several maps stacks duplicates. First live instance keeps the
|
||
// logo (Mapbox attribution satisfied once per document); later ones hide theirs
|
||
// and release the claim on destroy so the badge never disappears entirely.
|
||
function claimLogo(mapInstance) {
|
||
if (typeof window === 'undefined') return
|
||
const w = window
|
||
const hide = () => {
|
||
try {
|
||
const el = mapInstance.getContainer().querySelector('.mapboxgl-ctrl-logo, .maplibregl-ctrl-logo')
|
||
if (el) el.style.display = 'none'
|
||
} catch (_) {}
|
||
}
|
||
if (w.__doxaLogoOwner && w.__doxaLogoOwner !== mapInstance) { mapInstance.once('load', hide); hide(); return }
|
||
w.__doxaLogoOwner = mapInstance
|
||
mapInstance.once('remove', () => { if (w.__doxaLogoOwner === mapInstance) w.__doxaLogoOwner = null })
|
||
}
|
||
|
||
const MAPBOX_CSS_URL = ENGINES.mapbox.css
|
||
// Free basemap for MapLibre (no token). Deliberately NOT the hand-tuned Mapbox
|
||
// style — see the provider notes; a MapLibre map looks different by design.
|
||
const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
|
||
|
||
// Named styles: the 12-style pack. A profile-config style of 'ink' /
|
||
// 'midnight-glow' / … resolves per engine here; raw URLs pass through.
|
||
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
|
||
import { resolveMapProvider } from './useMapProvider.js'
|
||
|
||
/** The live engine global for a provider, or undefined if not yet loaded. */
|
||
function engineGlobal(provider) {
|
||
const name = ENGINES[provider]?.global || ENGINES.mapbox.global
|
||
return typeof window !== 'undefined' ? window[name] : undefined
|
||
}
|
||
|
||
/**
|
||
* Ensures the engine's CDN <script> (+ head css) is loaded for the provider.
|
||
* Idempotent — resolves immediately if the global already exists (preloaded via
|
||
* index.html), otherwise injects the script and awaits its load event.
|
||
*/
|
||
async function ensureEngineScript(provider) {
|
||
const cfg = ENGINES[provider] || ENGINES.mapbox
|
||
if (engineGlobal(provider)) return // already loaded — no-op
|
||
if (typeof document === 'undefined') return
|
||
|
||
if (!document.querySelector(`link[href="${cfg.css}"]`)) {
|
||
const link = document.createElement('link')
|
||
link.rel = 'stylesheet'
|
||
link.href = cfg.css
|
||
document.head.appendChild(link)
|
||
}
|
||
|
||
const existing = document.querySelector(`script[src="${cfg.js}"]`)
|
||
if (existing) {
|
||
if (engineGlobal(provider)) 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 = cfg.js
|
||
s.async = false
|
||
s.addEventListener('load', resolve, { once: true })
|
||
s.addEventListener('error', () => reject(new Error(`[useMapInstance] Failed to load ${provider} engine script`)), { once: true })
|
||
document.head.appendChild(s)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Injects the engine's GL CSS into the shadow root that contains the given
|
||
* element, then waits for it to finish loading.
|
||
*
|
||
* - Skips if the element is in the regular document (no shadow root).
|
||
* - Skips if the CSS link is already present and loaded (idempotent).
|
||
* - Always resolves — never hangs (2 s timeout fallback).
|
||
*/
|
||
async function ensureEngineCSS(element, cssUrl) {
|
||
const root = element.getRootNode()
|
||
if (!(root instanceof ShadowRoot)) return // light DOM — wrapper <link> is enough
|
||
|
||
const timeout = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||
|
||
// Already present — check if loaded via `sheet` or wait for event
|
||
const existing = root.querySelector(`link[href="${cssUrl}"]`)
|
||
if (existing) {
|
||
// `sheet` is set synchronously once the stylesheet is parsed — reliable check
|
||
if (existing.sheet) return
|
||
// Attach listeners BEFORE checking again to avoid a race where load fires
|
||
// between our check and adding the listener
|
||
await Promise.race([
|
||
new Promise(resolve => {
|
||
existing.addEventListener('load', resolve, { once: true })
|
||
existing.addEventListener('error', resolve, { once: true })
|
||
}),
|
||
timeout(2000)
|
||
])
|
||
return
|
||
}
|
||
|
||
// Create link element and attach listeners BEFORE inserting into DOM,
|
||
// so we cannot miss the load event even if the browser resolves it synchronously
|
||
const link = document.createElement('link')
|
||
link.rel = 'stylesheet'
|
||
link.href = cssUrl
|
||
|
||
await Promise.race([
|
||
new Promise(resolve => {
|
||
link.addEventListener('load', resolve, { once: true })
|
||
link.addEventListener('error', resolve, { once: true })
|
||
root.prepend(link) // insert AFTER attaching listeners
|
||
}),
|
||
timeout(2000)
|
||
])
|
||
}
|
||
|
||
/**
|
||
* Effective style for the MapLibre engine. A mapbox:// style needs a token and
|
||
* the Mapbox engine, so it is ignored here — an explicit http(s) style URL is
|
||
* honored, otherwise the free OpenFreeMap basemap is used.
|
||
*/
|
||
function maplibreStyle(style) {
|
||
return (typeof style === 'string' && /^https?:\/\//.test(style)) ? style : OPENFREEMAP_STYLE
|
||
}
|
||
|
||
// ─── Aspect-aware zoom-out floor (Feedback #2+#4 REDO) ───────────────────────
|
||
// We KEEP renderWorldCopies:true (infinite horizontal scroll) and instead cap
|
||
// zoom-out with a DYNAMIC minZoom floor computed from the viewport. The floor
|
||
// guarantees one world always at least fills the container WIDTH, so the user
|
||
// can never zoom out far enough to see a repeated world copy — no maxBounds, no
|
||
// disabling of world copies. Ported from FORGE doxa-maps computeWorldMinZoom.
|
||
const MERCATOR_NORTH = 85.0511 // Web-Mercator north pole limit (±85.0511°)
|
||
const MIN_ZOOM_FLOOR = -2 // Mapbox allows negative minZoom since v1.6.1 (PR #9028)
|
||
const FALLBACK_MIN_ZOOM = 0 // used only when W/H not ready (corrected on first resize)
|
||
const DEFAULT_SOUTH_CUTOFF = -60 // Antarctica crop line (FLOOR-CALC INPUT ONLY)
|
||
|
||
/**
|
||
* Web-Mercator normalized Y fraction: 0 at +85.0511° (north), 1 at -85.0511°.
|
||
* Measures how tall a latitude band is in the projected (square) world.
|
||
*/
|
||
function mercatorY(lat) {
|
||
return (1 - Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI / 180) / 2)) / Math.PI) / 2
|
||
}
|
||
|
||
/**
|
||
* Computes the ASPECT-AWARE zoom-out floor (worldPx(z) = 512·2^z, tileSize 512):
|
||
*
|
||
* zWidth = log2(W / 512) — zoom where one world fills the WIDTH.
|
||
* For any zoom >= zWidth the world is at least as wide as the viewport, so
|
||
* a second copy can never appear. Allowed to go NEGATIVE (legal since
|
||
* Mapbox v1.6.1) so narrow phones can still zoom out to one full world.
|
||
* zHeight = log2(H / (512 · bandFrac)) — zoom where the latitude-CROPPED world
|
||
* (north +85.0511°, south `southCutoff`) fills the HEIGHT. Cropping the
|
||
* empty Antarctic band (bandFrac < 1) lets portrait phones zoom out to see
|
||
* the populated world without the dead space forcing over-zoom-out. The
|
||
* crop is a FLOOR-CALC INPUT ONLY — no maxBounds, no visual clip.
|
||
*
|
||
* FLOOR = max(zWidth, zHeight): the less-zoomed-out (higher) of the two binds.
|
||
* Because the result is always >= zWidth, the map can never reveal duplicate
|
||
* worlds. Clamped to [-2, maxZoom-1]; returns a sane fallback when W/H not ready.
|
||
*/
|
||
function computeWorldMinZoom(W, H, maxZoom, southCutoff) {
|
||
if (!W || W <= 0 || !H || H <= 0) return FALLBACK_MIN_ZOOM // size not ready yet
|
||
const zWidth = Math.log2(W / 512)
|
||
const bandFrac = mercatorY(southCutoff) - mercatorY(MERCATOR_NORTH) // south=-60 → ~0.71
|
||
const zHeight = Math.log2(H / (512 * bandFrac))
|
||
const floor = Math.max(zWidth, zHeight)
|
||
// Allow NEGATIVE (do NOT Math.max(0,…)); never within 1 zoom of maxZoom.
|
||
return Math.max(MIN_ZOOM_FLOOR, Math.min(floor, (maxZoom ?? 18) - 1))
|
||
}
|
||
|
||
export function useMapInstance({
|
||
containerRef,
|
||
accessToken,
|
||
style,
|
||
// config: the Mapbox `config` constructor option — { <fragmentId>: {…} }, e.g. a Standard
|
||
// style's { basemap: { lightPreset: 'night' } } — or a FUNCTION returning it, re-evaluated on
|
||
// every initializeMap() so a reload boots on the theme of that moment. Mapbox applies it to
|
||
// the style's imports BEFORE the first frame, which setConfigProperty (a no-op until
|
||
// 'style.load') cannot do — see useMapTheme.bootConfig. Styles without imports ignore it;
|
||
// NOT forwarded to MapLibre. Default undefined → byte-for-byte the previous behaviour.
|
||
config = undefined,
|
||
// provider: 'mapbox' (default) | 'maplibre'. Selects the GL engine.
|
||
//
|
||
// ★ LEFT UNSET, THE PAGE DECIDES — THAT IS WHAT MAKES ONE SWITCH FLIP EVERY MAP.
|
||
// This used to default to the LITERAL 'mapbox'. resolveMapProvider has long supported a
|
||
// page-level switch (profile-config.provider → ?provider= → window.MAP_PROVIDER), but a literal
|
||
// default meant the switch only reached a map whose bundle happened to pass `provider` through —
|
||
// MEASURED: 3 of the 11 bundles that mount a map. The other 8 silently pinned Mapbox no matter
|
||
// what the page asked for, which is the opposite of a switch: it LOOKED global and was not.
|
||
// Resolving here instead moves the decision to the one place every map already goes through, so
|
||
// a partner running thousands of embeds sets ONE value and the whole tree follows.
|
||
// An explicit caller still wins outright (prayer-walk-planner pins 'maplibre' deliberately), and
|
||
// with no page switch set resolveMapProvider returns 'mapbox' — so the default is unchanged.
|
||
provider = undefined,
|
||
center = [20, 10],
|
||
zoom = 2,
|
||
// minZoom: a profile may PIN a value (e.g. 0.5). It is treated as a LOWER
|
||
// bound only — the composable raises it to the aspect-aware floor whenever the
|
||
// viewport is wide enough that the pinned value would reveal a duplicate world.
|
||
// A pinned value is therefore never lowered, only raised. Leave it undefined to
|
||
// get the pure dynamic floor.
|
||
minZoom = undefined,
|
||
// ★ skipWorldFloor: a GLOBE host opts out of the desktop no-duplicate-world minZoom floor.
|
||
// That floor exists to stop a wide mercator viewport showing the world twice; on the globe
|
||
// projection there is no second world -- the planet is a sphere -- so the floor only steals
|
||
// zoom-out range (measured: it pinned the dashboard at ~1.45 when the whole point of a globe
|
||
// is pulling back to see it whole). The pinned minZoom (or the engine floor) still applies.
|
||
skipWorldFloor = false,
|
||
maxZoom = 18,
|
||
pitch = 0,
|
||
bearing = 0,
|
||
renderWorldCopies = true, // KEEP infinite worlds — zoom-out is capped by the dynamic floor
|
||
// showEdgeBorders: the gray "you can scroll past this map" edge strips.
|
||
// Was previously referenced only in a COMMENT while the behaviour was
|
||
// unconditional; it is a real flag as of 2026-07-30. DEFAULT TRUE so every
|
||
// existing profile is byte-for-byte unchanged. Set false where a map fills the
|
||
// viewport and there is nothing to scroll to.
|
||
showEdgeBorders = true,
|
||
// southCutoff: south latitude (deg) used ONLY to size the cropped world band
|
||
// for the HEIGHT term of the floor. FLOOR-CALC INPUT ONLY — never maxBounds.
|
||
southCutoff = DEFAULT_SOUTH_CUTOFF,
|
||
// maxBounds: NO default, NO derivation — the map pans infinitely. A profile may
|
||
// still pass an explicit [[w,s],[e,n]] to cap a specific map.
|
||
maxBounds = undefined
|
||
}) {
|
||
// shallowRef: the Mapbox Map must never be deep-reactive. A ref() wraps it in a Proxy,
|
||
// every internal read/write inside Mapbox pays the trap, and a render scheduled through
|
||
// the proxy re-renders whole frames through it (internal/long-frames-probe.mjs --profile:
|
||
// Vue get/set 2.7 s of idle self-time before the profiles started unwrapping the map).
|
||
const map = shallowRef(null)
|
||
const isMapReady = ref(false)
|
||
// Holds the dynamic-minZoom resize handler so destroy() can remove it.
|
||
let _onResize = null
|
||
// Cancels the debounced zoom-floor snap (anti-strobe) so destroy() leaves no pending timer.
|
||
let _clearSnap = null
|
||
// Holds the gray side-edge touch handlers so destroy() can remove them.
|
||
let _detachEdgeBorders = null
|
||
|
||
// Resolve the engine ONCE, here, so every branch below (script, style, controls) agrees.
|
||
// profile-config.provider is per-ELEMENT and outranks the page switch; inject() is only legal
|
||
// during setup(), which is where every caller invokes this — guarded so a non-setup caller
|
||
// degrades to the page switch instead of throwing.
|
||
let _pcForProvider = null
|
||
try { _pcForProvider = inject('profileConfig', null) } catch { /* called outside setup */ }
|
||
provider = provider || resolveMapProvider(_pcForProvider)
|
||
const isMaplibre = provider === 'maplibre'
|
||
|
||
async function initializeMap() {
|
||
// Resolve a NAMED style ('ink', 'satellite', …) for this engine; raw URLs
|
||
// and undefined keep their existing behavior (see library/styles/registry.js).
|
||
const _named = resolveNamedStyle(style, provider)
|
||
const effectiveStyle = _named.style || style
|
||
if (!containerRef.value) {
|
||
console.error('containerRef.value is null — call initializeMap() inside onMounted()')
|
||
return
|
||
}
|
||
|
||
// Load the selected engine (no-op if index.html preloaded it).
|
||
await ensureEngineScript(provider)
|
||
const gl = engineGlobal(provider)
|
||
if (!gl) {
|
||
console.error(`[useMapInstance] ${isMaplibre ? 'maplibregl' : 'mapboxgl'} is not available. `
|
||
+ `Add the ${isMaplibre ? 'MapLibre' : 'Mapbox'} GL JS script to your page.`)
|
||
return
|
||
}
|
||
|
||
// ─── API Key validation (Mapbox only — MapLibre + OpenFreeMap need no token) ──
|
||
if (!isMaplibre) {
|
||
// Reusable fallback: a host that can't thread a per-instance token (e.g. a BAKED composite that
|
||
// mounts a map component directly, with no ProfileLoader / profile-config wrapper) may expose the
|
||
// token once as `window.MAP_TOKEN`. Any map on the page then finds it. Per-instance accessToken
|
||
// still wins; this only fills the gap when none was passed.
|
||
if (!accessToken && typeof window !== 'undefined' && typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) {
|
||
accessToken = window.MAP_TOKEN
|
||
}
|
||
if (!accessToken) {
|
||
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON or set window.MAP_TOKEN.')
|
||
return
|
||
}
|
||
if (!accessToken.startsWith('pk.')) {
|
||
console.error('Mapbox token looks invalid (expected "pk." prefix). Got:', accessToken.slice(0, 10) + '...')
|
||
return
|
||
}
|
||
}
|
||
|
||
// Wait for the engine CSS inside the shadow root before creating the Map.
|
||
// Without this, _detectMissingCSS fires and the canvas initializes at 0px.
|
||
await ensureEngineCSS(containerRef.value, isMaplibre ? ENGINES.maplibre.css : MAPBOX_CSS_URL)
|
||
|
||
// Wait one animation frame so the browser has laid out the container.
|
||
// Mapbox reads clientWidth/clientHeight at init — if layout hasn't settled
|
||
// yet the canvas initializes at 0×0 and nothing renders.
|
||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||
|
||
// ─── Dimension diagnostics ───────────────────────────────────────
|
||
const el = containerRef.value
|
||
const w = el.clientWidth
|
||
const h = el.clientHeight
|
||
if (w === 0 || h === 0) {
|
||
console.error(`[useMapInstance] Container has ZERO dimensions (${w}×${h}). Map will be invisible. Check that parent elements have CSS sizing.`)
|
||
// Log parent chain for debugging
|
||
let parent = el.parentElement
|
||
let depth = 0
|
||
while (parent && depth < 5) {
|
||
parent = parent.parentElement
|
||
depth++
|
||
}
|
||
}
|
||
|
||
// TODO(INT-future): Mapbox's create-web-app uses VITE_MAPBOX_ACCESS_TOKEN.
|
||
// See intel/discovery-reports/W1A-mapbox-create-web-app-bp.md for rationale.
|
||
// Rename deferred: MFEs reference the current name; needs a coordinated update.
|
||
const token = accessToken
|
||
if (!isMaplibre) gl.accessToken = token
|
||
|
||
// ─── Effective zoom-out floor ────────────────────────────────────
|
||
// floor = the aspect-aware no-duplicate-world minZoom. A profile-pinned
|
||
// `minZoom` is honored only as a LOWER bound (raised to the floor when the
|
||
// viewport is wide enough that the pinned value would show a second world);
|
||
// it is never lowered. With no pin we use the pure floor. The not-ready
|
||
// fallback is corrected by style.load + the first resize event.
|
||
// DESKTOP-ONLY restriction (coder directive 2026-06-22): the no-duplicate-
|
||
// world floor is a desktop concern. Mobile/tablet (< 1024px) are free to zoom
|
||
// out as far as Mapbox allows (down to MIN_ZOOM_FLOOR), so on those viewports
|
||
// floorFor releases the floor entirely. Centralized here so _onResize picks it
|
||
// up automatically on an orientation/breakpoint flip into mobile.
|
||
const isDesktop = () => typeof window !== 'undefined' && window.innerWidth >= 1024
|
||
const floorFor = (W, H) => {
|
||
if (skipWorldFloor) return (typeof minZoom === 'number') ? minZoom : MIN_ZOOM_FLOOR
|
||
if (!isDesktop()) return MIN_ZOOM_FLOOR // mobile/tablet — no floor
|
||
const f = computeWorldMinZoom(W, H, maxZoom, southCutoff)
|
||
return (typeof minZoom === 'number') ? Math.max(minZoom, f) : f
|
||
}
|
||
const initialMinZoom = floorFor(el.clientWidth, el.clientHeight)
|
||
|
||
if (isMaplibre) {
|
||
// MapLibre branch — free OpenFreeMap basemap, NO access token. Shares the
|
||
// GL-JS Map option surface; only the token/style differ from Mapbox.
|
||
map.value = new gl.Map({
|
||
container: containerRef.value, // real DOM element — never a string ID in shadow DOM
|
||
style: maplibreStyle(effectiveStyle),
|
||
center,
|
||
zoom,
|
||
minZoom: initialMinZoom,
|
||
maxZoom,
|
||
pitch,
|
||
bearing,
|
||
dragRotate: false,
|
||
renderWorldCopies,
|
||
...(maxBounds ? { maxBounds } : {}),
|
||
attributionControl: true, // Mapbox's own attribution on every map
|
||
// Phones: logo and attribution both bottom-right, so the bottom-left corner stays free
|
||
// for the people-group image in the detail sheet.
|
||
logoPosition: (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(max-width: 767px)').matches) ? 'bottom-right' : 'bottom-left'
|
||
})
|
||
claimLogo(map.value)
|
||
// Probe hook: the container element carries its map so gates can read live config (no global).
|
||
try { containerRef.value.__doxaMap = map.value } catch (_) {}
|
||
} else {
|
||
// Fragment config for THIS boot (a thunk re-reads the theme on every re-init).
|
||
const initialConfig = typeof config === 'function' ? config() : config
|
||
map.value = new gl.Map({
|
||
accessToken: token, // BP #1 from W1A — per-instance token; avoids last-writer-wins on multi-embed pages
|
||
container: containerRef.value, // real DOM element — never a string ID in shadow DOM
|
||
style: effectiveStyle,
|
||
// Pins a Standard basemap's lightPreset/theme BEFORE its first frame; without it the
|
||
// schema default 'day' paints one LIGHT frame before any setConfigProperty can run.
|
||
...(initialConfig ? { config: initialConfig } : {}),
|
||
center,
|
||
zoom,
|
||
minZoom: initialMinZoom,
|
||
maxZoom,
|
||
pitch,
|
||
bearing,
|
||
dragRotate: false, // disorienting north-drift: no right-click+drag bearing change (desktop)
|
||
touchPitch: false, // no two-finger pitch on touch devices
|
||
renderWorldCopies, // true → infinite worlds; zoom-out capped by the dynamic floor
|
||
// NOTE: cooperativeGestures (the two-finger-to-zoom requirement) was REMOVED
|
||
// per coder directive 2026-06-24 — one-finger interaction must work normally.
|
||
// The single-finger page-scroll escape is now the gray side-edge affordance
|
||
// wired below (showEdgeBorders): the .map-scroll-edge gutters flash gray while
|
||
// touching the map and are scrollable to move the page past the map.
|
||
...(maxBounds ? { maxBounds } : {}), // only if a profile explicitly supplied bounds
|
||
attributionControl: true, // Mapbox's own attribution on every map
|
||
// Phones: logo and attribution both bottom-right, so the bottom-left corner stays free
|
||
// for the people-group image in the detail sheet.
|
||
logoPosition: (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(max-width: 767px)').matches) ? 'bottom-right' : 'bottom-left'
|
||
})
|
||
claimLogo(map.value)
|
||
// Probe hook: the container element carries its map so gates can read live config (no global).
|
||
try { containerRef.value.__doxaMap = map.value } catch (_) {}
|
||
}
|
||
|
||
// ── TILE 403 → A NAMED DIAGNOSIS, NOT A SILENT VOID ────────────────────────────────────
|
||
// The failure this exists for, measured 2026-09-21: a Mapbox token that is URL-RESTRICTED to
|
||
// one domain still returns 200 for the STYLE document and the FONTS, and 403 only for the
|
||
// TILESETS. The element mounts at full height, the legend and labels paint, the data layer
|
||
// paints — and the basemap silently never arrives. It reads as a rendering bug in our code.
|
||
// It is not: it is one Mapbox account setting.
|
||
//
|
||
// Nothing listened for it. GL-JS fires `error` with e.status on every failed tile request, so
|
||
// the information was always there; no one asked. That gap cost real debugging hours and is
|
||
// exactly what an inheritor deploying to a NEW domain will hit first.
|
||
//
|
||
// ONE MESSAGE PER MAP. A blank basemap produces a 403 for every tile in the viewport — dozens
|
||
// per second while panning — so an unguarded console.error would bury the page in noise and
|
||
// train the reader to ignore it.
|
||
try {
|
||
let toldTileAuth = false
|
||
map.value.on('error', (e) => {
|
||
const status = e?.error?.status ?? e?.status
|
||
const url = String(e?.error?.url || e?.sourceId || '')
|
||
if (status !== 401 && status !== 403) return // other errors: leave to the engine
|
||
if (toldTileAuth) return
|
||
toldTileAuth = true
|
||
console.error(
|
||
`[doxa-maps] Mapbox returned ${status} for a TILE request while the style loaded fine. ` +
|
||
'That is a URL RESTRICTION on the access token, not a bad token and not a bug in this map. ' +
|
||
`Add this page's origin (${typeof location !== 'undefined' ? location.origin : 'this origin'}) ` +
|
||
'to the token\'s URL restrictions in the Mapbox account. ' +
|
||
'The map will keep its data layer and stay blank underneath until then.' +
|
||
(url ? ` First failing request: ${url.slice(0, 120)}` : '')
|
||
)
|
||
|
||
// ── CONSOLE ONLY — NO ON-SCREEN BANNER ──────────────────────────────────────────
|
||
// An earlier version of this handler also appended a `.dx-tile-auth-note` corner
|
||
// banner to the map container. REMOVED on Driver instruction 2026-09-22: "the map
|
||
// should not have a popup that says basemap tiles blocked".
|
||
//
|
||
// WHY THE INSTRUCTION IS RIGHT, so nobody re-adds it. The banner reported an
|
||
// OPERATOR problem (one account setting, on one deployment origin) to the END USER,
|
||
// who can do nothing about it and did not ask. It rendered on every map, on every
|
||
// load, in the product itself — turning a deployment note into a permanent piece of
|
||
// the UI. The console message below reaches exactly the person who can act, and only
|
||
// when they go looking. That is the right audience for a configuration diagnosis.
|
||
//
|
||
// If a future failure genuinely needs to be shown to a user, it must be a failure the
|
||
// USER can resolve, and it belongs in the profile's own status/empty-state affordance
|
||
// — not injected into the map container from a shared composable.
|
||
})
|
||
} catch (_) { /* an engine without .on() must never break map creation */ }
|
||
|
||
// Belt-and-suspenders to dragRotate:false — kills the two-finger twist→rotate
|
||
// handler so the north bearing can never drift on any input method. Present on
|
||
// both engines.
|
||
try { map.value.touchZoomRotate.disableRotation() } catch (_) { /* engine may differ */ }
|
||
|
||
// ─── Edge scroll-affordance strips (custom page-scroll "scrollbar") ─────────
|
||
// The .map-scroll-edge strips (left + right, every profile: pointer-events auto,
|
||
// touch-action pan-y) are always-present INVISIBLE hit-boxes. The gray indicator
|
||
// (a 36px gradient at the very page edge) appears when:
|
||
// • you SCROLL the map — on the PRIMARY side only (RIGHT on desktop via wheel,
|
||
// LEFT on mobile via a one-finger drag): the side with the most whitespace;
|
||
// • you HOVER a strip (desktop) or TOUCH a strip (mobile) — on THAT strip, so
|
||
// either edge can be summoned where you reach for it. A HOVERED strip is
|
||
// pinned: it never auto-hides (even while you scroll) until you leave it.
|
||
// MOBILE scrolls the page natively via the strip's touch-action: pan-y. DESKTOP
|
||
// has NO grab/click+drag-to-scroll (removed per coder 2026-06-24) — the strip is a
|
||
// hover indicator only; user-select:none on strips+buttons still blocks any stray
|
||
// text selection. Desktop map click+drag (Mapbox pan) never lights it.
|
||
// Visibility toggles via OPACITY (not a background swap) so it fades, never hard-
|
||
// flashes, and the hit-box stays live the whole time. (coder 2026-06-24)
|
||
// The implementation was LIFTED to library/composables/useEdgeScrollAffordance.js
|
||
// (2026-07-30) so panes that are NOT maps can use it too — a tree-node/whiteboard
|
||
// pane traps the page scroll exactly the same way a Mapbox canvas does. The
|
||
// behaviour here is unchanged: same strips, same primary side, same hover-pin.
|
||
_detachEdgeBorders = useEdgeScrollAffordance(
|
||
containerRef.value?.parentElement || null,
|
||
{ enabled: showEdgeBorders },
|
||
)
|
||
|
||
// ─── Keep the aspect-aware floor in sync on resize / orientation flip ──
|
||
// Recompute from the live container W AND H and setMinZoom so the floor
|
||
// always tracks max(one-world-fills-width, cropped-world-fills-height) — never
|
||
// two worlds at once, but low enough on tall/portrait phones to see the whole
|
||
// world. Portrait↔landscape flips which term binds; if the new floor is above
|
||
// the current zoom, snap up so the camera stays within the floor.
|
||
// A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): this handler runs on EVERY Mapbox/MapLibre
|
||
// 'resize' event, and map.resize() itself emits one. When a container is being dragged
|
||
// (a resizable dashboard section, a window-edge drag) the height — and therefore the
|
||
// computed floor — changes on every animation frame, so the unguarded `setZoom(floor)`
|
||
// re-snapped the camera ~60x/sec and re-rendered the whole globe: visible strobing, a
|
||
// seizure risk for photosensitive users. Two guards, both required:
|
||
// 1. IDEMPOTENCE — only touch minZoom when the floor actually CHANGED. A pure width
|
||
// drag, or any resize that leaves the floor alone, now costs nothing.
|
||
// 2. DEBOUNCE the camera snap — clamping the zoom is a visible camera move, so it must
|
||
// happen ONCE when the size settles, never mid-drag. The floor itself is applied
|
||
// immediately (cheap, non-visual); only the snap waits.
|
||
let _lastFloor = initialMinZoom
|
||
let _snapT = null
|
||
_onResize = () => {
|
||
const m = map.value
|
||
if (!m) return
|
||
const floor = floorFor(el.clientWidth, el.clientHeight)
|
||
if (floor !== _lastFloor) {
|
||
_lastFloor = floor
|
||
m.setMinZoom(floor)
|
||
}
|
||
// Defer the visible clamp until the resize stream goes quiet.
|
||
if (m.getZoom() < floor) {
|
||
if (_snapT) clearTimeout(_snapT)
|
||
_snapT = setTimeout(() => {
|
||
_snapT = null
|
||
const mm = map.value
|
||
if (!mm) return
|
||
const f = floorFor(el.clientWidth, el.clientHeight)
|
||
// jumpTo, not setZoom: no easing/animation, so the correction lands in a single
|
||
// frame instead of tweening (another motion source for reduced-motion users).
|
||
if (mm.getZoom() < f) { try { mm.jumpTo({ zoom: f }) } catch (_) { mm.setZoom(f) } }
|
||
}, 150)
|
||
} else if (_snapT) {
|
||
// the drag brought us back inside the floor — cancel the pending correction
|
||
clearTimeout(_snapT); _snapT = null
|
||
}
|
||
}
|
||
_clearSnap = () => { if (_snapT) { clearTimeout(_snapT); _snapT = null } }
|
||
// Mapbox fires 'resize' after map.resize(); also listen to window resize +
|
||
// orientationchange so panel toggles / breakpoints / device rotation recompute.
|
||
map.value.on('resize', _onResize)
|
||
if (typeof window !== 'undefined') {
|
||
window.addEventListener('resize', _onResize)
|
||
window.addEventListener('orientationchange', _onResize)
|
||
}
|
||
|
||
map.value.on('style.load', () => {
|
||
// ── NAMED-STYLE PAINT PATCH (e.g. 'galaxy': blue water on a black ground) ──────────────
|
||
// style.load is the ONLY correct moment: setPaintProperty before the style document has
|
||
// parsed throws, and re-applying here also survives a later setStyle() — the event fires
|
||
// again, so a style swap cannot silently drop the patch.
|
||
// Nothing happens for styles with no `paint` in the registry, so every existing map is
|
||
// byte-for-byte unchanged.
|
||
if (_named.paint && map.value) {
|
||
const hit = applyStylePaint(map.value, _named.paint)
|
||
// Log what the LOADED style actually had — layer ids differ between Mapbox and MapLibre
|
||
// and have moved across Mapbox style versions, so this is the only trustworthy record
|
||
// of which ids are real. A patch that matched nothing is a silent no-op otherwise.
|
||
console.info('[useMapInstance] style paint applied (' + provider + '):', hit.join(', ') || 'NONE MATCHED')
|
||
}
|
||
// Resize after style.load in a rAF: container layout may not have settled yet,
|
||
// especially when the panel was hidden (display:none) at startup.
|
||
requestAnimationFrame(() => {
|
||
map.value?.resize()
|
||
// Recompute the floor now the container has a real width/height (init may
|
||
// have used the not-ready fallback).
|
||
if (map.value) map.value.setMinZoom(floorFor(el.clientWidth, el.clientHeight))
|
||
isMapReady.value = true
|
||
})
|
||
})
|
||
}
|
||
|
||
function destroy() {
|
||
if (map.value) {
|
||
// Tear down the dynamic-minZoom handlers (mirrors registration above).
|
||
if (_onResize) {
|
||
map.value.off('resize', _onResize)
|
||
if (typeof window !== 'undefined') {
|
||
window.removeEventListener('resize', _onResize)
|
||
window.removeEventListener('orientationchange', _onResize)
|
||
}
|
||
_onResize = null
|
||
}
|
||
// Drop any pending debounced zoom-floor snap so it can never fire on a dead map.
|
||
if (_clearSnap) { try { _clearSnap() } catch (_) {} _clearSnap = null }
|
||
// Tear down the gray side-edge touch handlers (mirrors registration above).
|
||
if (_detachEdgeBorders) { _detachEdgeBorders(); _detachEdgeBorders = null }
|
||
map.value.remove()
|
||
map.value = null
|
||
isMapReady.value = false
|
||
}
|
||
}
|
||
|
||
return { map, isMapReady, initializeMap, destroy }
|
||
}
|