438 lines
20 KiB
JavaScript
438 lines
20 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 } 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',
|
||
},
|
||
}
|
||
|
||
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'
|
||
|
||
/** 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,
|
||
// provider: 'mapbox' (default — unchanged) | 'maplibre'. Selects the GL engine.
|
||
provider = 'mapbox',
|
||
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,
|
||
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
|
||
}) {
|
||
const map = ref(null)
|
||
const isMapReady = ref(false)
|
||
// Holds the dynamic-minZoom resize handler so destroy() can remove it.
|
||
let _onResize = null
|
||
// Holds the gray side-edge touch handlers so destroy() can remove them.
|
||
let _detachEdgeBorders = null
|
||
|
||
const isMaplibre = provider === 'maplibre'
|
||
|
||
async function initializeMap() {
|
||
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) {
|
||
if (!accessToken) {
|
||
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON.')
|
||
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 (!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(style),
|
||
center,
|
||
zoom,
|
||
minZoom: initialMinZoom,
|
||
maxZoom,
|
||
pitch,
|
||
bearing,
|
||
dragRotate: false,
|
||
renderWorldCopies,
|
||
...(maxBounds ? { maxBounds } : {}),
|
||
attributionControl: false
|
||
})
|
||
} else {
|
||
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,
|
||
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: false
|
||
})
|
||
}
|
||
|
||
// 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.
|
||
_onResize = () => {
|
||
const m = map.value
|
||
if (!m) return
|
||
const floor = floorFor(el.clientWidth, el.clientHeight)
|
||
m.setMinZoom(floor)
|
||
if (m.getZoom() < floor) m.setZoom(floor)
|
||
}
|
||
// 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', () => {
|
||
// 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
|
||
}
|
||
// 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 }
|
||
}
|