ptitlutins/app/composables/useVoyageMap.ts
2026-06-15 23:34:49 +02:00

115 lines
3.6 KiB
TypeScript

import type { Ref } from "vue"
import type { OsmMap } from "esquisse"
import type { Node } from "~/types"
import { createBaseMap, type MapView } from "~/services/mapService"
import EventLayer from "~/map/layers/EventLayer"
import { nodesToFeatureCollection } from "~/map/geojson"
/** Wait this long for the base style/tiles before surfacing an error + retry. */
const LOAD_TIMEOUT_MS = 15000
/**
* Owns the imperative MapLibre lifecycle for a container element and exposes a
* declarative `{ loading, error, retry }` handle, so the component stays free of
* any map-library glue.
*/
export function useVoyageMap(
container: Ref<HTMLElement | null>,
view: MapView,
nodes: Ref<Node[]>,
) {
const loading = ref(true)
const error = ref(false)
let map: OsmMap | null = null
let eventLayer: EventLayer | null = null
let loadTimer: ReturnType<typeof setTimeout> | null = null
let runToken = 0 // identifies the live attempt; a stale await resolves to a dead token
let disposed = false // set once, on unmount
const clearTimer = () => {
if (loadTimer) clearTimeout(loadTimer)
loadTimer = null
}
// Tear down the live attempt; bumping the token invalidates any start() still
// awaiting createBaseMap so it disposes its (now-orphan) map instead of us.
const teardown = () => {
runToken++
clearTimer()
map?.remove()
map = null
eventLayer = null
}
// The base map could not come up (creation failed, or it never loaded in
// time): drop any stalled instance and show the retryable error state rather
// than spinning forever.
const fail = () => {
clearTimer()
map?.remove()
map = null
eventLayer = null
error.value = true
loading.value = false
}
const start = async () => {
if (disposed || !container.value) return
const token = ++runToken
loading.value = true
error.value = false
const created = await createBaseMap(container.value, view)
// Unmounted, or a newer attempt superseded this one, while we awaited the
// libraries: discard the orphan so it can't leak or mutate dead refs.
if (disposed || token !== runToken) {
created?.remove()
return
}
if (!created) {
fail()
return
}
map = created
// Seed the node layer now (and again on every retry's fresh map). Subsequent
// `nodes` changes flow through the watch below; setData defers internally
// until the source is live, so this is safe before the style finishes.
eventLayer = new EventLayer({ map })
eventLayer.setData(nodesToFeatureCollection(nodes.value))
// Base map is up once the style + first tiles are ready (onLoadOrNow also
// covers the case where it finished loading during the await).
map.onLoadOrNow(() => {
clearTimer()
loading.value = false
})
// Unreachable tile server / stalled fetch → `load` never fires; rescue the
// user from an endless spinner with a retryable error.
loadTimer = setTimeout(() => {
if (loading.value) fail()
}, LOAD_TIMEOUT_MS)
// A single failed tile must never crash the UI nor spam the user: just log.
map.on("error", (event) => {
console.error("VoyageMap: tile/source error", event.error ?? event)
})
}
const retry = () => {
teardown()
return start()
}
// The reactive→imperative bridge: a no-op until a map (hence a layer) is live;
// the seed in start() covers the initial render and every retry.
watch(nodes, (next) => eventLayer?.setData(nodesToFeatureCollection(next)))
onMounted(start)
onBeforeUnmount(() => {
disposed = true
teardown()
})
return { loading, error, retry }
}