From 49af08c13f638e43f9661f7a6f79d7fc533187a7 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Sun, 1 Mar 2026 17:30:48 +0300 Subject: [PATCH] Enhance map updates and component performance - Updated API documentation to clarify the initial data message structure for real-time tile updates. - Modified MapView component to load configuration and user data in parallel, improving map loading speed. - Implemented asynchronous loading for markers after the map is visible, enhancing user experience. - Introduced batching for tile updates to optimize rendering performance during map updates. - Refactored character and marker creation functions to utilize dynamic Leaflet imports, improving modularity. --- docs/api.md | 2 +- frontend-nuxt/components/MapView.vue | 58 +++++++++++++++------- frontend-nuxt/composables/useMapInit.ts | 4 ++ frontend-nuxt/composables/useMapLayers.ts | 7 ++- frontend-nuxt/composables/useMapUpdates.ts | 33 ++++++++++-- frontend-nuxt/lib/Character.ts | 23 +++++---- frontend-nuxt/lib/LeafletCustomTypes.ts | 52 ++++++------------- frontend-nuxt/lib/Marker.ts | 10 +++- internal/app/handlers/tile.go | 6 +-- 9 files changed, 120 insertions(+), 75 deletions(-) diff --git a/docs/api.md b/docs/api.md index 1e9fc5f..24c3af2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -60,7 +60,7 @@ The game client (e.g. Purus Pasta) communicates via `/client/{token}/...` endpoi ## SSE (Server-Sent Events) -- **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends `data:` messages with tile cache arrays and `event: merge` messages for map merges. +- **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends an initial `data:` message with an empty tile cache array `[]`, then incremental `data:` messages with tile cache updates and `event: merge` messages for map merges. The client requests tiles with `cache=0` when not yet in cache. ## Tile images diff --git a/frontend-nuxt/components/MapView.vue b/frontend-nuxt/components/MapView.vue index 6f40a1a..2323ad0 100644 --- a/frontend-nuxt/components/MapView.vue +++ b/frontend-nuxt/components/MapView.vue @@ -271,9 +271,12 @@ onMounted(async () => { const L = (await import('leaflet')).default - const [charactersData, mapsData] = await Promise.all([ + // Load maps, characters, config and me in parallel so map can show sooner. + const [charactersData, mapsData, config, user] = await Promise.all([ api.getCharacters().then((d) => (Array.isArray(d) ? d : [])).catch(() => []), api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})), + api.getConfig().catch(() => ({})) as Promise, + api.me().catch(() => null) as Promise, ]) const mapsList: MapInfo[] = [] @@ -295,9 +298,7 @@ onMounted(async () => { maps.value = mapsList mapsLoaded.value = true - const config = (await api.getConfig().catch(() => ({}))) as ConfigResponse if (config?.title) document.title = config.title - const user = (await api.me().catch(() => null)) as MeResponse | null auths.value = user?.auths ?? config?.auths ?? [] const initialMapId = @@ -328,6 +329,7 @@ onMounted(async () => { document.addEventListener('contextmenu', contextMenuHandler, true) layersManager = createMapLayers({ + L, map: leafletMap, markerLayer: mapInit.markerLayer, layer: mapInit.layer, @@ -382,16 +384,25 @@ onMounted(async () => { } } + // Show map as soon as canvas and layers are ready; markers load in background. + if (leafletMap) leafletMap.invalidateSize() nextTick(() => { requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (leafletMap) leafletMap.invalidateSize() - mapReady.value = true - }) + if (leafletMap) leafletMap.invalidateSize() + mapReady.value = true }) }) - intervalId = setInterval(() => { + // Markers load asynchronously after map is visible. + api.getMarkers().then((body) => { + layersManager!.updateMarkers(Array.isArray(body) ? body : []) + questGivers.value = layersManager!.getQuestGivers() + }) + + const CHARACTER_POLL_MS = 4000 + const CHARACTER_POLL_MS_HIDDEN = 30000 + + function pollCharacters() { api .getCharacters() .then((body) => { @@ -400,24 +411,37 @@ onMounted(async () => { players.value = layersManager!.getPlayers() mapLive.value = list.some((c) => c.ownedByMe) }) - .catch(() => clearInterval(intervalId!)) - }, 2000) + .catch(() => { + if (intervalId) clearInterval(intervalId) + intervalId = null + }) + } - api.getMarkers().then((body) => { - layersManager!.updateMarkers(Array.isArray(body) ? body : []) - questGivers.value = layersManager!.getQuestGivers() - }) + function startCharacterPoll() { + if (intervalId) clearInterval(intervalId) + const ms = + typeof document !== 'undefined' && document.visibilityState === 'hidden' + ? CHARACTER_POLL_MS_HIDDEN + : CHARACTER_POLL_MS + pollCharacters() + intervalId = setInterval(pollCharacters, ms) + } + + startCharacterPoll() + if (import.meta.client) { + document.addEventListener('visibilitychange', () => { + startCharacterPoll() + }) + } watch(mapLogic.state.showGridCoordinates, (v) => { if (mapInit?.coordLayer) { ;(mapInit.coordLayer.options as { visible?: boolean }).visible = v mapInit.coordLayer.setOpacity(v ? 1 : 0) + mapInit.coordLayer.redraw?.() if (v && leafletMap) { mapInit.coordLayer.bringToFront?.() - mapInit.coordLayer.redraw?.() leafletMap.invalidateSize() - } else { - mapInit.coordLayer.redraw?.() } } }) diff --git a/frontend-nuxt/composables/useMapInit.ts b/frontend-nuxt/composables/useMapInit.ts index e7ef411..290ec9a 100644 --- a/frontend-nuxt/composables/useMapInit.ts +++ b/frontend-nuxt/composables/useMapInit.ts @@ -67,6 +67,7 @@ export async function initLeafletMap( const layer = new SmartTileLayer(tileUrl, { minZoom: 1, maxZoom: 6, + maxNativeZoom: 6, zoomOffset: 0, zoomReverse: true, tileSize: TileSize, @@ -81,6 +82,7 @@ export async function initLeafletMap( const overlayLayer = new SmartTileLayer(tileUrl, { minZoom: 1, maxZoom: 6, + maxNativeZoom: 6, zoomOffset: 0, zoomReverse: true, tileSize: TileSize, @@ -100,6 +102,8 @@ export async function initLeafletMap( opacity: 0, visible: false, pane: 'tilePane', + updateWhenIdle: true, + keepBuffer: 2, } as GridCoordLayerOptions) coordLayer.addTo(map) coordLayer.setZIndex(500) diff --git a/frontend-nuxt/composables/useMapLayers.ts b/frontend-nuxt/composables/useMapLayers.ts index 03d5078..7d16d17 100644 --- a/frontend-nuxt/composables/useMapLayers.ts +++ b/frontend-nuxt/composables/useMapLayers.ts @@ -15,6 +15,8 @@ import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api type SmartTileLayerInstance = InstanceType export interface MapLayersOptions { + /** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */ + L: typeof import('leaflet') map: L.Map markerLayer: L.LayerGroup layer: SmartTileLayerInstance @@ -48,6 +50,7 @@ export interface MapLayersManager { export function createMapLayers(options: MapLayersOptions): MapLayersManager { const { + L, map, markerLayer, layer, @@ -105,7 +108,7 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager { : undefined uniqueListUpdate( markers, - list.map((it) => createMarker(it as MarkerData, iconOptions)), + list.map((it) => createMarker(it as MarkerData, iconOptions, L)), (marker: MapMarker) => { if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx) marker.setClickCallback(() => { @@ -129,7 +132,7 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager { const ctx = characterCtx() uniqueListUpdate( characters, - list.map((it) => createCharacter(it as CharacterData)), + list.map((it) => createCharacter(it as CharacterData, L)), (character: MapCharacter) => { character.add(ctx) character.setClickCallback(() => setTrackingCharacterId(character.id)) diff --git a/frontend-nuxt/composables/useMapUpdates.ts b/frontend-nuxt/composables/useMapUpdates.ts index 9ac63fb..c16da98 100644 --- a/frontend-nuxt/composables/useMapUpdates.ts +++ b/frontend-nuxt/composables/useMapUpdates.ts @@ -53,6 +53,32 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet if (connectionStateRef) connectionStateRef.value = 'error' } + const BATCH_MS = 50 + let batch: TileUpdate[] = [] + let batchScheduled = false + + 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 + } + for (const u of updates) { + 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) + } + source.onmessage = (event: MessageEvent) => { if (connectionStateRef) connectionStateRef.value = 'open' try { @@ -61,12 +87,9 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet const updates: unknown = JSON.parse(raw) if (!Array.isArray(updates)) return for (const u of updates as TileUpdate[]) { - const key = `${u.M}:${u.X}:${u.Y}:${u.Z}` - layer.cache[key] = u.T - overlayLayer.cache[key] = u.T - 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) + batch.push(u) } + scheduleBatch() } catch { // Ignore parse errors from SSE } diff --git a/frontend-nuxt/lib/Character.ts b/frontend-nuxt/lib/Character.ts index 00fba4c..f8736d3 100644 --- a/frontend-nuxt/lib/Character.ts +++ b/frontend-nuxt/lib/Character.ts @@ -1,5 +1,7 @@ +import type L from 'leaflet' import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' -import * as L from 'leaflet' + +export type LeafletApi = typeof import('leaflet') /** SVG data URL for character marker icon (teal pin, bottom-center anchor). */ const CHARACTER_ICON_URL = @@ -11,12 +13,14 @@ const CHARACTER_ICON_URL = '' ) -const CHARACTER_ICON = new L.Icon({ - iconUrl: CHARACTER_ICON_URL, - iconSize: [24, 32], - iconAnchor: [12, 32], - popupAnchor: [0, -32], -}) +function createCharacterIcon(L: LeafletApi): L.Icon { + return new L.Icon({ + iconUrl: CHARACTER_ICON_URL, + iconSize: [24, 32], + iconAnchor: [12, 32], + popupAnchor: [0, -32], + }) +} export interface CharacterData { name: string @@ -47,9 +51,10 @@ export interface MapCharacter { setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void } -export function createCharacter(data: CharacterData): MapCharacter { +export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter { let leafletMarker: L.Marker | null = null let onClick: ((e: L.LeafletMouseEvent) => void) | null = null + const characterIcon = createCharacterIcon(L) const character: MapCharacter = { id: data.id, @@ -75,7 +80,7 @@ export function createCharacter(data: CharacterData): MapCharacter { add(mapview: CharacterMapViewRef): void { if (character.map === mapview.mapid) { const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom) - leafletMarker = L.marker(position, { icon: CHARACTER_ICON, title: character.name }) + leafletMarker = L.marker(position, { icon: characterIcon, title: character.name }) leafletMarker.on('click', (e: L.LeafletMouseEvent) => { if (onClick) onClick(e) }) diff --git a/frontend-nuxt/lib/LeafletCustomTypes.ts b/frontend-nuxt/lib/LeafletCustomTypes.ts index 6c78fa7..463b2eb 100644 --- a/frontend-nuxt/lib/LeafletCustomTypes.ts +++ b/frontend-nuxt/lib/LeafletCustomTypes.ts @@ -5,13 +5,17 @@ export const HnHMaxZoom = 6 export const HnHMinZoom = 1 export const HnHDefaultZoom = 6 -/** When scaleFactor exceeds this, render one label per tile instead of a full grid (avoids 100k+ DOM nodes at zoom 1). */ -const GRID_COORD_SCALE_FACTOR_THRESHOLD = 8 - export interface GridCoordLayerOptions extends L.GridLayerOptions { visible?: boolean } +/** + * Grid layer that draws one coordinate label per Leaflet tile in the top-left corner. + * coords.(x,y,z) are Leaflet tile indices and zoom; they map to game tiles as: + * scaleFactor = 2^(HnHMaxZoom - coords.z), + * topLeft = (coords.x * scaleFactor, coords.y * scaleFactor). + * This matches backend tile URL {mapid}/{z}/{x}_{y}.png (storageZ: z=6→0, Coord = tile index). + */ export const GridCoordLayer = L.GridLayer.extend({ options: { visible: true, @@ -32,40 +36,16 @@ export const GridCoordLayer = L.GridLayer.extend({ const scaleFactor = Math.pow(2, HnHMaxZoom - coords.z) const topLeft = { x: coords.x * scaleFactor, y: coords.y * scaleFactor } - const bottomRight = { x: topLeft.x + scaleFactor - 1, y: topLeft.y + scaleFactor - 1 } - const swPoint = { x: topLeft.x * TileSize, y: topLeft.y * TileSize } - const tileWidthPx = scaleFactor * TileSize - const tileHeightPx = scaleFactor * TileSize - if (scaleFactor > GRID_COORD_SCALE_FACTOR_THRESHOLD) { - // Low zoom: one label per tile to avoid hundreds of thousands of DOM nodes (Reset view freeze fix) - const textElement = document.createElement('div') - textElement.classList.add('map-tile-text') - textElement.textContent = `(${topLeft.x}, ${topLeft.y})` - textElement.style.position = 'absolute' - textElement.style.left = '2px' - textElement.style.top = '2px' - textElement.style.fontSize = Math.max(8, 12 - Math.log2(scaleFactor) * 2) + 'px' - element.appendChild(textElement) - return element - } - - for (let gx = topLeft.x; gx <= bottomRight.x; gx++) { - for (let gy = topLeft.y; gy <= bottomRight.y; gy++) { - const leftPx = tileWidthPx > 0 ? ((gx * TileSize - swPoint.x) / tileWidthPx) * TileSize : 0 - const topPx = tileHeightPx > 0 ? ((gy * TileSize - swPoint.y) / tileHeightPx) * TileSize : 0 - const textElement = document.createElement('div') - textElement.classList.add('map-tile-text') - textElement.textContent = `(${gx}, ${gy})` - textElement.style.position = 'absolute' - textElement.style.left = leftPx + 2 + 'px' - textElement.style.top = topPx + 2 + 'px' - if (scaleFactor > 1) { - textElement.style.fontSize = Math.max(8, 12 - Math.log2(scaleFactor) * 2) + 'px' - } - element.appendChild(textElement) - } - } + // One label per Leaflet tile at top-left (2px, 2px); same (x,y) as backend tile for this coords. + const textElement = document.createElement('div') + textElement.classList.add('map-tile-text') + textElement.textContent = `(${topLeft.x}, ${topLeft.y})` + textElement.style.position = 'absolute' + textElement.style.left = '2px' + textElement.style.top = '2px' + textElement.style.fontSize = Math.max(8, 12 - Math.log2(scaleFactor) * 2) + 'px' + element.appendChild(textElement) return element }, }) as unknown as new (options?: GridCoordLayerOptions) => L.GridLayer diff --git a/frontend-nuxt/lib/Marker.ts b/frontend-nuxt/lib/Marker.ts index c01e8b7..22a1116 100644 --- a/frontend-nuxt/lib/Marker.ts +++ b/frontend-nuxt/lib/Marker.ts @@ -1,5 +1,5 @@ +import type L from 'leaflet' import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes' -import * as L from 'leaflet' export interface MarkerData { id: number @@ -48,7 +48,13 @@ export interface MarkerIconOptions { fallbackIconUrl?: string } -export function createMarker(data: MarkerData, iconOptions?: MarkerIconOptions): MapMarker { +export type LeafletApi = typeof import('leaflet') + +export function createMarker( + data: MarkerData, + iconOptions: MarkerIconOptions | undefined, + L: LeafletApi +): MapMarker { let leafletMarker: L.Marker | null = null let onClick: ((e: L.LeafletMouseEvent) => void) | null = null let onContext: ((e: L.LeafletMouseEvent) => void) | null = null diff --git a/internal/app/handlers/tile.go b/internal/app/handlers/tile.go index c21c1a3..ec3ac91 100644 --- a/internal/app/handlers/tile.go +++ b/internal/app/handlers/tile.go @@ -50,13 +50,13 @@ func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) { c := h.Map.WatchTiles() mc := h.Map.WatchMerges() - tileCache := h.Map.GetAllTileCache(ctx) - + // Option 1A: do not send full cache on connect; client requests tiles with cache=0 when missing. + // This avoids a huge JSON dump and slow parse on connect when the DB has many tiles. + tileCache := []services.TileCache{} raw, _ := json.Marshal(tileCache) fmt.Fprint(rw, "data: ") rw.Write(raw) fmt.Fprint(rw, "\n\n") - tileCache = tileCache[:0] flusher.Flush() ticker := time.NewTicker(app.SSETickInterval)