Implement error handling and visual enhancements in map components

- Introduced MapErrorBoundary component to handle map loading errors gracefully.
- Enhanced MapView with a reconnection status indicator for live updates.
- Added tile freshness animation to indicate updated tiles visually.
- Preloaded marker icon images to improve rendering performance.
- Updated various pages to utilize the new MapErrorBoundary for better user experience.
This commit is contained in:
2026-03-01 16:04:19 +03:00
parent 7f990c0c11
commit db0b48774a
9 changed files with 128 additions and 11 deletions

View File

@@ -8,3 +8,18 @@
.leaflet-tile { .leaflet-tile {
visibility: visible !important; visibility: visible !important;
} }
/* Brief highlight when a tile is updated via SSE (tile freshness indicator). */
@keyframes tile-fresh-glow {
0% {
filter: brightness(1.15);
box-shadow: 0 0 0 0 oklch(0.6 0.2 264 / 0.4);
}
100% {
filter: brightness(1);
box-shadow: none;
}
}
.leaflet-tile.tile-fresh {
animation: tile-fresh-glow 0.6s ease-out;
}

View File

@@ -0,0 +1,33 @@
<template>
<div class="relative h-full w-full">
<slot v-if="!caughtError" />
<div
v-else
class="absolute inset-0 z-[600] flex flex-col items-center justify-center gap-4 bg-base-200/95 p-8"
role="alert"
>
<p class="text-center text-lg font-medium">Map failed to load</p>
<p class="text-center text-sm text-base-content/80">
Something went wrong while loading the map. You can try reloading the page.
</p>
<button type="button" class="btn btn-primary" @click="reload">
Reload page
</button>
</div>
</div>
</template>
<script setup lang="ts">
const caughtError = ref<Error | null>(null)
function reload() {
if (import.meta.client) {
window.location.reload()
}
}
onErrorCaptured((err) => {
caughtError.value = err
return true
})
</script>

View File

