- Introduced mechanisms to detect stale connections in the map updates, allowing for automatic reconnection if no messages are received within a specified timeframe. - Updated the `refresh` method in `SmartTileLayer` to return a boolean indicating whether the tile was refreshed, improving the handling of tile updates. - Enhanced the `Send` method in the `Topic` struct to drop messages for full subscribers while keeping them subscribed, ensuring continuous delivery of future updates. - Added a keepalive mechanism in the `WatchGridUpdates` handler to maintain the connection and prevent timeouts.
207 lines
6.2 KiB
TypeScript
207 lines
6.2 KiB
TypeScript
import type { Ref } from 'vue'
|
|
import type { SmartTileLayer } from '~/lib/SmartTileLayer'
|
|
import { TileSize } from '~/lib/LeafletCustomTypes'
|
|
import type L from 'leaflet'
|
|
|
|
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
|
|
|
|
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<SseConnectionState>
|
|
}
|
|
|
|
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<typeof setTimeout> | null = null
|
|
let staleCheckIntervalId: ReturnType<typeof setInterval> | 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 }
|
|
}
|