115 lines
3.6 KiB
TypeScript
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 }
|
|
}
|