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.
This commit is contained in:
2026-03-01 17:30:48 +03:00
parent 7bdaa6bfcc
commit 49af08c13f
9 changed files with 120 additions and 75 deletions

View File

@@ -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<ConfigResponse>,
api.me().catch(() => null) as Promise<MeResponse | null>,
])
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?.()
}
}
})

View File

@@ -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)

View File

@@ -15,6 +15,8 @@ import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
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))

View File

@@ -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
}

View File

@@ -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 =
'</svg>'
)
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)
})

View File

@@ -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

View File

@@ -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