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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
// Markers load asynchronously after map is visible.
|
||||
api.getMarkers().then((body) => {
|
||||
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
|
||||
questGivers.value = layersManager!.getQuestGivers()
|
||||
})
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
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)
|
||||
|
||||
api.getMarkers().then((body) => {
|
||||
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
|
||||
questGivers.value = layersManager!.getQuestGivers()
|
||||
.catch(() => {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
intervalId = null
|
||||
})
|
||||
}
|
||||
|
||||
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?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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,13 +36,8 @@ 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)
|
||||
// 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})`
|
||||
@@ -48,25 +47,6 @@ export const GridCoordLayer = L.GridLayer.extend({
|
||||
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)
|
||||
}
|
||||
}
|
||||
return element
|
||||
},
|
||||
}) as unknown as new (options?: GridCoordLayerOptions) => L.GridLayer
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user