import type { Ref } from 'vue' import type { SmartTileLayer } from '~/lib/SmartTileLayer' import { TileSize } from '~/lib/LeafletCustomTypes' import type L from 'leaflet' type SmartTileLayerInstance = InstanceType export type SseConnectionState = 'connecting' | 'open' | 'error' interface TileUpdate { M: number X: number Y: number Z: number T: number } interface MergeEvent { From: number To: number Shift: { x: number; y: number } } export interface UseMapUpdatesOptions { backendBase: string layer: SmartTileLayerInstance overlayLayer: SmartTileLayerInstance map: L.Map getCurrentMapId: () => number onMerge: (mapTo: number, shift: { x: number; y: number }) => void /** Optional ref updated with SSE connection state for reconnection indicator. */ connectionStateRef?: Ref } export interface UseMapUpdatesReturn { cleanup: () => void } const RECONNECT_INITIAL_MS = 1000 const RECONNECT_MAX_MS = 30000 /** If no SSE message received for this long, treat connection as stale and reconnect. */ const STALE_CONNECTION_MS = 65000 const STALE_CHECK_INTERVAL_MS = 30000 export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn { const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options const updatesPath = `${backendBase}/updates` const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath const BATCH_MS = 50 let batch: TileUpdate[] = [] let batchScheduled = false let source: EventSource | null = null let reconnectTimeoutId: ReturnType | null = null let staleCheckIntervalId: ReturnType | null = null let lastMessageTime = 0 let reconnectDelayMs = RECONNECT_INITIAL_MS let destroyed = false const VISIBLE_TILE_BUFFER = 1 function getVisibleTileBounds() { const zoom = map.getZoom() const px = map.getPixelBounds() if (!px) return null return { zoom, minX: Math.floor(px.min.x / TileSize) - VISIBLE_TILE_BUFFER, maxX: Math.ceil(px.max.x / TileSize) + VISIBLE_TILE_BUFFER, minY: Math.floor(px.min.y / TileSize) - VISIBLE_TILE_BUFFER, maxY: Math.ceil(px.max.y / TileSize) + VISIBLE_TILE_BUFFER, } } function applyBatch() { batchScheduled = false if (batch.length === 0) return const updates = batch batch = [] for (const u of updates) { const key = `${u.M}:${u.X}:${u.Y}:${u.Z}` layer.cache[key] = u.T overlayLayer.cache[key] = u.T } const visible = getVisibleTileBounds() // u.Z is backend storage zoom (0..5); visible.zoom is map zoom (1..6). With zoomReverse, current backend Z = maxZoom - mapZoom. const currentBackendZ = visible ? layer.options.maxZoom - visible.zoom : null let needRedraw = false for (const u of updates) { if (visible && currentBackendZ != null && u.Z !== currentBackendZ) continue if ( visible && (u.X < visible.minX || u.X > visible.maxX || u.Y < visible.minY || u.Y > visible.maxY) ) continue if (layer.map === u.M && !layer.refresh(u.X, u.Y, u.Z)) needRedraw = true if (overlayLayer.map === u.M && !overlayLayer.refresh(u.X, u.Y, u.Z)) needRedraw = true } if (needRedraw) { layer.redraw() overlayLayer.redraw() } } function scheduleBatch() { if (batchScheduled) return batchScheduled = true setTimeout(applyBatch, BATCH_MS) } function connect() { if (destroyed || !import.meta.client) return if (staleCheckIntervalId != null) { clearInterval(staleCheckIntervalId) staleCheckIntervalId = null } source = new EventSource(updatesUrl) if (connectionStateRef) connectionStateRef.value = 'connecting' source.onopen = () => { if (connectionStateRef) connectionStateRef.value = 'open' lastMessageTime = Date.now() reconnectDelayMs = RECONNECT_INITIAL_MS staleCheckIntervalId = setInterval(() => { if (destroyed || !source) return if (Date.now() - lastMessageTime > STALE_CONNECTION_MS) { if (staleCheckIntervalId != null) { clearInterval(staleCheckIntervalId) staleCheckIntervalId = null } source.close() source = null if (connectionStateRef) connectionStateRef.value = 'error' connect() } }, STALE_CHECK_INTERVAL_MS) } source.onerror = () => { if (destroyed || !source) return if (connectionStateRef) connectionStateRef.value = 'error' source.close() source = null if (destroyed) return reconnectTimeoutId = setTimeout(() => { reconnectTimeoutId = null connect() reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS) }, reconnectDelayMs) } source.onmessage = (event: MessageEvent) => { lastMessageTime = Date.now() if (connectionStateRef) connectionStateRef.value = 'open' try { const raw: unknown = event?.data if (raw == null || typeof raw !== 'string' || raw.trim() === '') return const updates: unknown = JSON.parse(raw) if (!Array.isArray(updates)) return for (const u of updates as TileUpdate[]) { batch.push(u) } scheduleBatch() } catch { // Ignore parse errors from SSE } } source.addEventListener('merge', (e: MessageEvent) => { try { const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}') if (getCurrentMapId() === merge.From) { const point = map.project(map.getCenter(), 6) const shift = { x: Math.floor(point.x / TileSize) + merge.Shift.x, y: Math.floor(point.y / TileSize) + merge.Shift.y, } onMerge(merge.To, shift) } } catch { // Ignore merge parse errors } }) } connect() function cleanup() { destroyed = true if (staleCheckIntervalId != null) { clearInterval(staleCheckIntervalId) staleCheckIntervalId = null } if (reconnectTimeoutId != null) { clearTimeout(reconnectTimeoutId) reconnectTimeoutId = null } if (source) { source.close() source = null } } return { cleanup } }