Files
Nikolay Tatarinov fd624c2357 Refactor frontend components for improved functionality and accessibility
- Consolidated global error handling in app.vue to redirect users to the login page on API authentication failure.
- Enhanced MapView component by reintroducing event listeners for selected map and marker updates, improving interactivity.
- Updated PasswordInput and various modal components to ensure proper input handling and accessibility compliance.
- Refactored MapControls and MapControlsContent to streamline prop management and enhance user experience.
- Improved error handling in local storage operations within useMapBookmarks and useRecentLocations composables.
- Standardized input elements across forms for consistency in user interaction.
2026-03-04 14:06:27 +03:00

760 lines
27 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div ref="mapContainerRef" class="relative h-full w-full" @click="(e: MouseEvent) => e.button === 0 && mapLogic.closeContextMenus()">
<div
v-if="!mapReady"
class="absolute inset-0 z-[400] flex flex-col items-center justify-center gap-6 bg-base-200/95 p-8"
aria-busy="true"
aria-label="Loading map"
>
<Skeleton class="h-12 w-48" />
<div class="flex gap-3">
<Skeleton class="h-10 w-24" />
<Skeleton class="h-10 w-32" />
<Skeleton class="h-10 w-28" />
</div>
<Skeleton class="h-64 w-72 max-w-full" />
</div>
<div
v-if="mapsLoaded && maps.length === 0"
role="region"
aria-label="No maps available"
class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6"
>
<icons-icon-map-pin class="size-12 text-base-content/40 shrink-0" aria-hidden="true" />
<p class="text-center text-lg">Map list is empty.</p>
<p class="text-center text-sm opacity-80">
Make sure you are logged in and at least one map exists in Admin (uncheck «Hidden» if needed).
</p>
<div class="flex gap-2">
<NuxtLink to="/login" class="btn btn-sm">Login</NuxtLink>
<NuxtLink to="/admin" class="btn btn-sm btn-primary">Admin</NuxtLink>
</div>
</div>
<div ref="mapRef" class="map h-full w-full" />
<MapCoordsDisplay
:mapid="mapLogic.state.mapid.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 flex items-center gap-2"
role="status"
>
<span class="inline-flex items-center gap-2">
<span class="inline-block size-2 animate-pulse rounded-full bg-current" aria-hidden="true" />
Connection lost. Reconnecting
</span>
<button
type="button"
class="btn btn-ghost btn-xs text-warning-content hover:bg-warning-content/20"
@click="reloadPage"
>
Reload
</button>
</div>
<div class="absolute top-2 right-2 z-[501] flex flex-col gap-1">
<button
type="button"
class="btn btn-ghost btn-sm btn-square rounded-lg bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow hover:border-primary/50"
:title="fullscreen.isFullscreen.value ? 'Exit fullscreen' : 'Fullscreen'"
:aria-label="fullscreen.isFullscreen.value ? 'Exit fullscreen' : 'Fullscreen'"
@click="fullscreen.toggle()"
>
<icons-icon-fullscreen-exit v-if="fullscreen.isFullscreen.value" class="size-4" />
<icons-icon-fullscreen v-else class="size-4" />
</button>
<button
type="button"
class="btn btn-ghost btn-sm btn-square rounded-lg bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow hover:border-primary/50"
:class="{ 'border-primary bg-primary/10': measureMode }"
:title="measureMode ? 'Exit measure mode' : 'Measure distance'"
aria-label="Measure distance"
@click="measureMode = !measureMode; if (!measureMode) clearMeasure()"
>
<icons-icon-ruler class="size-4" />
</button>
</div>
<div
v-if="measureMode && mapReady"
class="absolute top-24 right-2 z-[500] rounded-lg px-3 py-2 text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow"
>
<template v-if="measurePointB == null">
<span class="text-base-content/80">{{ measurePointA == null ? 'Click first point on map' : 'Click second point' }}</span>
</template>
<template v-else>
<span class="font-mono">Distance: {{ measureDistance.toFixed(3) }} units</span>
<button type="button" class="btn btn-ghost btn-xs ml-2" @click="clearMeasure">Clear</button>
</template>
</div>
<MapControls
:hide-markers="mapLogic.state.hideMarkers.value"
:selected-map-id="mapLogic.state.selectedMapId.value"
:overlay-map-id="mapLogic.state.overlayMapId.value"
:selected-marker-id="mapLogic.state.selectedMarkerId.value"
:selected-player-id="mapLogic.state.selectedPlayerId.value"
:maps="maps"
:quest-givers="questGivers"
:players="players"
:markers="allMarkers"
:current-zoom="currentZoom"
:current-map-id="mapLogic.state.mapid.value"
:current-coords="mapLogic.state.displayCoords.value"
:selected-marker-for-bookmark="selectedMarkerForBookmark"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
@zoom-in="mapLogic.zoomIn(leafletMap)"
@zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(leafletMap)"
@set-zoom="onSetZoom"
@jump-to-marker="mapLogic.state.selectedMarkerId.value = $event"
/>
<MapContextMenu
:context-menu="mapLogic.contextMenu"
:is-admin="auths.includes('admin')"
@wipe-tile="onWipeTile"
@rewrite-coords="onRewriteCoords"
@hide-marker="onHideMarker"
@add-to-bookmarks="onAddMarkerToBookmarks"
/>
<MapCoordSetModal
:coord-set-from="mapLogic.coordSetFrom.value"
:coord-set="mapLogic.coordSet.value"
:open="mapLogic.coordSetModalOpen.value"
@close="mapLogic.closeCoordSetModal()"
@submit="onSubmitCoordSet"
/>
<MapBookmarkNameModal
:open="bookmarkNameModalOpen"
:default-name="bookmarkNameModalDefaultName"
:title="bookmarkNameModalTitle"
@close="bookmarkNameModalOpen = false"
@submit="onBookmarkNameModalSubmit"
/>
<MapShortcutsOverlay :open="showShortcutsOverlay" @close="showShortcutsOverlay = false" />
</div>
</template>
<script setup lang="ts">
import MapControls from '~/components/map/MapControls.vue'
import MapCoordsDisplay from '~/components/map/MapCoordsDisplay.vue'
import MapContextMenu from '~/components/map/MapContextMenu.vue'
import MapCoordSetModal from '~/components/map/MapCoordSetModal.vue'
import MapBookmarkNameModal from '~/components/map/MapBookmarkNameModal.vue'
import MapShortcutsOverlay from '~/components/map/MapShortcutsOverlay.vue'
import { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
import { useMapNavigate } from '~/composables/useMapNavigate'
import { useFullscreen } from '~/composables/useFullscreen'
import { startMapUpdates, type UseMapUpdatesReturn, type SseConnectionState } from '~/composables/useMapUpdates'
import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
import type { MapInfo, ConfigResponse, MeResponse, Marker as ApiMarker } from '~/types/api'
import type L from 'leaflet'
const props = withDefaults(
defineProps<{
characterId?: number
mapId?: number
gridX?: number
gridY?: number
zoom?: number
}>(),
{ characterId: -1, mapId: undefined, gridX: 0, gridY: 0, zoom: 1 }
)
const mapContainerRef = ref<HTMLElement | null>(null)
const mapRef = ref<HTMLElement | null>(null)
const api = useMapApi()
const toast = useToast()
/** Global state for navbar Live/Offline: true when at least one character is owned by current user's tokens. */
const mapLive = useState<boolean>('mapLive', () => false)
const mapLogic = useMapLogic()
const mapBookmarks = useMapBookmarks()
const { resolvePath } = useAppPaths()
/** Payload for bookmark name modal: add new bookmark or edit existing by id. */
type BookmarkModalPayload =
| { kind: 'add'; mapId: number; x: number; y: number; zoom?: number }
| { kind: 'edit'; editId: string }
const bookmarkNameModalOpen = ref(false)
const bookmarkNameModalDefaultName = ref('')
const bookmarkNameModalTitle = ref('Add bookmark')
const bookmarkNameModalPendingData = ref<BookmarkModalPayload | null>(null)
function openBookmarkModal(defaultName: string, title: string, data: BookmarkModalPayload) {
bookmarkNameModalDefaultName.value = defaultName
bookmarkNameModalTitle.value = title
bookmarkNameModalPendingData.value = data
bookmarkNameModalOpen.value = true
}
function onBookmarkNameModalSubmit(name: string) {
const data = bookmarkNameModalPendingData.value
bookmarkNameModalOpen.value = false
bookmarkNameModalPendingData.value = null
if (!data) return
if (data.kind === 'add') {
mapBookmarks.add({ name, mapId: data.mapId, x: data.x, y: data.y, zoom: data.zoom })
} else {
mapBookmarks.update(data.editId, { name })
}
}
function onAddMarkerToBookmarks() {
mapLogic.closeContextMenus()
const id = mapLogic.contextMenu.marker.data?.id
if (id == null || !layersManager) return
const marker = layersManager.findMarkerById(id)
if (!marker) return
const x = Math.floor(marker.position.x / TileSize)
const y = Math.floor(marker.position.y / TileSize)
openBookmarkModal(marker.name, 'Add bookmark', {
kind: 'add',
mapId: marker.map,
x,
y,
})
}
provide('openBookmarkModal', openBookmarkModal)
const mapNavigate = useMapNavigate()
const fullscreen = useFullscreen(mapContainerRef)
const showShortcutsOverlay = ref(false)
const sseConnectionState = ref<SseConnectionState>('connecting')
const measureMode = ref(false)
const measurePointA = ref<{ x: number; y: number } | null>(null)
const measurePointB = ref<{ x: number; y: number } | null>(null)
const measureLayer = ref<L.LayerGroup | null>(null)
const measureDistance = computed(() => {
const a = measurePointA.value
const b = measurePointB.value
if (!a || !b) return 0
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2)
})
function clearMeasure() {
measurePointA.value = null
measurePointB.value = null
if (measureLayer.value && leafletMap) {
measureLayer.value.clearLayers()
}
}
/** Fallback marker icon (simple pin) when the real icon image fails to load. */
const FALLBACK_MARKER_ICON =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"><path fill="%236366f1" stroke="%234f46e5" stroke-width="1.5" d="M16 4c-4 0-7 3-7 7 0 5 7 13 7 13s7-8 7-13c0-4-3-7-7-7z"/><circle cx="16" cy="11" r="3" fill="white"/></svg>'
)
const mapReady = ref(false)
const maps = ref<MapInfo[]>([])
const mapsLoaded = ref(false)
const questGivers = ref<Array<{ id: number; name: string }>>([])
const players = ref<Array<{ id: number; name: string }>>([])
/** All markers from API for search suggestions (updated when markers load or on merge). */
const allMarkers = ref<ApiMarker[]>([])
/** Current map zoom level (16) for zoom slider. Updated on zoomend. */
const currentZoom = ref(HnHDefaultZoom)
/** Single source of truth: layout updates me, we derive auths for context menu. */
const me = useState<MeResponse | null>('me', () => null)
const auths = computed(() => me.value?.auths ?? [])
let leafletMap: L.Map | null = null
let mapInit: MapInitResult | null = null
let updatesHandle: UseMapUpdatesReturn | null = null
let layersManager: MapLayersManager | null = null
/** Selected marker as bookmark target: grid coords + name, or null. Passed to MapControls → MapBookmarks. */
const selectedMarkerForBookmark = ref<{ mapId: number; x: number; y: number; name: string } | null>(null)
function updateSelectedMarkerForBookmark() {
const id = mapLogic.state.selectedMarkerId.value
if (id == null || !layersManager) {
selectedMarkerForBookmark.value = null
return
}
const marker = layersManager.findMarkerById(id)
if (!marker) {
selectedMarkerForBookmark.value = null
return
}
selectedMarkerForBookmark.value = {
mapId: marker.map,
x: Math.floor(marker.position.x / TileSize),
y: Math.floor(marker.position.y / TileSize),
name: marker.name,
}
}
let intervalId: ReturnType<typeof setInterval> | null = null
let characterPollFirstTickId: ReturnType<typeof setTimeout> | null = null
let autoMode = false
let mapContainer: HTMLElement | null = null
let contextMenuHandler: ((ev: MouseEvent) => void) | null = null
let mounted = false
let visibilityChangeHandler: (() => void) | null = null
function toLatLng(x: number, y: number) {
return leafletMap!.unproject([x, y], HnHMaxZoom)
}
async function onWipeTile(coords: { x: number; y: number } | undefined) {
if (!coords) return
mapLogic.closeContextMenus()
try {
await api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y })
toast.success('Tile wiped')
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to wipe tile'
toast.error(msg === 'Forbidden' ? 'No permission' : msg)
}
}
function onRewriteCoords(coords: { x: number; y: number } | undefined) {
if (!coords) return
mapLogic.closeContextMenus()
mapLogic.openCoordSet(coords)
}
async function onHideMarker(id: number | undefined) {
if (id == null) return
mapLogic.closeContextMenus()
try {
await api.adminHideMarker({ id })
const m = layersManager?.findMarkerById(id)
if (m) m.remove({ map: leafletMap!, markerLayer: mapInit!.markerLayer, mapid: mapLogic.state.mapid.value })
toast.success('Marker hidden')
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to hide marker'
toast.error(msg === 'Forbidden' ? 'No permission' : msg)
}
}
async function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
try {
await api.adminSetCoords({
map: mapLogic.state.mapid.value,
fx: from.x,
fy: from.y,
tx: to.x,
ty: to.y,
})
mapLogic.closeCoordSetModal()
toast.success('Coordinates updated')
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to set coordinates'
toast.error(msg === 'Forbidden' ? 'No permission' : msg)
}
}
function reloadPage() {
if (import.meta.client) window.location.reload()
}
function onSetZoom(z: number) {
if (leafletMap) leafletMap.setZoom(z)
}
function onKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement
const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')
if (e.key === 'Escape') {
if (showShortcutsOverlay.value) showShortcutsOverlay.value = false
else mapLogic.closeContextMenus()
return
}
if (showShortcutsOverlay.value) return
if (e.key === '?') {
showShortcutsOverlay.value = true
e.preventDefault()
return
}
if (inInput) return
if (e.key === 'H') {
mapLogic.state.hideMarkers.value = !mapLogic.state.hideMarkers.value
e.preventDefault()
return
}
if (e.key === 'f' || e.key === 'F') {
mapNavigate.focusSearch()
e.preventDefault()
return
}
if (e.key === '+' || e.key === '=') {
mapLogic.zoomIn(leafletMap)
e.preventDefault()
return
}
if (e.key === '-') {
mapLogic.zoomOutControl(leafletMap)
e.preventDefault()
return
}
}
onMounted(async () => {
mounted = true
if (import.meta.client) {
window.addEventListener('keydown', onKeydown)
}
if (!import.meta.client || !mapRef.value) return
const L = (await import('leaflet')).default
// Load maps, characters and config in parallel. User (me) is loaded by layout.
const [charactersData, mapsData, config] = 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>,
])
const mapsList: MapInfo[] = []
const raw = mapsData as Record<
string,
{ ID?: number; Name?: string; id?: number; name?: string; size?: number; Hidden?: boolean; hidden?: boolean }
>
for (const id in raw) {
const m = raw[id]
if (!m || typeof m !== 'object') continue
const idVal = m.ID ?? m.id
const nameVal = m.Name ?? m.name
if (idVal == null || nameVal == null) continue
const hidden = !!(m.Hidden ?? m.hidden)
if (hidden) continue
mapsList.push({ ID: Number(idVal), Name: String(nameVal), size: m.size })
}
mapsList.sort((a, b) => (b.size ?? 0) - (a.size ?? 0))
maps.value = mapsList
mapsLoaded.value = true
if (config?.title) document.title = config.title
const initialMapId =
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
mapLogic.state.mapid.value = initialMapId
mapInit = await initLeafletMap(mapRef.value, mapsList, initialMapId)
leafletMap = mapInit.map
// Document-level capture so we get contextmenu before any map layer or iframe can swallow it
mapContainer = leafletMap.getContainer()
contextMenuHandler = (ev: MouseEvent) => {
const target = ev.target as Node
if (!mapContainer?.contains(target)) return
// Right-click on a marker: let the marker's context menu open instead of tile menu
if ((target as Element).closest?.('.leaflet-marker-icon')) return
const isAdmin = auths.value.includes('admin')
if (isAdmin) {
ev.preventDefault()
ev.stopPropagation()
const rect = mapContainer.getBoundingClientRect()
const containerPoint = L.point(ev.clientX - rect.left, ev.clientY - rect.top)
const latlng = leafletMap!.containerPointToLatLng(containerPoint)
const point = leafletMap!.project(latlng, 6)
const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) }
mapLogic.openTileContextMenu(ev.clientX, ev.clientY, coords)
}
}
document.addEventListener('contextmenu', contextMenuHandler, true)
layersManager = createMapLayers({
L,
map: leafletMap,
markerLayer: mapInit.markerLayer,
layer: mapInit.layer,
overlayLayer: mapInit.overlayLayer,
getCurrentMapId: () => mapLogic.state.mapid.value,
setCurrentMapId: (id: number) => { mapLogic.state.mapid.value = id },
setSelectedMapId: (id: number) => { mapLogic.state.selectedMapId.value = id },
getAuths: () => auths.value,
getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
onMarkerContextMenu: mapLogic.openMarkerContextMenu,
onAddMarkerToBookmark: (markerId, getMarkerById) => {
const m = getMarkerById(markerId)
if (!m) return
openBookmarkModal(m.name, 'Add bookmark', {
kind: 'add',
mapId: m.map,
x: Math.floor(m.position.x / TileSize),
y: Math.floor(m.position.y / TileSize),
})
},
resolveIconUrl: (path) => resolvePath(path),
fallbackIconUrl: FALLBACK_MARKER_ICON,
})
updatesHandle = startMapUpdates({
backendBase: mapInit.backendBase,
layer: mapInit.layer,
overlayLayer: mapInit.overlayLayer,
map: leafletMap,
getCurrentMapId: () => mapLogic.state.mapid.value,
connectionStateRef: sseConnectionState,
onMerge: (mapTo: number, shift: { x: number; y: number }) => {
if (!mounted) return
const latLng = toLatLng(shift.x * 100, shift.y * 100)
layersManager!.changeMap(mapTo)
api.getMarkers().then((body) => {
if (!mounted) return
const list = Array.isArray(body) ? body : []
allMarkers.value = list
layersManager!.updateMarkers(list)
questGivers.value = layersManager!.getQuestGivers()
})
leafletMap!.setView(latLng, leafletMap!.getZoom())
},
})
const { shouldInvalidateTileCache, clearRebuildDoneFlag } = useRebuildZoomsInvalidation()
if (shouldInvalidateTileCache()) {
mapInit.layer.cache = {}
mapInit.overlayLayer.cache = {}
clearRebuildDoneFlag()
nextTick(() => {
mapInit.layer.redraw()
mapInit.overlayLayer.redraw()
})
}
const charsList = Array.isArray(charactersData) ? charactersData : []
layersManager.updateCharacters(charsList)
players.value = layersManager.getPlayers()
mapLive.value = charsList.some((c) => c.ownedByMe)
if (props.characterId !== undefined && props.characterId >= 0) {
mapLogic.state.trackingCharacterId.value = props.characterId
} else if (props.mapId != null && props.gridX != null && props.gridY != null && props.zoom != null) {
const latLng = toLatLng(props.gridX * 100, props.gridY * 100)
if (mapLogic.state.mapid.value !== props.mapId) layersManager.changeMap(props.mapId)
mapLogic.state.selectedMapId.value = props.mapId
leafletMap.setView(latLng, props.zoom, { animate: false })
} else if (mapsList.length > 0) {
const first = mapsList[0]
if (first) {
layersManager.changeMap(first.ID)
mapLogic.state.selectedMapId.value = first.ID
leafletMap.setView([0, 0], HnHDefaultZoom, { animate: false })
}
}
// Show map as soon as canvas and layers are ready; markers load in background.
if (leafletMap) leafletMap.invalidateSize()
nextTick(() => {
requestAnimationFrame(() => {
if (leafletMap) leafletMap.invalidateSize()
mapReady.value = true
})
})
// Markers load asynchronously after map is visible.
api.getMarkers().then((body) => {
if (!mounted) return
const list = Array.isArray(body) ? body : []
allMarkers.value = list
layersManager!.updateMarkers(list)
questGivers.value = layersManager!.getQuestGivers()
updateSelectedMarkerForBookmark()
})
const CHARACTER_POLL_MS = 4000
const CHARACTER_POLL_MS_HIDDEN = 30000
function pollCharacters() {
api
.getCharacters()
.then((body) => {
if (!mounted) return
const list = Array.isArray(body) ? body : []
layersManager!.updateCharacters(list)
players.value = layersManager!.getPlayers()
mapLive.value = list.some((c) => c.ownedByMe)
})
.catch(() => {
if (intervalId) clearInterval(intervalId)
intervalId = null
})
}
function startCharacterPoll() {
if (characterPollFirstTickId) clearTimeout(characterPollFirstTickId)
characterPollFirstTickId = null
if (intervalId) clearInterval(intervalId)
intervalId = null
const ms =
typeof document !== 'undefined' && document.visibilityState === 'hidden'
? CHARACTER_POLL_MS_HIDDEN
: CHARACTER_POLL_MS
// First tick after delay to avoid double-fetch with initial getCharacters() in Promise.all
characterPollFirstTickId = setTimeout(() => {
characterPollFirstTickId = null
pollCharacters()
intervalId = setInterval(pollCharacters, ms)
}, ms)
}
startCharacterPoll()
if (import.meta.client) {
visibilityChangeHandler = () => {
startCharacterPoll()
}
document.addEventListener('visibilitychange', visibilityChangeHandler)
}
watch(
() => fullscreen.isFullscreen.value,
() => {
if (!mounted) return
nextTick(() => {
requestAnimationFrame(() => {
if (mounted && leafletMap) leafletMap.invalidateSize()
})
})
}
)
watch(mapLogic.state.hideMarkers, (v) => {
if (!mounted || !layersManager) return
layersManager.refreshMarkersVisibility(v)
})
watch(mapLogic.state.trackingCharacterId, (value) => {
if (!mounted || !leafletMap || !layersManager) return
if (value === -1) return
const character = layersManager.findCharacterById(value)
if (character) {
layersManager.changeMap(character.map)
const latlng = leafletMap.unproject([character.position.x, character.position.y], HnHMaxZoom)
leafletMap.setView(latlng, HnHMaxZoom)
autoMode = true
} else {
leafletMap.setView([0, 0], HnHMinZoom)
mapLogic.state.trackingCharacterId.value = -1
}
})
watch(mapLogic.state.selectedMapId, (value) => {
if (!mounted || !leafletMap || !layersManager) return
if (value == null) return
layersManager.changeMap(value)
const zoom = leafletMap.getZoom()
leafletMap.setView([0, 0], zoom)
})
watch(mapLogic.state.overlayMapId, (value) => {
if (!mounted || !layersManager) return
layersManager.refreshOverlayMarkers(value ?? -1)
})
watch(mapLogic.state.selectedMarkerId, (value) => {
if (!mounted) return
updateSelectedMarkerForBookmark()
if (!leafletMap || !layersManager) return
if (value == null) return
const marker = layersManager.findMarkerById(value)
if (marker?.leafletMarker) leafletMap.setView(marker.leafletMarker.getLatLng(), leafletMap.getZoom())
})
watch(mapLogic.state.selectedPlayerId, (value) => {
if (!mounted) return
if (value != null) mapLogic.state.trackingCharacterId.value = value
})
mapNavigate.setGoTo((mapId: number, x: number, y: number, zoom?: number) => {
if (!mounted || !leafletMap) return
if (mapLogic.state.mapid.value !== mapId && layersManager) {
layersManager.changeMap(mapId)
mapLogic.state.selectedMapId.value = mapId
}
const latLng = toLatLng(x * TileSize, y * TileSize)
leafletMap.setView(latLng, zoom ?? leafletMap.getZoom(), { animate: true })
})
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
mapLogic.updateDisplayCoords(leafletMap)
currentZoom.value = leafletMap.getZoom()
leafletMap.on('zoomend', () => {
if (leafletMap) currentZoom.value = leafletMap.getZoom()
})
leafletMap.on('drag', () => {
mapLogic.state.trackingCharacterId.value = -1
})
leafletMap.on('zoom', () => {
if (autoMode) {
autoMode = false
} else {
mapLogic.state.trackingCharacterId.value = -1
}
})
const measureLayerGroup = L.layerGroup().addTo(leafletMap)
measureLayerGroup.setZIndex(550)
measureLayer.value = measureLayerGroup
leafletMap.on('click', (e: L.LeafletMouseEvent) => {
if (!measureMode.value) return
e.originalEvent.stopPropagation()
const point = leafletMap!.project(e.latlng, HnHMaxZoom)
const x = point.x / TileSize
const y = point.y / TileSize
if (measurePointA.value == null) {
measurePointA.value = { x, y }
} else if (measurePointB.value == null) {
measurePointB.value = { x, y }
} else {
measurePointA.value = { x, y }
measurePointB.value = null
}
})
watch([measurePointA, measurePointB], () => {
if (!mounted || !leafletMap) return
const layer = measureLayer.value
if (!layer) return
layer.clearLayers()
const a = measurePointA.value
const b = measurePointB.value
if (!a) return
const latLngA = toLatLng(a.x * TileSize, a.y * TileSize)
L.circleMarker(latLngA, { radius: 6, color: '#6366f1', fillColor: '#6366f1', fillOpacity: 0.8, weight: 2 }).addTo(layer)
if (b) {
const latLngB = toLatLng(b.x * TileSize, b.y * TileSize)
L.circleMarker(latLngB, { radius: 6, color: '#6366f1', fillColor: '#6366f1', fillOpacity: 0.8, weight: 2 }).addTo(layer)
L.polyline([latLngA, latLngB], { color: '#6366f1', weight: 2, opacity: 0.9 }).addTo(layer)
}
})
})
onBeforeUnmount(() => {
mounted = false
if (import.meta.client) {
window.removeEventListener('keydown', onKeydown)
if (visibilityChangeHandler) {
document.removeEventListener('visibilitychange', visibilityChangeHandler)
}
}
mapNavigate.setGoTo(null)
if (contextMenuHandler) {
document.removeEventListener('contextmenu', contextMenuHandler, true)
}
if (characterPollFirstTickId) clearTimeout(characterPollFirstTickId)
if (intervalId) clearInterval(intervalId)
updatesHandle?.cleanup()
if (leafletMap) leafletMap.remove()
})
</script>
<style scoped>
.map {
height: 100%;
min-height: 400px;
}
:deep(.leaflet-container .leaflet-tile-pane img.leaflet-tile) {
mix-blend-mode: normal;
visibility: visible !important;
}
</style>