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:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
33
frontend-nuxt/components/MapErrorBoundary.vue
Normal file
33
frontend-nuxt/components/MapErrorBoundary.vue
Normal 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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0">
|
<div class="h-full min-h-0">
|
||||||
<MapPageWrapper>
|
<MapPageWrapper>
|
||||||
<MapView :character-id="characterId" />
|
<MapErrorBoundary>
|
||||||
|
<MapView :character-id="characterId" />
|
||||||
|
</MapErrorBoundary>
|
||||||
</MapPageWrapper>
|
</MapPageWrapper>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<MapPageWrapper>
|
<MapPageWrapper>
|
||||||
<MapView
|
<MapErrorBoundary>
|
||||||
:map-id="mapId"
|
<MapView
|
||||||
:grid-x="gridX"
|
:map-id="mapId"
|
||||||
:grid-y="gridY"
|
:grid-x="gridX"
|
||||||
:zoom="zoom"
|
:grid-y="gridY"
|
||||||
/>
|
:zoom="zoom"
|
||||||
|
/>
|
||||||
|
</MapErrorBoundary>
|
||||||
</MapPageWrapper>
|
</MapPageWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<MapPageWrapper>
|
<MapPageWrapper>
|
||||||
<MapView />
|
<MapErrorBoundary>
|
||||||
|
<MapView />
|
||||||
|
</MapErrorBoundary>
|
||||||
</MapPageWrapper>
|
</MapPageWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user