- Updated Makefile to include `--build` flag for `docker-compose.dev.yml` and `--no-cache` for `docker-compose.prod.yml` to ensure fresh builds. - Added new CSS styles for Leaflet tooltips and popups to utilize theme colors, enhancing visual consistency. - Enhanced MapView component with new props for markers and current zoom level, improving marker management and zoom functionality. - Introduced new icons for copy and info actions to improve user interface clarity. - Updated MapBookmarks and MapControls components to support new features and improve user experience with bookmarks and zoom controls. - Refactored MapSearch to display coordinates and improve marker search functionality.
760 lines
27 KiB
Vue
760 lines
27 KiB
Vue
<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"
|
||
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
|
||
:selected-map-id="mapLogic.state.selectedMapId.value"
|
||
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
|
||
:overlay-map-id="mapLogic.state.overlayMapId.value"
|
||
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
|
||
:selected-marker-id="mapLogic.state.selectedMarkerId.value"
|
||
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
|
||
:selected-player-id="mapLogic.state.selectedPlayerId.value"
|
||
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
|
||
: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"
|
||
@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 (1–6) 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>
|