@@ -32,6 +32,17 @@
:mapid="mapLogic.state.mapid.value" :mapid="mapLogic.state.mapid.value"
:display-coords="mapLogic.state.displayCoords.value" :display-coords="mapLogic.state.displayCoords.value"
/> />
<div
v-if="sseConnectionState === 'error' && mapReady"
class="absolute bottom-4 left-1/2 z-[501] -translate-x-1/2 rounded-lg px-3 py-2 text-sm bg-warning/90 text-warning-content shadow"
role="status"
title="Live updates will resume when reconnected"
>
<span class="inline-flex items-center gap-2">
<span class="inline-block size-2 animate-pulse rounded-full bg-current" aria-hidden="true" />
Reconnecting
</span>
</div>
<div class="absolute top-2 right-2 z-[501] flex flex-col gap-1"> <div class="absolute top-2 right-2 z-[501] flex flex-col gap-1">
<button <button
type="button" type="button"
@@ -116,7 +127,7 @@ import { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletC
import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit' import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
import { useMapNavigate } from '~/composables/useMapNavigate' import { useMapNavigate } from '~/composables/useMapNavigate'
import { useFullscreen } from '~/composables/useFullscreen' import { useFullscreen } from '~/composables/useFullscreen'
import { startMapUpdates, type UseMapUpdatesReturn } from '~/composables/useMapUpdates' import { startMapUpdates, type UseMapUpdatesReturn, type SseConnectionState } from '~/composables/useMapUpdates'
import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers' import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
import type { MapInfo, ConfigResponse, MeResponse } from '~/types/api' import type { MapInfo, ConfigResponse, MeResponse } from '~/types/api'
import type L from 'leaflet' import type L from 'leaflet'
@@ -140,6 +151,7 @@ const { resolvePath } = useAppPaths()
const mapNavigate = useMapNavigate() const mapNavigate = useMapNavigate()
const fullscreen = useFullscreen(mapContainerRef) const fullscreen = useFullscreen(mapContainerRef)
const showShortcutsOverlay = ref(false) const showShortcutsOverlay = ref(false)
const sseConnectionState = ref<SseConnectionState>('connecting')
const measureMode = ref(false) const measureMode = ref(false)
const measurePointA = ref<{ x: number; y: number } | null>(null) const measurePointA = ref<{ x: number; y: number } | null>(null)
const measurePointB = ref<{ x: number; y: number } | null>(null) const measurePointB = ref<{ x: number; y: number } | null>(null)
@@ -330,6 +342,7 @@ onMounted(async () => {
overlayLayer: mapInit.overlayLayer, overlayLayer: mapInit.overlayLayer,
map: leafletMap, map: leafletMap,
getCurrentMapId: () => mapLogic.state.mapid.value, getCurrentMapId: () => mapLogic.state.mapid.value,
connectionStateRef: sseConnectionState,
onMerge: (mapTo: number, shift: { x: number; y: number }) => { onMerge: (mapTo: number, shift: { x: number; y: number }) => {
const latLng = toLatLng(shift.x * 100, shift.y * 100) const latLng = toLatLng(shift.x * 100, shift.y * 100)
layersManager!.changeMap(mapTo) layersManager!.changeMap(mapTo)

View File

@@ -6,6 +6,31 @@ import type { MapInfo } from '~/types/api'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer> type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
/** Known marker icon paths (without .png) to preload so markers render without broken images. */
const MARKER_ICON_PATHS = [
'gfx/terobjs/mm/custom',
'gfx/terobjs/mm/tower',
'gfx/terobjs/mm/village',
'gfx/terobjs/mm/dungeon',
'gfx/terobjs/mm/cave',
'gfx/terobjs/mm/settlement',
'gfx/invobjs/small/bush',
'gfx/invobjs/small/bumling',
]
/**
* Preloads marker icon images so they are in the browser cache before markers render.
* Call from client only. resolvePath should produce absolute URLs for static assets.
*/
export function preloadMarkerIcons(resolvePath: (path: string) => string): void {
if (import.meta.server) return
for (const base of MARKER_ICON_PATHS) {
const url = resolvePath(`${base}.png`)
const img = new Image()
img.src = url
}
}
export interface MapInitResult { export interface MapInitResult {
map: L.Map map: L.Map
layer: SmartTileLayerInstance layer: SmartTileLayerInstance
@@ -87,5 +112,11 @@ export async function initLeafletMap(
const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/' const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/'
L.Icon.Default.imagePath = markerIconPath L.Icon.Default.imagePath = markerIconPath
const resolvePath = (path: string) => {
const p = path.startsWith('/') ? path : `/${path}`
return baseURL === '/' ? p : `${baseURL.replace(/\/$/, '')}${p}`
}
preloadMarkerIcons(resolvePath)
return { map, layer, overlayLayer, coordLayer, markerLayer, backendBase } return { map, layer, overlayLayer, coordLayer, markerLayer, backendBase }
} }

View File

@@ -1,9 +1,12 @@
import type { Ref } from 'vue'
import type { SmartTileLayer } from '~/lib/SmartTileLayer' import type { SmartTileLayer } from '~/lib/SmartTileLayer'
import { TileSize } from '~/lib/LeafletCustomTypes' import { TileSize } from '~/lib/LeafletCustomTypes'
import type L from 'leaflet' import type L from 'leaflet'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer> type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
export type SseConnectionState = 'connecting' | 'open' | 'error'
interface TileUpdate { interface TileUpdate {
M: number M: number
X: number X: number
@@ -25,6 +28,8 @@ export interface UseMapUpdatesOptions {
map: L.Map map: L.Map
getCurrentMapId: () => number getCurrentMapId: () => number
onMerge: (mapTo: number, shift: { x: number; y: number }) => void 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 { export interface UseMapUpdatesReturn {
@@ -32,13 +37,24 @@ export interface UseMapUpdatesReturn {
} }
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn { export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge } = options const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options
const updatesPath = `${backendBase}/updates` const updatesPath = `${backendBase}/updates`
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
const source = new EventSource(updatesUrl) const source = new EventSource(updatesUrl)
if (connectionStateRef) {
connectionStateRef.value = 'connecting'
}
source.onopen = () => {
if (connectionStateRef) connectionStateRef.value = 'open'
}
source.onerror = () => {
if (connectionStateRef) connectionStateRef.value = 'error'
}
source.onmessage = (event: MessageEvent) => { source.onmessage = (event: MessageEvent) => {
if (connectionStateRef) connectionStateRef.value = 'open'
try { try {
const raw: unknown = event?.data const raw: unknown = event?.data
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return if (raw == null || typeof raw !== 'string' || raw.trim() === '') return

View File

@@ -71,8 +71,11 @@ export const SmartTileLayer = L.TileLayer.extend({
const key = `${x}:${y}:${zoom}` const key = `${x}:${y}:${zoom}`
const tile = this._tiles[key] const tile = this._tiles[key]
if (tile) { if (tile?.el) {
tile.el.src = this.getTrueTileUrl({ x, y }, z) tile.el.src = this.getTrueTileUrl({ x, y }, z)
tile.el.classList.add('tile-fresh')
const el = tile.el
setTimeout(() => el.classList.remove('tile-fresh'), 600)
} }
}, },
}) as unknown as new (urlTemplate: string, options?: L.TileLayerOptions) => L.TileLayer & { }) as unknown as new (urlTemplate: string, options?: L.TileLayerOptions) => L.TileLayer & {

View File

@@ -1,7 +1,9 @@
<template> <template>
<div class="h-full min-h-0"> <div class="h-full min-h-0">
<MapPageWrapper> <MapPageWrapper>
<MapErrorBoundary>
<MapView :character-id="characterId" /> <MapView :character-id="characterId" />
</MapErrorBoundary>
</MapPageWrapper> </MapPageWrapper>
</div> </div>
</template> </template>

View File

@@ -1,11 +1,13 @@
<template> <template>
<MapPageWrapper> <MapPageWrapper>
<MapErrorBoundary>
<MapView <MapView
:map-id="mapId" :map-id="mapId"
:grid-x="gridX" :grid-x="gridX"
:grid-y="gridY" :grid-y="gridY"
:zoom="zoom" :zoom="zoom"
/> />
</MapErrorBoundary>
</MapPageWrapper> </MapPageWrapper>
</template> </template>

View File

@@ -1,6 +1,8 @@
<template> <template>
<MapPageWrapper> <MapPageWrapper>
<MapErrorBoundary>
<MapView /> <MapView />
</MapErrorBoundary>
</MapPageWrapper> </MapPageWrapper>
</template> </template>