Files
hnh-map/frontend-nuxt/composables/useMapUpdates.ts
Nikolay Tatarinov 52c34ef8f2 Enhance map functionality with bookmark features and UI improvements
- Introduced a new MapBookmarkNameModal for adding and editing bookmarks.
- Updated MapView to manage selected markers for bookmarking and handle bookmark name submissions.
- Enhanced MapContextMenu with an option to add markers to bookmarks.
- Improved MapBookmarks component to support editing bookmark names and adding selected markers.
- Refactored MapControls and MapControlsContent to integrate selected marker functionality for bookmarks.
- Updated useMapBookmarks composable to include bookmark updating logic.
- Removed unused grid coordinates toggle from the UI for a cleaner interface.
2026-03-03 20:05:42 +03:00

172 lines
5.0 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
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 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()
for (const u of updates) {
if (visible && u.Z !== visible.zoom) 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)
if (overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
}
}
function scheduleBatch() {
if (batchScheduled) return
batchScheduled = true
setTimeout(applyBatch, BATCH_MS)
}
function connect() {
if (destroyed || !import.meta.client) return
source = new EventSource(updatesUrl)
if (connectionStateRef) connectionStateRef.value = 'connecting'
source.onopen = () => {
if (connectionStateRef) connectionStateRef.value = 'open'
reconnectDelayMs = RECONNECT_INITIAL_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) => {
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 (reconnectTimeoutId != null) {
clearTimeout(reconnectTimeoutId)
reconnectTimeoutId = null
}
if (source) {
source.close()
source = null
}
}
return { cleanup }
}