Files
hnh-map/frontend-nuxt/components/MapView.vue
Nikolay Tatarinov 82cb8a13f5 Update project documentation and improve frontend functionality
- Updated the backend documentation in CONTRIBUTING.md and README.md to reflect changes in application structure and API endpoints.
- Enhanced the frontend components in MapView.vue for better handling of context menu actions.
- Added new types and interfaces in TypeScript for improved type safety in the frontend.
- Introduced new utility classes for managing characters and markers in the map.
- Updated .gitignore to include .vscode directory for better development environment management.
2026-02-24 23:32:50 +03:00

653 lines
24 KiB
Vue
Raw 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 class="relative h-full w-full" @click="contextMenu.tile.show = false; contextMenu.marker.show = false">
<div
v-if="mapsLoaded && maps.length === 0"
class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6"
>
<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" />
<!-- Grid coords & zoom (bottom-right) -->
<div
v-if="displayCoords"
class="absolute bottom-2 right-2 z-[501] rounded bg-base-100/90 px-2 py-1 font-mono text-xs shadow"
aria-label="Current grid position and zoom"
>
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
</div>
<!-- Control panel -->
<div class="absolute left-3 top-[10%] z-[502] card card-compact bg-base-100 shadow-xl w-56">
<div class="card-body p-3 gap-2">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1">
<button
type="button"
class="btn btn-neutral btn-sm btn-square"
title="Zoom in"
aria-label="Zoom in"
@click="zoomIn"
>
+
</button>
<button
type="button"
class="btn btn-neutral btn-sm btn-square"
title="Zoom out"
aria-label="Zoom out"
@click="zoomOutControl"
>
</button>
</div>
<label class="label cursor-pointer justify-start gap-2 py-0">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Show grid coordinates</span>
</label>
<label class="label cursor-pointer justify-start gap-2 py-0">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Hide markers</span>
</label>
<button
type="button"
class="btn btn-neutral btn-sm"
title="Center map and minimum zoom"
@click="zoomOut"
>
Reset view
</button>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Map</span></label>
<select v-model="selectedMapId" class="select select-bordered select-sm w-full">
<option :value="null">Select map</option>
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Overlay Map</span></label>
<select v-model="overlayMapId" class="select select-bordered select-sm w-full">
<option :value="-1">None</option>
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Quest Giver</span></label>
<select v-model="selectedMarkerId" class="select select-bordered select-sm w-full">
<option :value="null">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Player</span></label>
<select v-model="selectedPlayerId" class="select select-bordered select-sm w-full">
<option :value="null">Select player</option>
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- Context menu (tile) -->
<div
v-show="contextMenu.tile.show"
class="fixed z-[1000] bg-base-100 shadow-lg rounded-lg border border-base-300 py-1 min-w-[180px]"
:style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }"
>
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="contextMenu.tile.data && (wipeTile(contextMenu.tile.data), (contextMenu.tile.show = false))">
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
</button>
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="contextMenu.tile.data && (openCoordSet(contextMenu.tile.data), (contextMenu.tile.show = false))">
Rewrite tile coords
</button>
</div>
<!-- Context menu (marker) -->
<div
v-show="contextMenu.marker.show"
class="fixed z-[1000] bg-base-100 shadow-lg rounded-lg border border-base-300 py-1 min-w-[180px]"
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
>
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="contextMenu.marker.data?.id != null && (hideMarkerById(contextMenu.marker.data.id), (contextMenu.marker.show = false))">
Hide marker {{ contextMenu.marker.data?.name }}
</button>
</div>
<!-- Coord-set modal: close via Cancel, backdrop click, or Escape -->
<dialog ref="coordSetModal" class="modal" @cancel="closeCoordSetModal">
<div class="modal-box" @click.stop>
<h3 class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2">
<input v-model.number="coordSet.x" type="number" class="input input-bordered flex-1" placeholder="X" />
<input v-model.number="coordSet.y" type="number" class="input input-bordered flex-1" placeholder="Y" />
</div>
<div class="modal-action">
<form method="dialog" @submit="submitCoordSet">
<button type="submit" class="btn btn-primary">Submit</button>
<button type="button" class="btn" @click="closeCoordSetModal">Cancel</button>
</form>
</div>
</div>
<div class="modal-backdrop cursor-pointer" aria-label="Close" @click="closeCoordSetModal" />
</dialog>
</div>
</template>
<script setup lang="ts">
import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { SmartTileLayer } from '~/lib/SmartTileLayer'
import { Marker } from '~/lib/Marker'
import { Character } from '~/lib/Character'
import { UniqueList } from '~/lib/UniqueList'
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 mapRef = ref<HTMLElement | null>(null)
const route = useRoute()
const api = useMapApi()
const showGridCoordinates = ref(false)
const hideMarkers = ref(false)
const trackingCharacterId = ref(-1)
const maps = ref<{ ID: number; Name: string; size?: number }[]>([])
const mapsLoaded = ref(false)
const selectedMapId = ref<number | null>(null)
const overlayMapId = ref<number>(-1)
const questGivers = ref<{ id: number; name: string; marker?: any }[]>([])
const players = ref<{ id: number; name: string }[]>([])
const selectedMarkerId = ref<number | null>(null)
const selectedPlayerId = ref<number | null>(null)
const auths = ref<string[]>([])
const coordSetFrom = ref({ x: 0, y: 0 })
const coordSet = ref({ x: 0, y: 0 })
const coordSetModal = ref<HTMLDialogElement | null>(null)
const displayCoords = ref<{ x: number; y: number; z: number } | null>(null)
const contextMenu = reactive({
tile: { show: false, x: 0, y: 0, data: null as { coords: { x: number; y: number } } | null },
marker: { show: false, x: 0, y: 0, data: null as { id: number; name: string } | null },
})
let map: L.Map | null = null
let layer: InstanceType<typeof SmartTileLayer> | null = null
let overlayLayer: InstanceType<typeof SmartTileLayer> | null = null
let coordLayer: InstanceType<typeof GridCoordLayer> | null = null
let markerLayer: L.LayerGroup | null = null
let source: EventSource | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let mapid = 0
let markers: UniqueList<InstanceType<typeof Marker>> | null = null
let characters: UniqueList<InstanceType<typeof Character>> | null = null
let markersHidden = false
let autoMode = false
function toLatLng(x: number, y: number) {
return map!.unproject([x, y], HnHMaxZoom)
}
function changeMap(id: number) {
if (id === mapid) return
mapid = id
if (layer) {
layer.map = mapid
layer.redraw()
}
if (overlayLayer) {
overlayLayer.map = -1
overlayLayer.redraw()
}
if (markers && !markersHidden) {
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid }))
markers.getElements().filter((it: any) => it.map === mapid).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid }))
}
if (characters) {
characters.getElements().forEach((it: any) => {
it.remove({ map: map! })
it.add({ map: map!, mapid })
})
}
}
function zoomIn() {
map?.zoomIn()
}
function zoomOutControl() {
map?.zoomOut()
}
function zoomOut() {
trackingCharacterId.value = -1
map?.setView([0, 0], HnHMinZoom, { animate: false })
}
function wipeTile(data: { coords: { x: number; y: number } }) {
if (!data?.coords) return
api.adminWipeTile({ map: mapid, x: data.coords.x, y: data.coords.y })
}
function closeCoordSetModal() {
coordSetModal.value?.close()
}
function openCoordSet(data: { coords: { x: number; y: number } }) {
if (!data?.coords) return
coordSetFrom.value = { ...data.coords }
coordSet.value = { x: data.coords.x, y: data.coords.y }
coordSetModal.value?.showModal()
}
function submitCoordSet() {
api.adminSetCoords({
map: mapid,
fx: coordSetFrom.value.x,
fy: coordSetFrom.value.y,
tx: coordSet.value.x,
ty: coordSet.value.y,
})
coordSetModal.value?.close()
}
function hideMarkerById(id: number) {
if (id == null) return
api.adminHideMarker({ id })
const m = markers?.byId(id)
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid })
}
function closeContextMenus() {
contextMenu.tile.show = false
contextMenu.marker.show = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeContextMenus()
}
onMounted(async () => {
if (import.meta.client) {
window.addEventListener('keydown', onKeydown)
}
if (!import.meta.client || !mapRef.value) return
const L = (await import('leaflet')).default
const [charactersData, mapsData] = await Promise.all([
api.getCharacters().then((d) => (Array.isArray(d) ? d : [])).catch(() => []),
api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})),
])
const mapsList: { ID: number; Name: string; size?: number }[] = []
const raw = mapsData as Record<string, { ID?: number; Name?: string; id?: number; name?: string; size?: number }>
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
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
const config = (await api.getConfig().catch(() => ({}))) as { title?: string; auths?: string[] }
if (config?.title) document.title = config.title
if (config?.auths) auths.value = config.auths
map = L.map(mapRef.value, {
minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom,
crs: HnHCRS,
attributionControl: false,
zoomControl: false,
inertia: true,
zoomAnimation: true,
fadeAnimation: true,
markerZoomAnimation: true,
})
const initialMapId =
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
mapid = initialMapId
const tileBase = (useRuntimeConfig().app.baseURL as string) ?? '/'
const tileUrl = tileBase.endsWith('/') ? `${tileBase}grids/{map}/{z}/{x}_{y}.png?{cache}` : `${tileBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
layer = new SmartTileLayer(tileUrl, {
minZoom: 1,
maxZoom: 6,
zoomOffset: 0,
zoomReverse: true,
tileSize: TileSize,
updateWhenIdle: true,
keepBuffer: 2,
}) as any
layer!.map = initialMapId
layer!.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
layer!.addTo(map)
overlayLayer = new SmartTileLayer(tileUrl, {
minZoom: 1,
maxZoom: 6,
zoomOffset: 0,
zoomReverse: true,
tileSize: TileSize,
opacity: 0.5,
updateWhenIdle: true,
keepBuffer: 2,
}) as any
overlayLayer!.map = -1
overlayLayer!.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
overlayLayer!.addTo(map)
coordLayer = new GridCoordLayer({
tileSize: TileSize,
minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom,
opacity: 0,
visible: false,
} as any)
coordLayer.addTo(map)
coordLayer.setZIndex(500)
markerLayer = L.layerGroup()
markerLayer.addTo(map)
markerLayer.setZIndex(600)
const baseURL = useRuntimeConfig().app.baseURL ?? '/'
const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/'
L.Icon.Default.imagePath = markerIconPath
map.on('contextmenu', (mev: L.LeafletMouseEvent) => {
if (auths.value.includes('admin')) {
const point = map!.project(mev.latlng, 6)
const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) }
contextMenu.tile.show = true
contextMenu.tile.x = mev.originalEvent.clientX
contextMenu.tile.y = mev.originalEvent.clientY
contextMenu.tile.data = { coords }
}
})
// Use same origin as page so SSE connects to correct host/port (e.g. 3080 not 3088)
const base = (useRuntimeConfig().app.baseURL as string) ?? '/'
const updatesPath = base.endsWith('/') ? `${base}updates` : `${base}/updates`
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
source = new EventSource(updatesUrl)
source.onmessage = (event: MessageEvent) => {
try {
const raw = event?.data
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
const updates = JSON.parse(raw)
if (!Array.isArray(updates)) return
for (const u of updates) {
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
layer!.cache[key] = u.T
if (overlayLayer) overlayLayer.cache[key] = u.T
if (layer!.map === u.M) layer!.refresh(u.X, u.Y, u.Z)
if (overlayLayer && overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
}
// After initial batch (or any batch), redraw so tiles re-request with filled cache
if (updates.length > 0 && layer) {
layer.redraw()
if (overlayLayer) overlayLayer.redraw()
}
} catch {
// Ignore parse errors (e.g. empty SSE message or non-JSON)
}
}
source.onerror = () => {
// Connection lost or 401; avoid uncaught errors
}
source.addEventListener('merge', (e: MessageEvent) => {
try {
const merge = JSON.parse(e?.data ?? '{}')
if (mapid === merge.From) {
const mapTo = merge.To
const point = map!.project(map!.getCenter(), 6)
const coordinate = {
x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y,
z: map!.getZoom(),
}
const latLng = toLatLng(coordinate.x * 100, coordinate.y * 100)
changeMap(mapTo)
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
map!.setView(latLng, map!.getZoom())
}
} catch {
// Ignore merge parse errors
}
})
markers = new UniqueList<InstanceType<typeof Marker>>()
characters = new UniqueList<InstanceType<typeof Character>>()
updateCharacters(charactersData as any[])
if (props.characterId !== undefined && props.characterId >= 0) {
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 (mapid !== props.mapId) changeMap(props.mapId)
selectedMapId.value = props.mapId
map.setView(latLng, props.zoom)
} else if (mapsList.length > 0) {
const first = mapsList[0]
if (first) {
changeMap(first.ID)
selectedMapId.value = first.ID
map.setView([0, 0], HnHDefaultZoom)
}
}
// Recompute map size after layout (fixes grid/container height chain in Nuxt)
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (map) map.invalidateSize()
})
})
})
intervalId = setInterval(() => {
api.getCharacters().then((body) => updateCharacters(Array.isArray(body) ? body : [])).catch(() => clearInterval(intervalId!))
}, 2000)
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
function updateMarkers(markersData: any[]) {
if (!markers || !map || !markerLayer) return
const list = Array.isArray(markersData) ? markersData : []
const ctx = { map, markerLayer, mapid, overlayLayer, auths: auths.value }
markers.update(
list.map((it) => new Marker(it)),
(marker: InstanceType<typeof Marker>) => {
if (marker.map === mapid || marker.map === overlayLayer?.map) marker.add(ctx)
marker.setClickCallback(() => map!.setView(marker.marker!.getLatLng(), HnHMaxZoom))
marker.setContextMenu((mev: L.LeafletMouseEvent) => {
if (auths.value.includes('admin')) {
contextMenu.marker.show = true
contextMenu.marker.x = mev.originalEvent.clientX
contextMenu.marker.y = mev.originalEvent.clientY
contextMenu.marker.data = { id: marker.id, name: marker.name }
}
})
},
(marker: InstanceType<typeof Marker>) => marker.remove(ctx),
(marker: InstanceType<typeof Marker>, updated: any) => marker.update(ctx, updated)
)
questGivers.value = markers.getElements().filter((it: any) => it.type === 'quest')
}
function updateCharacters(charactersData: any[]) {
if (!characters || !map) return
const list = Array.isArray(charactersData) ? charactersData : []
const ctx = { map, mapid }
characters.update(
list.map((it) => new Character(it)),
(character: InstanceType<typeof Character>) => {
character.add(ctx)
character.setClickCallback(() => (trackingCharacterId.value = character.id))
},
(character: InstanceType<typeof Character>) => character.remove(ctx),
(character: InstanceType<typeof Character>, updated: any) => {
if (trackingCharacterId.value === updated.id) {
if (mapid !== updated.map) changeMap(updated.map)
const latlng = map!.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
map!.setView(latlng, HnHMaxZoom)
}
character.update(ctx, updated)
}
)
players.value = characters.getElements()
}
watch(showGridCoordinates, (v) => {
if (coordLayer) {
;(coordLayer.options as { visible?: boolean }).visible = v
coordLayer.setOpacity(v ? 1 : 0)
if (v && map) {
coordLayer.bringToFront?.()
coordLayer.redraw?.()
map.invalidateSize()
} else if (coordLayer) {
coordLayer.redraw?.()
}
}
})
watch(hideMarkers, (v) => {
markersHidden = v
if (!markers) return
const ctx = { map: map!, markerLayer: markerLayer!, mapid, overlayLayer }
if (v) {
markers.getElements().forEach((it: any) => it.remove(ctx))
} else {
markers.getElements().forEach((it: any) => it.remove(ctx))
markers.getElements().filter((it: any) => it.map === mapid || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx))
}
})
watch(trackingCharacterId, (value) => {
if (value === -1) return
const character = characters?.byId(value)
if (character) {
changeMap(character.map)
const latlng = map!.unproject([character.position.x, character.position.y], HnHMaxZoom)
map!.setView(latlng, HnHMaxZoom)
autoMode = true
} else {
map!.setView([0, 0], HnHMinZoom)
trackingCharacterId.value = -1
}
})
watch(selectedMapId, (value) => {
if (value == null) return
changeMap(value)
const zoom = map!.getZoom()
map!.setView([0, 0], zoom)
})
watch(overlayMapId, (value) => {
if (overlayLayer) overlayLayer.map = value ?? -1
overlayLayer?.redraw()
if (!markers) return
const ctx = { map: map!, markerLayer: markerLayer!, mapid, overlayLayer }
markers.getElements().forEach((it: any) => it.remove(ctx))
markers.getElements().filter((it: any) => it.map === mapid || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx))
})
watch(selectedMarkerId, (value) => {
if (value == null) return
const marker = markers?.byId(value)
if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom())
})
watch(selectedPlayerId, (value) => {
if (value != null) trackingCharacterId.value = value
})
function updateDisplayCoords() {
if (!map) return
const point = map.project(map.getCenter(), 6)
displayCoords.value = {
x: Math.floor(point.x / TileSize),
y: Math.floor(point.y / TileSize),
z: map.getZoom(),
}
}
map.on('moveend', updateDisplayCoords)
updateDisplayCoords()
map.on('zoomend', () => {
if (map) map.invalidateSize()
})
map.on('drag', () => {
trackingCharacterId.value = -1
})
map.on('zoom', () => {
if (autoMode) {
autoMode = false
} else {
trackingCharacterId.value = -1
}
})
})
onBeforeUnmount(() => {
if (import.meta.client) {
window.removeEventListener('keydown', onKeydown)
}
if (intervalId) clearInterval(intervalId)
if (source) source.close()
if (map) map.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;
}
:deep(.map-tile) {
width: 100px;
height: 100px;
min-width: 100px;
min-height: 100px;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
border-right: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
font-size: 11px;
line-height: 1.2;
pointer-events: none;
text-shadow: 0 0 2px #000, 0 1px 2px #000;
}
:deep(.map-tile-text) {
position: absolute;
left: 2px;
top: 2px;
}
</style>