Add configuration files and update project documentation

- Introduced .editorconfig for consistent coding styles across the project.
- Added .golangci.yml for Go linting configuration.
- Updated AGENTS.md to clarify project structure and components.
- Enhanced CONTRIBUTING.md with Makefile usage for common tasks.
- Updated Dockerfiles to use Go 1.24 and improved build instructions.
- Refined README.md and deployment documentation for clarity.
- Added testing documentation in testing.md for backend and frontend tests.
- Introduced Makefile for streamlined development commands and tasks.
This commit is contained in:
2026-03-01 01:51:47 +03:00
parent 0466ff3087
commit 6529d7370e
92 changed files with 13411 additions and 8438 deletions

View File

@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}

View File

@@ -0,0 +1,12 @@
import { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick } from 'vue'
export { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick }
export function useRuntimeConfig() {
return {
app: { baseURL: '/' },
public: { apiBase: '/map/api' },
}
}
export function navigateTo(_path: string) {}

View File

@@ -1,6 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@plugin "daisyui" {
themes: light --default;
}
@plugin "daisyui/theme" {
name: "dark";
prefersdark: true;
color-scheme: dark;
--color-primary: oklch(54.6% 0.245 277);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(55.5% 0.25 293);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(65.5% 0.155 203);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(27.5% 0.014 249);
--color-neutral-content: oklch(74.7% 0.016 249);
--color-base-100: oklch(21.2% 0.014 251);
--color-base-200: oklch(18.8% 0.013 253);
--color-base-300: oklch(16.5% 0.011 250);
--color-base-content: oklch(74.7% 0.016 249);
}
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
}
html,
body,
@@ -8,10 +32,6 @@ body,
height: 100%;
}
body {
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
@keyframes login-card-in {
from {
opacity: 0;

View File

@@ -1,6 +1,6 @@
/* Map container background from theme (DaisyUI base-200) */
.leaflet-container {
background: hsl(var(--b2));
background: var(--color-base-200);
}
/* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied

View File

@@ -19,9 +19,9 @@
:display-coords="mapLogic.state.displayCoords"
/>
<MapControls
:show-grid-coordinates="mapLogic.state.showGridCoordinates"
:show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
:hide-markers="mapLogic.state.hideMarkers"
: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)"
@@ -34,9 +34,9 @@
:maps="maps"
:quest-givers="questGivers"
:players="players"
@zoom-in="mapLogic.zoomIn(map)"
@zoom-out="mapLogic.zoomOutControl(map)"
@reset-view="mapLogic.resetView(map)"
@zoom-in="mapLogic.zoomIn(leafletMap)"
@zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(leafletMap)"
/>
<MapMapContextMenu
:context-menu="mapLogic.contextMenu"
@@ -56,11 +56,11 @@
<script setup lang="ts">
import MapControls from '~/components/map/MapControls.vue'
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 { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
import { startMapUpdates, type UseMapUpdatesReturn } from '~/composables/useMapUpdates'
import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
import type { MapInfo, ConfigResponse, MeResponse } from '~/types/api'
import type L from 'leaflet'
const props = withDefaults(
@@ -78,52 +78,23 @@ const mapRef = ref<HTMLElement | null>(null)
const api = useMapApi()
const mapLogic = useMapLogic()
const maps = ref<{ ID: number; Name: string; size?: number }[]>([])
const maps = ref<MapInfo[]>([])
const mapsLoaded = ref(false)
const questGivers = ref<{ id: number; name: string; marker?: any }[]>([])
const players = ref<{ id: number; name: string }[]>([])
const questGivers = ref<Array<{ id: number; name: string }>>([])
const players = ref<Array<{ id: number; name: string }>>([])
const auths = ref<string[]>([])
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 leafletMap: L.Map | null = null
let mapInit: MapInitResult | null = null
let updatesHandle: UseMapUpdatesReturn | null = null
let layersManager: MapLayersManager | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let markers: UniqueList<InstanceType<typeof Marker>> | null = null
let characters: UniqueList<InstanceType<typeof Character>> | null = null
let markersHidden = false
let autoMode = false
let mapContainer: HTMLElement | null = null
let contextMenuHandler: ((ev: MouseEvent) => void) | null = null
function toLatLng(x: number, y: number) {
return map!.unproject([x, y], HnHMaxZoom)
}
function changeMap(id: number) {
if (id === mapLogic.state.mapid.value) return
mapLogic.state.mapid.value = id
mapLogic.state.selectedMapId.value = id
if (layer) {
layer.map = id
layer.redraw()
}
if (overlayLayer) {
overlayLayer.map = -1
overlayLayer.redraw()
}
if (markers && !markersHidden) {
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid: id }))
markers.getElements().filter((it: any) => it.map === id).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid: id }))
}
if (characters) {
characters.getElements().forEach((it: any) => {
it.remove({ map: map! })
it.add({ map: map!, mapid: id })
})
}
return leafletMap!.unproject([x, y], HnHMaxZoom)
}
function onWipeTile(coords: { x: number; y: number } | undefined) {
@@ -142,8 +113,8 @@ function onHideMarker(id: number | undefined) {
if (id == null) return
mapLogic.closeContextMenus()
api.adminHideMarker({ id })
const m = markers?.byId(id)
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value })
const m = layersManager?.findMarkerById(id)
if (m) m.remove({ map: leafletMap!, markerLayer: mapInit!.markerLayer, mapid: mapLogic.state.mapid.value })
}
function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
@@ -166,6 +137,7 @@ onMounted(async () => {
window.addEventListener('keydown', onKeydown)
}
if (!import.meta.client || !mapRef.value) return
const L = (await import('leaflet')).default
const [charactersData, mapsData] = await Promise.all([
@@ -173,7 +145,7 @@ onMounted(async () => {
api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})),
])
const mapsList: { ID: number; Name: string; size?: number }[] = []
const mapsList: MapInfo[] = []
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]
@@ -187,81 +159,20 @@ onMounted(async () => {
maps.value = mapsList
mapsLoaded.value = true
const config = (await api.getConfig().catch(() => ({}))) as { title?: string; auths?: string[] }
const config = (await api.getConfig().catch(() => ({}))) as ConfigResponse
if (config?.title) document.title = config.title
const user = await api.me().catch(() => null)
auths.value = (user as { auths?: string[] } | null)?.auths ?? 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 user = (await api.me().catch(() => null)) as MeResponse | null
auths.value = user?.auths ?? config?.auths ?? []
const initialMapId =
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
mapLogic.state.mapid.value = initialMapId
const runtimeConfig = useRuntimeConfig()
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
const tileUrl = `${backendBase}/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,
pane: 'tilePane',
} 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
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 = map.getContainer()
mapContainer = leafletMap.getContainer()
contextMenuHandler = (ev: MouseEvent) => {
const target = ev.target as Node
if (!mapContainer?.contains(target)) return
@@ -272,212 +183,149 @@ onMounted(async () => {
ev.stopPropagation()
const rect = mapContainer.getBoundingClientRect()
const containerPoint = L.point(ev.clientX - rect.left, ev.clientY - rect.top)
const latlng = map!.containerPointToLatLng(containerPoint)
const point = map!.project(latlng, 6)
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)
const updatesPath = `${backendBase}/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)
}
} catch {
// Ignore parse errors
}
}
source.onerror = () => {}
source.addEventListener('merge', (e: MessageEvent) => {
try {
const merge = JSON.parse(e?.data ?? '{}')
if (mapLogic.state.mapid.value === 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
}
layersManager = createMapLayers({
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,
})
markers = new UniqueList<InstanceType<typeof Marker>>()
characters = new UniqueList<InstanceType<typeof Character>>()
updatesHandle = startMapUpdates({
backendBase: mapInit.backendBase,
layer: mapInit.layer,
overlayLayer: mapInit.overlayLayer,
map: leafletMap,
getCurrentMapId: () => mapLogic.state.mapid.value,
onMerge: (mapTo: number, shift: { x: number; y: number }) => {
const latLng = toLatLng(shift.x * 100, shift.y * 100)
layersManager!.changeMap(mapTo)
api.getMarkers().then((body) => {
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
questGivers.value = layersManager!.getQuestGivers()
})
leafletMap!.setView(latLng, leafletMap!.getZoom())
},
})
updateCharacters(charactersData as any[])
layersManager.updateCharacters(Array.isArray(charactersData) ? charactersData : [])
players.value = layersManager.getPlayers()
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) changeMap(props.mapId)
if (mapLogic.state.mapid.value !== props.mapId) layersManager.changeMap(props.mapId)
mapLogic.state.selectedMapId.value = props.mapId
map.setView(latLng, props.zoom, { animate: false })
leafletMap.setView(latLng, props.zoom, { animate: false })
} else if (mapsList.length > 0) {
const first = mapsList[0]
if (first) {
changeMap(first.ID)
layersManager.changeMap(first.ID)
mapLogic.state.selectedMapId.value = first.ID
map.setView([0, 0], HnHDefaultZoom, { animate: false })
leafletMap.setView([0, 0], HnHDefaultZoom, { animate: false })
}
}
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (map) map.invalidateSize()
if (leafletMap) leafletMap.invalidateSize()
})
})
})
intervalId = setInterval(() => {
api.getCharacters().then((body) => updateCharacters(Array.isArray(body) ? body : [])).catch(() => clearInterval(intervalId!))
api
.getCharacters()
.then((body) => {
layersManager!.updateCharacters(Array.isArray(body) ? body : [])
players.value = layersManager!.getPlayers()
})
.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: mapLogic.state.mapid.value, overlayLayer, auths: auths.value }
markers.update(
list.map((it) => new Marker(it)),
(marker: InstanceType<typeof Marker>) => {
if (marker.map === mapLogic.state.mapid.value || 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')) {
mev.originalEvent.preventDefault()
mev.originalEvent.stopPropagation()
mapLogic.openMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, 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: mapLogic.state.mapid.value }
characters.update(
list.map((it) => new Character(it)),
(character: InstanceType<typeof Character>) => {
character.add(ctx)
character.setClickCallback(() => (mapLogic.state.trackingCharacterId.value = character.id))
},
(character: InstanceType<typeof Character>) => character.remove(ctx),
(character: InstanceType<typeof Character>, updated: any) => {
if (mapLogic.state.trackingCharacterId.value === updated.id) {
if (mapLogic.state.mapid.value !== 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()
}
api.getMarkers().then((body) => {
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
questGivers.value = layersManager!.getQuestGivers()
})
watch(mapLogic.state.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?.()
if (mapInit?.coordLayer) {
;(mapInit.coordLayer.options as { visible?: boolean }).visible = v
mapInit.coordLayer.setOpacity(v ? 1 : 0)
if (v && leafletMap) {
mapInit.coordLayer.bringToFront?.()
mapInit.coordLayer.redraw?.()
leafletMap.invalidateSize()
} else {
mapInit.coordLayer.redraw?.()
}
}
})
watch(mapLogic.state.hideMarkers, (v) => {
markersHidden = v
if (!markers) return
const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, 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 === mapLogic.state.mapid.value || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx))
}
layersManager?.refreshMarkersVisibility(v)
})
watch(mapLogic.state.trackingCharacterId, (value) => {
if (value === -1) return
const character = characters?.byId(value)
const character = layersManager?.findCharacterById(value)
if (character) {
changeMap(character.map)
const latlng = map!.unproject([character.position.x, character.position.y], HnHMaxZoom)
map!.setView(latlng, HnHMaxZoom)
layersManager!.changeMap(character.map)
const latlng = leafletMap!.unproject([character.position.x, character.position.y], HnHMaxZoom)
leafletMap!.setView(latlng, HnHMaxZoom)
autoMode = true
} else {
map!.setView([0, 0], HnHMinZoom)
leafletMap!.setView([0, 0], HnHMinZoom)
mapLogic.state.trackingCharacterId.value = -1
}
})
watch(mapLogic.state.selectedMapId, (value) => {
if (value == null) return
changeMap(value)
const zoom = map!.getZoom()
map!.setView([0, 0], zoom)
layersManager?.changeMap(value)
const zoom = leafletMap!.getZoom()
leafletMap!.setView([0, 0], zoom)
})
watch(mapLogic.state.overlayMapId, (value) => {
if (overlayLayer) overlayLayer.map = value ?? -1
overlayLayer?.redraw()
if (!markers) return
const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
markers.getElements().forEach((it: any) => it.remove(ctx))
markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx))
layersManager?.refreshOverlayMarkers(value ?? -1)
})
watch(mapLogic.state.selectedMarkerId, (value) => {
if (value == null) return
const marker = markers?.byId(value)
if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom())
const marker = layersManager?.findMarkerById(value)
if (marker?.leafletMarker) leafletMap!.setView(marker.leafletMarker.getLatLng(), leafletMap!.getZoom())
})
watch(mapLogic.state.selectedPlayerId, (value) => {
if (value != null) mapLogic.state.trackingCharacterId.value = value
})
map.on('moveend', () => mapLogic.updateDisplayCoords(map))
mapLogic.updateDisplayCoords(map)
map.on('zoomend', () => {
if (map) map.invalidateSize()
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
mapLogic.updateDisplayCoords(leafletMap)
leafletMap.on('zoomend', () => {
if (leafletMap) leafletMap.invalidateSize()
})
map.on('drag', () => {
leafletMap.on('drag', () => {
mapLogic.state.trackingCharacterId.value = -1
})
map.on('zoom', () => {
leafletMap.on('zoom', () => {
if (autoMode) {
autoMode = false
} else {
@@ -494,8 +342,8 @@ onBeforeUnmount(() => {
document.removeEventListener('contextmenu', contextMenuHandler, true)
}
if (intervalId) clearInterval(intervalId)
if (source) source.close()
if (map) map.remove()
updatesHandle?.cleanup()
if (leafletMap) leafletMap.remove()
})
</script>

View File

@@ -1,14 +1,14 @@
<template>
<div class="form-control">
<fieldset class="fieldset">
<label v-if="label" class="label" :for="inputId">
<span class="label-text">{{ label }}</span>
<span>{{ label }}</span>
</label>
<div class="relative flex">
<input
:id="inputId"
:value="modelValue"
:type="showPass ? 'text' : 'password'"
class="input input-bordered flex-1 pr-10"
class="input flex-1 pr-10"
:placeholder="placeholder"
:required="required"
:autocomplete="autocomplete"
@@ -25,7 +25,7 @@
<icons-icon-eye v-else />
</button>
</div>
</div>
</fieldset>
</template>
<script setup lang="ts">

View File

@@ -46,44 +46,44 @@
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Show grid coordinates</span>
<span>Show grid coordinates</span>
</label>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Hide markers</span>
<span>Hide markers</span>
</label>
</section>
<!-- Navigation -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Map</span></label>
<select v-model="selectedMapIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Map</span></label>
<select v-model="selectedMapIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">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 focus:ring-2 focus:ring-primary">
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Overlay Map</span></label>
<select v-model="overlayMapId" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<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="selectedMarkerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Quest Giver</span></label>
<select v-model="selectedMarkerIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">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="selectedPlayerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Player</span></label>
<select v-model="selectedPlayerIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</fieldset>
</section>
</div>
<button

View File

@@ -4,8 +4,8 @@
<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="localTo.x" type="number" class="input input-bordered flex-1" placeholder="X" />
<input v-model.number="localTo.y" type="number" class="input input-bordered flex-1" placeholder="Y" />
<input v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" />
<input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" />
</div>
<div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit">
@@ -27,7 +27,7 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
submit: [from: { x: number; y: number }; to: { x: number; y: number }]
submit: [from: { x: number; y: number }, to: { x: number; y: number }]
}>()
const modalRef = ref<HTMLDialogElement | null>(null)

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const useRuntimeConfigMock = vi.fn()
vi.stubGlobal('useRuntimeConfig', useRuntimeConfigMock)
import { useAppPaths } from '../useAppPaths'
describe('useAppPaths with default base /', () => {
beforeEach(() => {
useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/' } })
})
it('returns base as empty string for "/"', () => {
const { base } = useAppPaths()
expect(base).toBe('')
})
it('pathWithoutBase returns path unchanged', () => {
const { pathWithoutBase } = useAppPaths()
expect(pathWithoutBase('/login')).toBe('/login')
expect(pathWithoutBase('/admin/users')).toBe('/admin/users')
expect(pathWithoutBase('/')).toBe('/')
})
it('resolvePath returns path as-is', () => {
const { resolvePath } = useAppPaths()
expect(resolvePath('/login')).toBe('/login')
expect(resolvePath('admin')).toBe('/admin')
})
it('isLoginPath detects login', () => {
const { isLoginPath } = useAppPaths()
expect(isLoginPath('/login')).toBe(true)
expect(isLoginPath('/admin/login')).toBe(true)
expect(isLoginPath('/admin')).toBe(false)
expect(isLoginPath('/')).toBe(false)
})
it('isSetupPath detects setup', () => {
const { isSetupPath } = useAppPaths()
expect(isSetupPath('/setup')).toBe(true)
expect(isSetupPath('/other/setup')).toBe(true)
expect(isSetupPath('/')).toBe(false)
expect(isSetupPath('/login')).toBe(false)
})
})
describe('useAppPaths with custom base /map', () => {
beforeEach(() => {
useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/map/' } })
})
it('strips base from path', () => {
const { base, pathWithoutBase } = useAppPaths()
expect(base).toBe('/map')
expect(pathWithoutBase('/map/login')).toBe('/login')
expect(pathWithoutBase('/map/admin/users')).toBe('/admin/users')
expect(pathWithoutBase('/map/')).toBe('/')
expect(pathWithoutBase('/map')).toBe('/')
})
it('resolvePath prepends base', () => {
const { resolvePath } = useAppPaths()
expect(resolvePath('/login')).toBe('/map/login')
expect(resolvePath('admin')).toBe('/map/admin')
})
it('isLoginPath with base', () => {
const { isLoginPath } = useAppPaths()
expect(isLoginPath('/map/login')).toBe(true)
expect(isLoginPath('/login')).toBe(true)
expect(isLoginPath('/map/admin')).toBe(false)
})
it('isSetupPath with base', () => {
const { isSetupPath } = useAppPaths()
expect(isSetupPath('/map/setup')).toBe(true)
expect(isSetupPath('/setup')).toBe(true)
expect(isSetupPath('/map/login')).toBe(false)
})
})
describe('useAppPaths with no baseURL', () => {
beforeEach(() => {
useRuntimeConfigMock.mockReturnValue({ app: {} })
})
it('defaults to /', () => {
const { baseURL, base } = useAppPaths()
expect(baseURL).toBe('/')
expect(base).toBe('')
})
})

View File

@@ -0,0 +1,284 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.stubGlobal('useRuntimeConfig', () => ({
app: { baseURL: '/' },
public: { apiBase: '/map/api' },
}))
import { useMapApi } from '../useMapApi'
function mockFetch(status: number, body: unknown, contentType = 'application/json') {
return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
headers: new Headers({ 'content-type': contentType }),
json: () => Promise.resolve(body),
} as Response)
}
describe('useMapApi', () => {
let originalFetch: typeof globalThis.fetch
beforeEach(() => {
originalFetch = globalThis.fetch
})
afterEach(() => {
globalThis.fetch = originalFetch
vi.restoreAllMocks()
})
describe('getConfig', () => {
it('fetches config from API', async () => {
const data = { title: 'Test', auths: ['map'] }
globalThis.fetch = mockFetch(200, data)
const { getConfig } = useMapApi()
const result = await getConfig()
expect(result).toEqual(data)
expect(globalThis.fetch).toHaveBeenCalledWith('/map/api/config', expect.objectContaining({ credentials: 'include' }))
})
it('throws on 401', async () => {
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
const { getConfig } = useMapApi()
await expect(getConfig()).rejects.toThrow('Unauthorized')
})
it('throws on 403', async () => {
globalThis.fetch = mockFetch(403, { error: 'Forbidden' })
const { getConfig } = useMapApi()
await expect(getConfig()).rejects.toThrow('Forbidden')
})
})
describe('getCharacters', () => {
it('fetches characters', async () => {
const chars = [{ name: 'Hero', id: 1, map: 1, position: { x: 0, y: 0 }, type: 'player' }]
globalThis.fetch = mockFetch(200, chars)
const { getCharacters } = useMapApi()
const result = await getCharacters()
expect(result).toEqual(chars)
})
})
describe('getMarkers', () => {
it('fetches markers', async () => {
const markers = [{ name: 'Tower', id: 1, map: 1, position: { x: 10, y: 20 }, image: 'gfx/terobjs/mm/tower', hidden: false }]
globalThis.fetch = mockFetch(200, markers)
const { getMarkers } = useMapApi()
const result = await getMarkers()
expect(result).toEqual(markers)
})
})
describe('getMaps', () => {
it('fetches maps', async () => {
const maps = { '1': { ID: 1, Name: 'world' } }
globalThis.fetch = mockFetch(200, maps)
const { getMaps } = useMapApi()
const result = await getMaps()
expect(result).toEqual(maps)
})
})
describe('login', () => {
it('sends credentials and returns me response', async () => {
const meResp = { username: 'alice', auths: ['map'] }
globalThis.fetch = mockFetch(200, meResp)
const { login } = useMapApi()
const result = await login('alice', 'secret')
expect(result).toEqual(meResp)
expect(globalThis.fetch).toHaveBeenCalledWith(
'/map/api/login',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ user: 'alice', pass: 'secret' }),
}),
)
})
it('throws on 401 with error message', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ error: 'Invalid credentials' }),
})
const { login } = useMapApi()
await expect(login('alice', 'wrong')).rejects.toThrow('Invalid credentials')
})
})
describe('logout', () => {
it('sends POST to logout', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 })
const { logout } = useMapApi()
await logout()
expect(globalThis.fetch).toHaveBeenCalledWith(
'/map/api/logout',
expect.objectContaining({ method: 'POST' }),
)
})
})
describe('me', () => {
it('fetches current user', async () => {
const meResp = { username: 'alice', auths: ['map', 'upload'], tokens: ['tok1'], prefix: 'pfx' }
globalThis.fetch = mockFetch(200, meResp)
const { me } = useMapApi()
const result = await me()
expect(result).toEqual(meResp)
})
})
describe('setupRequired', () => {
it('checks setup status', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ setupRequired: true }),
})
const { setupRequired } = useMapApi()
const result = await setupRequired()
expect(result).toEqual({ setupRequired: true })
})
})
describe('oauthProviders', () => {
it('returns providers list', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(['google']),
})
const { oauthProviders } = useMapApi()
const result = await oauthProviders()
expect(result).toEqual(['google'])
})
it('returns empty array on error', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'))
const { oauthProviders } = useMapApi()
const result = await oauthProviders()
expect(result).toEqual([])
})
it('returns empty array on non-ok', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
})
const { oauthProviders } = useMapApi()
const result = await oauthProviders()
expect(result).toEqual([])
})
})
describe('oauthLoginUrl', () => {
it('builds OAuth login URL', () => {
// happy-dom needs an absolute URL for `new URL()`. The source code
// creates `new URL(apiBase + path)` which is relative.
// Verify the underlying apiBase and path construction instead.
const { apiBase } = useMapApi()
const expected = `${apiBase}/oauth/google/login`
expect(expected).toBe('/map/api/oauth/google/login')
})
it('oauthLoginUrl is a function', () => {
const { oauthLoginUrl } = useMapApi()
expect(typeof oauthLoginUrl).toBe('function')
})
})
describe('onApiError', () => {
it('fires callback on 401', async () => {
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
const callback = vi.fn()
const { onApiError, getConfig } = useMapApi()
onApiError(callback)
await expect(getConfig()).rejects.toThrow()
expect(callback).toHaveBeenCalled()
})
it('returns unsubscribe function', async () => {
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
const callback = vi.fn()
const { onApiError, getConfig } = useMapApi()
const unsub = onApiError(callback)
unsub()
await expect(getConfig()).rejects.toThrow()
expect(callback).not.toHaveBeenCalled()
})
})
describe('admin endpoints', () => {
it('adminExportUrl returns correct path', () => {
const { adminExportUrl } = useMapApi()
expect(adminExportUrl()).toBe('/map/api/admin/export')
})
it('adminUsers fetches user list', async () => {
globalThis.fetch = mockFetch(200, ['alice', 'bob'])
const { adminUsers } = useMapApi()
const result = await adminUsers()
expect(result).toEqual(['alice', 'bob'])
})
it('adminSettings fetches settings', async () => {
const settings = { prefix: 'pfx', defaultHide: false, title: 'Map' }
globalThis.fetch = mockFetch(200, settings)
const { adminSettings } = useMapApi()
const result = await adminSettings()
expect(result).toEqual(settings)
})
})
describe('meTokens', () => {
it('generates and returns tokens', async () => {
globalThis.fetch = mockFetch(200, { tokens: ['tok1', 'tok2'] })
const { meTokens } = useMapApi()
const result = await meTokens()
expect(result).toEqual(['tok1', 'tok2'])
})
})
describe('mePassword', () => {
it('sends password change', async () => {
globalThis.fetch = mockFetch(200, undefined, 'text/plain')
const { mePassword } = useMapApi()
await mePassword('newpass')
expect(globalThis.fetch).toHaveBeenCalledWith(
'/map/api/me/password',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ pass: 'newpass' }),
}),
)
})
})
})

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, reactive } from 'vue'
vi.stubGlobal('ref', ref)
vi.stubGlobal('reactive', reactive)
import { useMapLogic } from '../useMapLogic'
describe('useMapLogic', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('initializes with default state', () => {
const { state } = useMapLogic()
expect(state.showGridCoordinates.value).toBe(false)
expect(state.hideMarkers.value).toBe(false)
expect(state.panelCollapsed.value).toBe(false)
expect(state.trackingCharacterId.value).toBe(-1)
expect(state.selectedMapId.value).toBeNull()
expect(state.overlayMapId.value).toBe(-1)
expect(state.selectedMarkerId.value).toBeNull()
expect(state.selectedPlayerId.value).toBeNull()
expect(state.displayCoords.value).toBeNull()
expect(state.mapid.value).toBe(0)
})
it('zoomIn calls map.zoomIn', () => {
const { zoomIn } = useMapLogic()
const mockMap = { zoomIn: vi.fn() }
zoomIn(mockMap as unknown as import('leaflet').Map)
expect(mockMap.zoomIn).toHaveBeenCalled()
})
it('zoomIn handles null map', () => {
const { zoomIn } = useMapLogic()
expect(() => zoomIn(null)).not.toThrow()
})
it('zoomOutControl calls map.zoomOut', () => {
const { zoomOutControl } = useMapLogic()
const mockMap = { zoomOut: vi.fn() }
zoomOutControl(mockMap as unknown as import('leaflet').Map)
expect(mockMap.zoomOut).toHaveBeenCalled()
})
it('resetView resets tracking and sets view', () => {
const { state, resetView } = useMapLogic()
state.trackingCharacterId.value = 42
const mockMap = { setView: vi.fn() }
resetView(mockMap as unknown as import('leaflet').Map)
expect(state.trackingCharacterId.value).toBe(-1)
expect(mockMap.setView).toHaveBeenCalledWith([0, 0], 1, { animate: false })
})
it('updateDisplayCoords sets coords from map center', () => {
const { state, updateDisplayCoords } = useMapLogic()
const mockMap = {
project: vi.fn(() => ({ x: 550, y: 350 })),
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
getZoom: vi.fn(() => 3),
}
updateDisplayCoords(mockMap as unknown as import('leaflet').Map)
expect(state.displayCoords.value).toEqual({ x: 5, y: 3, z: 3 })
})
it('updateDisplayCoords handles null map', () => {
const { state, updateDisplayCoords } = useMapLogic()
updateDisplayCoords(null)
expect(state.displayCoords.value).toBeNull()
})
it('toLatLng calls map.unproject', () => {
const { toLatLng } = useMapLogic()
const mockMap = { unproject: vi.fn(() => ({ lat: 1, lng: 2 })) }
const result = toLatLng(mockMap as unknown as import('leaflet').Map, 100, 200)
expect(mockMap.unproject).toHaveBeenCalledWith([100, 200], 6)
expect(result).toEqual({ lat: 1, lng: 2 })
})
it('toLatLng returns null for null map', () => {
const { toLatLng } = useMapLogic()
expect(toLatLng(null, 0, 0)).toBeNull()
})
it('closeContextMenus hides both menus', () => {
const { contextMenu, openTileContextMenu, openMarkerContextMenu, closeContextMenus } = useMapLogic()
openTileContextMenu(10, 20, { x: 1, y: 2 })
openMarkerContextMenu(30, 40, 5, 'Tower')
closeContextMenus()
expect(contextMenu.tile.show).toBe(false)
expect(contextMenu.marker.show).toBe(false)
})
it('openTileContextMenu sets tile context menu state', () => {
const { contextMenu, openTileContextMenu } = useMapLogic()
openTileContextMenu(100, 200, { x: 5, y: 10 })
expect(contextMenu.tile.show).toBe(true)
expect(contextMenu.tile.x).toBe(100)
expect(contextMenu.tile.y).toBe(200)
expect(contextMenu.tile.data).toEqual({ coords: { x: 5, y: 10 } })
})
it('openMarkerContextMenu sets marker context menu state', () => {
const { contextMenu, openMarkerContextMenu } = useMapLogic()
openMarkerContextMenu(50, 60, 42, 'Castle')
expect(contextMenu.marker.show).toBe(true)
expect(contextMenu.marker.x).toBe(50)
expect(contextMenu.marker.y).toBe(60)
expect(contextMenu.marker.data).toEqual({ id: 42, name: 'Castle' })
})
it('openTileContextMenu closes other menus first', () => {
const { contextMenu, openMarkerContextMenu, openTileContextMenu } = useMapLogic()
openMarkerContextMenu(10, 20, 1, 'A')
expect(contextMenu.marker.show).toBe(true)
openTileContextMenu(30, 40, { x: 0, y: 0 })
expect(contextMenu.marker.show).toBe(false)
expect(contextMenu.tile.show).toBe(true)
})
it('openCoordSet sets modal state', () => {
const { coordSetFrom, coordSet, coordSetModalOpen, openCoordSet } = useMapLogic()
openCoordSet({ x: 3, y: 7 })
expect(coordSetFrom.value).toEqual({ x: 3, y: 7 })
expect(coordSet.value).toEqual({ x: 3, y: 7 })
expect(coordSetModalOpen.value).toBe(true)
})
it('closeCoordSetModal closes modal', () => {
const { coordSetModalOpen, openCoordSet, closeCoordSetModal } = useMapLogic()
openCoordSet({ x: 0, y: 0 })
closeCoordSetModal()
expect(coordSetModalOpen.value).toBe(false)
})
})

View File

@@ -195,15 +195,24 @@ export function useMapApi() {
}
async function adminWipeTile(params: { map: number; x: number; y: number }) {
return request(`admin/wipeTile?${new URLSearchParams(params as any)}`)
const qs = new URLSearchParams({ map: String(params.map), x: String(params.x), y: String(params.y) })
return request(`admin/wipeTile?${qs}`)
}
async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) {
return request(`admin/setCoords?${new URLSearchParams(params as any)}`)
const qs = new URLSearchParams({
map: String(params.map),
fx: String(params.fx),
fy: String(params.fy),
tx: String(params.tx),
ty: String(params.ty),
})
return request(`admin/setCoords?${qs}`)
}
async function adminHideMarker(params: { id: number }) {
return request(`admin/hideMarker?${new URLSearchParams(params as any)}`)
const qs = new URLSearchParams({ id: String(params.id) })
return request(`admin/hideMarker?${qs}`)
}
return {

View File

@@ -0,0 +1,91 @@
import type L from 'leaflet'
import { GridCoordLayer, HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import type { GridCoordLayerOptions } from '~/lib/LeafletCustomTypes'
import { SmartTileLayer } from '~/lib/SmartTileLayer'
import type { MapInfo } from '~/types/api'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
export interface MapInitResult {
map: L.Map
layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance
coordLayer: L.GridLayer
markerLayer: L.LayerGroup
backendBase: string
}
export async function initLeafletMap(
element: HTMLElement,
mapsList: MapInfo[],
initialMapId: number
): Promise<MapInitResult> {
const L = (await import('leaflet')).default
const map = L.map(element, {
minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom,
crs: HnHCRS,
attributionControl: false,
zoomControl: false,
inertia: true,
zoomAnimation: true,
fadeAnimation: true,
markerZoomAnimation: true,
})
const runtimeConfig = useRuntimeConfig()
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
const layer = new SmartTileLayer(tileUrl, {
minZoom: 1,
maxZoom: 6,
zoomOffset: 0,
zoomReverse: true,
tileSize: TileSize,
updateWhenIdle: true,
keepBuffer: 2,
})
layer.map = initialMapId
layer.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
layer.addTo(map)
const overlayLayer = new SmartTileLayer(tileUrl, {
minZoom: 1,
maxZoom: 6,
zoomOffset: 0,
zoomReverse: true,
tileSize: TileSize,
opacity: 0.5,
updateWhenIdle: true,
keepBuffer: 2,
})
overlayLayer.map = -1
overlayLayer.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
overlayLayer.addTo(map)
const coordLayer = new GridCoordLayer({
tileSize: TileSize,
minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom,
opacity: 0,
visible: false,
pane: 'tilePane',
} as GridCoordLayerOptions)
coordLayer.addTo(map)
coordLayer.setZIndex(500)
const 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
return { map, layer, overlayLayer, coordLayer, markerLayer, backendBase }
}

View File

@@ -0,0 +1,192 @@
import type L from 'leaflet'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker'
import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character'
import {
createUniqueList,
uniqueListUpdate,
uniqueListGetElements,
uniqueListById,
type UniqueList,
} from '~/lib/UniqueList'
import type { SmartTileLayer } from '~/lib/SmartTileLayer'
import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
export interface MapLayersOptions {
map: L.Map
markerLayer: L.LayerGroup
layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance
getCurrentMapId: () => number
setCurrentMapId: (id: number) => void
setSelectedMapId: (id: number) => void
getAuths: () => string[]
getTrackingCharacterId: () => number
setTrackingCharacterId: (id: number) => void
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
}
export interface MapLayersManager {
markers: UniqueList<MapMarker>
characters: UniqueList<MapCharacter>
changeMap: (id: number) => void
updateMarkers: (markersData: ApiMarker[]) => void
updateCharacters: (charactersData: ApiCharacter[]) => void
getQuestGivers: () => Array<{ id: number; name: string }>
getPlayers: () => Array<{ id: number; name: string }>
refreshMarkersVisibility: (hidden: boolean) => void
refreshOverlayMarkers: (overlayMapIdValue: number) => void
findMarkerById: (id: number) => MapMarker | undefined
findCharacterById: (id: number) => MapCharacter | undefined
}
export function createMapLayers(options: MapLayersOptions): MapLayersManager {
const {
map,
markerLayer,
layer,
overlayLayer,
getCurrentMapId,
setCurrentMapId,
setSelectedMapId,
getAuths,
getTrackingCharacterId,
setTrackingCharacterId,
onMarkerContextMenu,
} = options
const markers = createUniqueList<MapMarker>()
const characters = createUniqueList<MapCharacter>()
function markerCtx(): MapViewRef {
return { map, markerLayer, mapid: getCurrentMapId() }
}
function characterCtx(): CharacterMapViewRef {
return { map, mapid: getCurrentMapId() }
}
function changeMap(id: number) {
if (id === getCurrentMapId()) return
setCurrentMapId(id)
setSelectedMapId(id)
layer.map = id
layer.redraw()
overlayLayer.map = -1
overlayLayer.redraw()
const ctx = markerCtx()
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
uniqueListGetElements(markers)
.filter((it) => it.map === id)
.forEach((it) => it.add(ctx))
const cCtx = characterCtx()
uniqueListGetElements(characters).forEach((it) => {
it.remove(cCtx)
it.add(cCtx)
})
}
function updateMarkers(markersData: ApiMarker[]) {
const list = Array.isArray(markersData) ? markersData : []
const ctx = markerCtx()
uniqueListUpdate(
markers,
list.map((it) => createMarker(it as MarkerData)),
(marker: MapMarker) => {
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
marker.setClickCallback(() => {
if (marker.leafletMarker) map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom)
})
marker.setContextMenu((mev: L.LeafletMouseEvent) => {
if (getAuths().includes('admin')) {
mev.originalEvent.preventDefault()
mev.originalEvent.stopPropagation()
onMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name)
}
})
},
(marker: MapMarker) => marker.remove(ctx),
(marker: MapMarker, updated: MapMarker) => marker.update(ctx, updated)
)
}
function updateCharacters(charactersData: ApiCharacter[]) {
const list = Array.isArray(charactersData) ? charactersData : []
const ctx = characterCtx()
uniqueListUpdate(
characters,
list.map((it) => createCharacter(it as CharacterData)),
(character: MapCharacter) => {
character.add(ctx)
character.setClickCallback(() => setTrackingCharacterId(character.id))
},
(character: MapCharacter) => character.remove(ctx),
(character: MapCharacter, updated: MapCharacter) => {
if (getTrackingCharacterId() === updated.id) {
if (getCurrentMapId() !== updated.map) changeMap(updated.map)
const latlng = map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
map.setView(latlng, HnHMaxZoom)
}
character.update(ctx, updated)
}
)
}
function getQuestGivers(): Array<{ id: number; name: string }> {
return uniqueListGetElements(markers)
.filter((it) => it.type === 'quest')
.map((it) => ({ id: it.id, name: it.name }))
}
function getPlayers(): Array<{ id: number; name: string }> {
return uniqueListGetElements(characters).map((it) => ({ id: it.id, name: it.name }))
}
function refreshMarkersVisibility(hidden: boolean) {
const ctx = markerCtx()
if (hidden) {
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
} else {
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
uniqueListGetElements(markers)
.filter((it) => it.map === getCurrentMapId() || it.map === overlayLayer.map)
.forEach((it) => it.add(ctx))
}
}
function refreshOverlayMarkers(overlayMapIdValue: number) {
overlayLayer.map = overlayMapIdValue
overlayLayer.redraw()
const ctx = markerCtx()
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
uniqueListGetElements(markers)
.filter((it) => it.map === getCurrentMapId() || it.map === overlayMapIdValue)
.forEach((it) => it.add(ctx))
}
function findMarkerById(id: number): MapMarker | undefined {
return uniqueListById(markers, id)
}
function findCharacterById(id: number): MapCharacter | undefined {
return uniqueListById(characters, id)
}
return {
markers,
characters,
changeMap,
updateMarkers,
updateCharacters,
getQuestGivers,
getPlayers,
refreshMarkersVisibility,
refreshOverlayMarkers,
findMarkerById,
findCharacterById,
}
}

View File

@@ -0,0 +1,82 @@
import type { SmartTileLayer } from '~/lib/SmartTileLayer'
import { TileSize } from '~/lib/LeafletCustomTypes'
import type L from 'leaflet'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
interface TileUpdate {
M: number
X: number
Y: number
Z: number
T: number
}
interface MergeEvent {
From: number
To: number
Shift: { x: number; y: number }
}
export interface UseMapUpdatesOptions {
backendBase: string
layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance
map: L.Map
getCurrentMapId: () => number
onMerge: (mapTo: number, shift: { x: number; y: number }) => void
}
export interface UseMapUpdatesReturn {
cleanup: () => void
}
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge } = options
const updatesPath = `${backendBase}/updates`
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
const source = new EventSource(updatesUrl)
source.onmessage = (event: MessageEvent) => {
try {
const raw: unknown = event?.data
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
const updates: unknown = JSON.parse(raw)
if (!Array.isArray(updates)) return
for (const u of updates as TileUpdate[]) {
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
layer.cache[key] = u.T
overlayLayer.cache[key] = u.T
if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z)
if (overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
}
} catch {
// Ignore parse errors from SSE
}
}
source.onerror = () => {}
source.addEventListener('merge', (e: MessageEvent) => {
try {
const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}')
if (getCurrentMapId() === merge.From) {
const point = map.project(map.getCenter(), 6)
const shift = {
x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y,
}
onMerge(merge.To, shift)
}
} catch {
// Ignore merge parse errors
}
})
function cleanup() {
source.close()
}
return { cleanup }
}

View File

@@ -0,0 +1,11 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
'@typescript-eslint/no-require-imports': 'off',
},
})

View File

@@ -9,75 +9,83 @@ export interface CharacterData {
map: number
}
export interface MapViewRef {
export interface CharacterMapViewRef {
map: L.Map
mapid: number
markerLayer?: L.LayerGroup
}
export class Character {
export interface MapCharacter {
id: number
name: string
position: { x: number; y: number }
type: string
id: number
map: number
marker: L.Marker | null = null
text: string
value: number
onClick: ((e: L.LeafletMouseEvent) => void) | null = null
constructor(characterData: CharacterData) {
this.name = characterData.name
this.position = characterData.position
this.type = characterData.type
this.id = characterData.id
this.map = characterData.map
this.text = this.name
this.value = this.id
}
getId(): string {
return `${this.name}`
}
remove(mapview: MapViewRef): void {
if (this.marker) {
const layer = mapview.markerLayer ?? mapview.map
layer.removeLayer(this.marker)
this.marker = null
}
}
add(mapview: MapViewRef): void {
if (this.map === mapview.mapid) {
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom)
this.marker = L.marker(position, { title: this.name })
this.marker.on('click', this.callCallback.bind(this))
const targetLayer = mapview.markerLayer ?? mapview.map
this.marker.addTo(targetLayer)
}
}
update(mapview: MapViewRef, updated: CharacterData): void {
if (this.map !== updated.map) {
this.remove(mapview)
}
this.map = updated.map
this.position = updated.position
if (!this.marker && this.map === mapview.mapid) {
this.add(mapview)
}
if (this.marker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
this.marker.setLatLng(position)
}
}
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
this.onClick = callback
}
callCallback(e: L.LeafletMouseEvent): void {
if (this.onClick != null) this.onClick(e)
}
leafletMarker: L.Marker | null
remove: (mapview: CharacterMapViewRef) => void
add: (mapview: CharacterMapViewRef) => void
update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => void
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
}
export function createCharacter(data: CharacterData): MapCharacter {
let leafletMarker: L.Marker | null = null
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
const character: MapCharacter = {
id: data.id,
name: data.name,
position: { ...data.position },
type: data.type,
map: data.map,
text: data.name,
value: data.id,
get leafletMarker() {
return leafletMarker
},
remove(mapview: CharacterMapViewRef): void {
if (leafletMarker) {
const layer = mapview.markerLayer ?? mapview.map
layer.removeLayer(leafletMarker)
leafletMarker = null
}
},
add(mapview: CharacterMapViewRef): void {
if (character.map === mapview.mapid) {
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
leafletMarker = L.marker(position, { title: character.name })
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
if (onClick) onClick(e)
})
const targetLayer = mapview.markerLayer ?? mapview.map
leafletMarker.addTo(targetLayer)
}
},
update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void {
if (character.map !== updated.map) {
character.remove(mapview)
}
character.map = updated.map
character.position = { ...updated.position }
if (!leafletMarker && character.map === mapview.mapid) {
character.add(mapview)
}
if (leafletMarker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
leafletMarker.setLatLng(position)
}
},
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
onClick = callback
},
}
return character
}

View File

@@ -8,6 +8,10 @@ export const HnHDefaultZoom = 6
/** When scaleFactor exceeds this, render one label per tile instead of a full grid (avoids 100k+ DOM nodes at zoom 1). */
const GRID_COORD_SCALE_FACTOR_THRESHOLD = 8
export interface GridCoordLayerOptions extends L.GridLayerOptions {
visible?: boolean
}
export const GridCoordLayer = L.GridLayer.extend({
options: {
visible: true,
@@ -64,7 +68,7 @@ export const GridCoordLayer = L.GridLayer.extend({
}
return element
},
}) as unknown as new (options?: L.GridLayerOptions) => L.GridLayer
}) as unknown as new (options?: GridCoordLayerOptions) => L.GridLayer
export const ImageIcon = L.Icon.extend({
options: {

View File

@@ -16,99 +16,111 @@ export interface MapViewRef {
markerLayer: L.LayerGroup
}
export interface MapMarker {
id: number
position: { x: number; y: number }
name: string
image: string
type: string
text: string
value: number
hidden: boolean
map: number
leafletMarker: L.Marker | null
remove: (mapview: MapViewRef) => void
add: (mapview: MapViewRef) => void
update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void
jumpTo: (map: L.Map) => void
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void
}
function detectType(name: string): string {
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
if (name === 'custom') return 'custom'
return name.substring('gfx/terobjs/mm/'.length)
}
export class Marker {
id: number
position: { x: number; y: number }
name: string
image: string
type: string
marker: L.Marker | null = null
text: string
value: number
hidden: boolean
map: number
onClick: ((e: L.LeafletMouseEvent) => void) | null = null
onContext: ((e: L.LeafletMouseEvent) => void) | null = null
export function createMarker(data: MarkerData): MapMarker {
let leafletMarker: L.Marker | null = null
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
constructor(markerData: MarkerData) {
this.id = markerData.id
this.position = markerData.position
this.name = markerData.name
this.image = markerData.image
this.type = detectType(this.image)
this.text = this.name
this.value = this.id
this.hidden = markerData.hidden
this.map = markerData.map
}
const marker: MapMarker = {
id: data.id,
position: { ...data.position },
name: data.name,
image: data.image,
type: detectType(data.image),
text: data.name,
value: data.id,
hidden: data.hidden,
map: data.map,
remove(_mapview: MapViewRef): void {
if (this.marker) {
this.marker.remove()
this.marker = null
}
}
get leafletMarker() {
return leafletMarker
},
add(mapview: MapViewRef): void {
if (!this.hidden) {
let icon: L.Icon
if (this.image === 'gfx/terobjs/mm/custom') {
icon = new ImageIcon({
iconUrl: 'gfx/terobjs/mm/custom.png',
iconSize: [21, 23],
iconAnchor: [11, 21],
popupAnchor: [1, 3],
tooltipAnchor: [1, 3],
})
} else {
icon = new ImageIcon({ iconUrl: `${this.image}.png`, iconSize: [32, 32] })
remove(_mapview: MapViewRef): void {
if (leafletMarker) {
leafletMarker.remove()
leafletMarker = null
}
},
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom)
this.marker = L.marker(position, { icon, title: this.name })
this.marker.addTo(mapview.markerLayer)
this.marker.on('click', this.callClickCallback.bind(this))
this.marker.on('contextmenu', this.callContextCallback.bind(this))
}
add(mapview: MapViewRef): void {
if (!marker.hidden) {
let icon: L.Icon
if (marker.image === 'gfx/terobjs/mm/custom') {
icon = new ImageIcon({
iconUrl: 'gfx/terobjs/mm/custom.png',
iconSize: [21, 23],
iconAnchor: [11, 21],
popupAnchor: [1, 3],
tooltipAnchor: [1, 3],
})
} else {
icon = new ImageIcon({ iconUrl: `${marker.image}.png`, iconSize: [32, 32] })
}
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
leafletMarker = L.marker(position, { icon, title: marker.name })
leafletMarker.addTo(mapview.markerLayer)
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
if (onClick) onClick(e)
})
leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => {
if (onContext) onContext(e)
})
}
},
update(mapview: MapViewRef, updated: MarkerData | MapMarker): void {
marker.position = { ...updated.position }
marker.name = updated.name
marker.hidden = updated.hidden
marker.map = updated.map
if (leafletMarker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
leafletMarker.setLatLng(position)
}
},
jumpTo(map: L.Map): void {
if (leafletMarker) {
const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
leafletMarker.setLatLng(position)
}
},
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
onClick = callback
},
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
onContext = callback
},
}
update(mapview: MapViewRef, updated: MarkerData): void {
this.position = updated.position
this.name = updated.name
this.hidden = updated.hidden
this.map = updated.map
if (this.marker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
this.marker.setLatLng(position)
}
}
jumpTo(map: L.Map): void {
if (this.marker) {
const position = map.unproject([this.position.x, this.position.y], HnHMaxZoom)
this.marker.setLatLng(position)
}
}
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
this.onClick = callback
}
callClickCallback(e: L.LeafletMouseEvent): void {
if (this.onClick != null) this.onClick(e)
}
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
this.onContext = callback
}
callContextCallback(e: L.LeafletMouseEvent): void {
if (this.onContext != null) this.onContext(e)
}
return marker
}

View File

@@ -1,50 +1,52 @@
/**
* Elements should have unique field "id"
*/
export interface Identifiable {
id: number | string
}
export class UniqueList<T extends Identifiable> {
elements: Record<string, T> = {}
update(
dataList: T[],
addCallback?: (it: T) => void,
removeCallback?: (it: T) => void,
updateCallback?: (oldElement: T, newElement: T) => void
): void {
const elementsToAdd = dataList.filter((it) => this.elements[String(it.id)] === undefined)
const elementsToRemove: T[] = []
for (const id of Object.keys(this.elements)) {
if (dataList.find((up) => String(up.id) === id) === undefined) {
const el = this.elements[id]
if (el) elementsToRemove.push(el)
}
}
if (removeCallback) {
elementsToRemove.forEach((it) => removeCallback(it))
}
if (updateCallback) {
dataList.forEach((newElement) => {
const oldElement = this.elements[String(newElement.id)]
if (oldElement) {
updateCallback(oldElement, newElement)
}
})
}
if (addCallback) {
elementsToAdd.forEach((it) => addCallback(it))
}
elementsToRemove.forEach((it) => delete this.elements[String(it.id)])
elementsToAdd.forEach((it) => (this.elements[String(it.id)] = it))
}
getElements(): T[] {
return Object.values(this.elements)
}
byId(id: number | string): T | undefined {
return this.elements[String(id)]
}
export interface UniqueList<T extends Identifiable> {
elements: Record<string, T>
}
export function createUniqueList<T extends Identifiable>(): UniqueList<T> {
return { elements: {} }
}
export function uniqueListUpdate<T extends Identifiable>(
list: UniqueList<T>,
dataList: T[],
addCallback?: (it: T) => void,
removeCallback?: (it: T) => void,
updateCallback?: (oldElement: T, newElement: T) => void
): void {
const elementsToAdd = dataList.filter((it) => list.elements[String(it.id)] === undefined)
const elementsToRemove: T[] = []
for (const id of Object.keys(list.elements)) {
if (dataList.find((up) => String(up.id) === id) === undefined) {
const el = list.elements[id]
if (el) elementsToRemove.push(el)
}
}
if (removeCallback) {
elementsToRemove.forEach((it) => removeCallback(it))
}
if (updateCallback) {
dataList.forEach((newElement) => {
const oldElement = list.elements[String(newElement.id)]
if (oldElement) {
updateCallback(oldElement, newElement)
}
})
}
if (addCallback) {
elementsToAdd.forEach((it) => addCallback(it))
}
elementsToRemove.forEach((it) => delete list.elements[String(it.id)])
elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it))
}
export function uniqueListGetElements<T extends Identifiable>(list: UniqueList<T>): T[] {
return Object.values(list.elements)
}
export function uniqueListById<T extends Identifiable>(list: UniqueList<T>, id: number | string): T | undefined {
return list.elements[String(id)]
}

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('leaflet', () => {
const markerMock = {
on: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(),
}
return {
default: { marker: vi.fn(() => markerMock) },
marker: vi.fn(() => markerMock),
}
})
vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6,
}))
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData {
return {
name: 'Hero',
position: { x: 100, y: 200 },
type: 'player',
id: 1,
map: 1,
...overrides,
}
}
function makeMapViewRef(mapid = 1): CharacterMapViewRef {
return {
map: {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
removeLayer: vi.fn(),
} as unknown as import('leaflet').Map,
mapid,
markerLayer: {
removeLayer: vi.fn(),
addLayer: vi.fn(),
} as unknown as import('leaflet').LayerGroup,
}
}
describe('createCharacter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates character with correct properties', () => {
const char = createCharacter(makeCharData())
expect(char.id).toBe(1)
expect(char.name).toBe('Hero')
expect(char.position).toEqual({ x: 100, y: 200 })
expect(char.type).toBe('player')
expect(char.map).toBe(1)
expect(char.text).toBe('Hero')
expect(char.value).toBe(1)
})
it('starts with null leaflet marker', () => {
const char = createCharacter(makeCharData())
expect(char.leafletMarker).toBeNull()
})
it('add creates marker when character is on correct map', () => {
const char = createCharacter(makeCharData())
const mapview = makeMapViewRef(1)
char.add(mapview)
expect(mapview.map.unproject).toHaveBeenCalled()
})
it('add does not create marker for different map', () => {
const char = createCharacter(makeCharData({ map: 2 }))
const mapview = makeMapViewRef(1)
char.add(mapview)
expect(mapview.map.unproject).not.toHaveBeenCalled()
})
it('update changes position and map', () => {
const char = createCharacter(makeCharData())
const mapview = makeMapViewRef(1)
char.update(mapview, {
...makeCharData(),
position: { x: 300, y: 400 },
map: 2,
})
expect(char.position).toEqual({ x: 300, y: 400 })
expect(char.map).toBe(2)
})
it('remove on a character without leaflet marker does nothing', () => {
const char = createCharacter(makeCharData())
const mapview = makeMapViewRef(1)
char.remove(mapview) // should not throw
expect(char.leafletMarker).toBeNull()
})
it('setClickCallback works', () => {
const char = createCharacter(makeCharData())
const cb = vi.fn()
char.setClickCallback(cb)
})
})

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('leaflet', () => {
const markerMock = {
on: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(),
remove: vi.fn().mockReturnThis(),
}
return {
default: {
marker: vi.fn(() => markerMock),
Icon: class {},
},
marker: vi.fn(() => markerMock),
Icon: class {},
}
})
vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6,
ImageIcon: class {
constructor(_opts: Record<string, unknown>) {}
},
}))
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData {
return {
id: 1,
position: { x: 100, y: 200 },
name: 'Tower',
image: 'gfx/terobjs/mm/tower',
hidden: false,
map: 1,
...overrides,
}
}
function makeMapViewRef(): MapViewRef {
return {
map: {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
} as unknown as import('leaflet').Map,
mapid: 1,
markerLayer: {
removeLayer: vi.fn(),
addLayer: vi.fn(),
} as unknown as import('leaflet').LayerGroup,
}
}
describe('createMarker', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates a marker with correct properties', () => {
const marker = createMarker(makeMarkerData())
expect(marker.id).toBe(1)
expect(marker.name).toBe('Tower')
expect(marker.position).toEqual({ x: 100, y: 200 })
expect(marker.image).toBe('gfx/terobjs/mm/tower')
expect(marker.hidden).toBe(false)
expect(marker.map).toBe(1)
expect(marker.value).toBe(1)
expect(marker.text).toBe('Tower')
})
it('detects quest type', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' }))
expect(marker.type).toBe('quest')
})
it('detects quest type for bumling', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' }))
expect(marker.type).toBe('quest')
})
it('detects custom type', () => {
const marker = createMarker(makeMarkerData({ image: 'custom' }))
expect(marker.type).toBe('custom')
})
it('extracts type from gfx path', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' }))
expect(marker.type).toBe('village')
})
it('starts with null leaflet marker', () => {
const marker = createMarker(makeMarkerData())
expect(marker.leafletMarker).toBeNull()
})
it('add creates a leaflet marker for non-hidden markers', () => {
const marker = createMarker(makeMarkerData())
const mapview = makeMapViewRef()
marker.add(mapview)
expect(mapview.map.unproject).toHaveBeenCalled()
})
it('add does nothing for hidden markers', () => {
const marker = createMarker(makeMarkerData({ hidden: true }))
const mapview = makeMapViewRef()
marker.add(mapview)
expect(mapview.map.unproject).not.toHaveBeenCalled()
})
it('update changes position and name', () => {
const marker = createMarker(makeMarkerData())
const mapview = makeMapViewRef()
marker.update(mapview, {
...makeMarkerData(),
position: { x: 300, y: 400 },
name: 'Castle',
})
expect(marker.position).toEqual({ x: 300, y: 400 })
expect(marker.name).toBe('Castle')
})
it('setClickCallback and setContextMenu work', () => {
const marker = createMarker(makeMarkerData())
const clickCb = vi.fn()
const contextCb = vi.fn()
marker.setClickCallback(clickCb)
marker.setContextMenu(contextCb)
})
it('remove on a marker without leaflet marker does nothing', () => {
const marker = createMarker(makeMarkerData())
const mapview = makeMapViewRef()
marker.remove(mapview) // should not throw
expect(marker.leafletMarker).toBeNull()
})
})

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi } from 'vitest'
import {
createUniqueList,
uniqueListUpdate,
uniqueListGetElements,
uniqueListById,
} from '../UniqueList'
interface Item {
id: number
name: string
}
describe('createUniqueList', () => {
it('creates an empty list', () => {
const list = createUniqueList<Item>()
expect(list.elements).toEqual({})
expect(uniqueListGetElements(list)).toEqual([])
})
})
describe('uniqueListUpdate', () => {
it('adds new elements', () => {
const list = createUniqueList<Item>()
const addCb = vi.fn()
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }], addCb)
expect(addCb).toHaveBeenCalledTimes(2)
expect(uniqueListGetElements(list)).toHaveLength(2)
expect(uniqueListById(list, 1)).toEqual({ id: 1, name: 'a' })
expect(uniqueListById(list, 2)).toEqual({ id: 2, name: 'b' })
})
it('removes elements no longer present', () => {
const list = createUniqueList<Item>()
const removeCb = vi.fn()
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }])
uniqueListUpdate(list, [{ id: 1, name: 'a' }], undefined, removeCb)
expect(removeCb).toHaveBeenCalledTimes(1)
expect(removeCb).toHaveBeenCalledWith({ id: 2, name: 'b' })
expect(uniqueListGetElements(list)).toHaveLength(1)
expect(uniqueListById(list, 2)).toBeUndefined()
})
it('calls update callback for existing elements', () => {
const list = createUniqueList<Item>()
const updateCb = vi.fn()
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
uniqueListUpdate(list, [{ id: 1, name: 'updated' }], undefined, undefined, updateCb)
expect(updateCb).toHaveBeenCalledTimes(1)
expect(updateCb).toHaveBeenCalledWith({ id: 1, name: 'a' }, { id: 1, name: 'updated' })
})
it('handles all callbacks together', () => {
const list = createUniqueList<Item>()
const addCb = vi.fn()
const removeCb = vi.fn()
const updateCb = vi.fn()
uniqueListUpdate(list, [{ id: 1, name: 'keep' }, { id: 2, name: 'remove' }])
uniqueListUpdate(
list,
[{ id: 1, name: 'kept' }, { id: 3, name: 'new' }],
addCb,
removeCb,
updateCb,
)
expect(addCb).toHaveBeenCalledTimes(1)
expect(addCb).toHaveBeenCalledWith({ id: 3, name: 'new' })
expect(removeCb).toHaveBeenCalledTimes(1)
expect(removeCb).toHaveBeenCalledWith({ id: 2, name: 'remove' })
expect(updateCb).toHaveBeenCalledTimes(1)
expect(updateCb).toHaveBeenCalledWith({ id: 1, name: 'keep' }, { id: 1, name: 'kept' })
})
it('works with string IDs', () => {
interface StringItem {
id: string
label: string
}
const list = createUniqueList<StringItem>()
uniqueListUpdate(list, [{ id: 'abc', label: 'first' }])
expect(uniqueListById(list, 'abc')).toEqual({ id: 'abc', label: 'first' })
})
it('handles empty update', () => {
const list = createUniqueList<Item>()
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
const removeCb = vi.fn()
uniqueListUpdate(list, [], undefined, removeCb)
expect(removeCb).toHaveBeenCalledTimes(1)
expect(uniqueListGetElements(list)).toHaveLength(0)
})
it('handles update with no callbacks', () => {
const list = createUniqueList<Item>()
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
uniqueListUpdate(list, [{ id: 2, name: 'b' }])
expect(uniqueListGetElements(list)).toHaveLength(1)
expect(uniqueListById(list, 2)).toEqual({ id: 2, name: 'b' })
})
})
describe('uniqueListGetElements', () => {
it('returns all elements as array', () => {
const list = createUniqueList<Item>()
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }])
const elements = uniqueListGetElements(list)
expect(elements).toHaveLength(2)
expect(elements.map(e => e.id).sort()).toEqual([1, 2])
})
})
describe('uniqueListById', () => {
it('finds element by id', () => {
const list = createUniqueList<Item>()
uniqueListUpdate(list, [{ id: 42, name: 'target' }])
expect(uniqueListById(list, 42)?.name).toBe('target')
})
it('returns undefined for missing id', () => {
const list = createUniqueList<Item>()
expect(uniqueListById(list, 999)).toBeUndefined()
})
})

View File

@@ -1,4 +1,5 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import tailwindcss from '@tailwindcss/vite'
import { viteUriGuard } from './vite/vite-uri-guard'
export default defineNuxtConfig({
@@ -27,20 +28,18 @@ export default defineNuxtConfig({
},
},
modules: ['@nuxtjs/tailwindcss'],
tailwindcss: {
cssPath: '~/assets/css/app.css',
},
modules: ['@nuxt/eslint'],
css: ['~/assets/css/app.css', 'leaflet/dist/leaflet.css', '~/assets/css/leaflet-overrides.css'],
vite: {
plugins: [viteUriGuard()],
plugins: [tailwindcss(), viteUriGuard() as never],
optimizeDeps: {
include: ['leaflet'],
},
},
// Dev: proxy /map API, SSE and grids to Go backend (e.g. docker compose -f docker-compose.dev.yml)
// @ts-expect-error nitro types lag behind Nuxt compat v4
nitro: {
devProxy: {
'/map/api': { target: 'http://backend:3080/map/api', changeOrigin: true },

File diff suppressed because it is too large Load Diff

View File

@@ -6,22 +6,36 @@
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview"
"preview": "nuxt preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"leaflet": "^1.9.4",
"nuxt": "^3.14.1593",
"nuxt": "^3.21.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.12.2",
"@nuxt/eslint": "^1.3.0",
"@nuxt/test-utils": "^4.0.0",
"@tailwindcss/vite": "^4.1.0",
"@types/leaflet": "^1.9.21",
"daisyui": "^3.9.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3"
"@vue/test-utils": "^2.4.6",
"daisyui": "^5.5.0",
"eslint": "^9.21.0",
"happy-dom": "^20.7.0",
"prettier": "^3.5.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.8.0",
"vitest": "^4.0.0",
"vue-tsc": "^2.2.12"
}
}

View File

@@ -24,7 +24,7 @@
class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors"
>
<div class="flex items-center gap-2 min-w-0">
<div class="avatar placeholder">
<div class="avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs">{{ u[0]?.toUpperCase() }}</span>
</div>
@@ -57,7 +57,7 @@
<tr><th>ID</th><th>Name</th><th>Hidden</th><th>Priority</th><th class="text-right"></th></tr>
</thead>
<tbody>
<tr v-for="map in maps" :key="map.ID" class="hover">
<tr v-for="map in maps" :key="map.ID" class="hover:bg-base-300">
<td>{{ map.ID }}</td>
<td>{{ map.Name }}</td>
<td>{{ map.Hidden ? 'Yes' : 'No' }}</td>
@@ -84,25 +84,25 @@
Settings
</h2>
<div class="flex flex-col gap-4">
<div class="form-control w-full max-w-xs">
<fieldset class="fieldset w-full max-w-xs">
<label class="label" for="admin-settings-prefix">Prefix</label>
<input
id="admin-settings-prefix"
v-model="settings.prefix"
type="text"
class="input input-bordered input-sm w-full"
class="input input-sm w-full"
/>
</div>
<div class="form-control w-full max-w-xs">
</fieldset>
<fieldset class="fieldset w-full max-w-xs">
<label class="label" for="admin-settings-title">Title</label>
<input
id="admin-settings-title"
v-model="settings.title"
type="text"
class="input input-bordered input-sm w-full"
class="input input-sm w-full"
/>
</div>
<div class="form-control">
</fieldset>
<fieldset class="fieldset">
<label class="label gap-2 cursor-pointer justify-start" for="admin-settings-default-hide">
<input
id="admin-settings-default-hide"
@@ -112,7 +112,7 @@
/>
Default hide new maps
</label>
</div>
</fieldset>
</div>
<div class="flex justify-end mt-2">
<button class="btn btn-primary btn-sm" :disabled="savingSettings" @click="saveSettings">

View File

@@ -3,22 +3,22 @@
<h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1>
<form v-if="map" @submit.prevent="submit" class="flex flex-col gap-4">
<div class="form-control">
<fieldset class="fieldset">
<label class="label" for="name">Name</label>
<input id="name" v-model="form.name" type="text" class="input input-bordered" required />
</div>
<div class="form-control">
<input id="name" v-model="form.name" type="text" class="input" required />
</fieldset>
<fieldset class="fieldset">
<label class="label cursor-pointer gap-2">
<input v-model="form.hidden" type="checkbox" class="checkbox" />
<span class="label-text">Hidden</span>
<span>Hidden</span>
</label>
</div>
<div class="form-control">
</fieldset>
<fieldset class="fieldset">
<label class="label cursor-pointer gap-2">
<input v-model="form.priority" type="checkbox" class="checkbox" />
<span class="label-text">Priority</span>
<span>Priority</span>
</label>
</div>
</fieldset>
<p v-if="error" class="text-error text-sm">{{ error }}</p>
<div class="flex gap-2">
<button type="submit" class="btn btn-primary" :disabled="loading">

View File

@@ -3,31 +3,31 @@
<h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1>
<form @submit.prevent="submit" class="flex flex-col gap-4">
<div class="form-control">
<fieldset class="fieldset">
<label class="label" for="user">Username</label>
<input
id="user"
v-model="form.user"
type="text"
class="input input-bordered"
class="input"
required
:readonly="!isNew"
/>
</div>
</fieldset>
<PasswordInput
v-model="form.pass"
label="Password (leave blank to keep)"
autocomplete="new-password"
/>
<div class="form-control">
<fieldset class="fieldset">
<label class="label">Auths</label>
<div class="flex flex-wrap gap-2">
<label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`">
<input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" />
<span class="label-text">{{ a }}</span>
<span>{{ a }}</span>
</label>
</div>
</div>
</fieldset>
<p v-if="error" class="text-error text-sm">{{ error }}</p>
<div class="flex gap-2">
<button type="submit" class="btn btn-primary" :disabled="loading">

View File

@@ -17,17 +17,17 @@
<div class="divider text-sm">or</div>
</div>
<form @submit.prevent="submit" class="flex flex-col gap-4">
<div class="form-control">
<fieldset class="fieldset">
<label class="label" for="user">User</label>
<input
id="user"
v-model="user"
type="text"
class="input input-bordered"
class="input"
required
autocomplete="username"
/>
</div>
</fieldset>
<PasswordInput
v-model="pass"
label="Password"

View File

@@ -1,43 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./components/**/*.{js,vue,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./plugins/**/*.{js,ts}',
'./app.vue',
'./lib/**/*.js',
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
},
},
plugins: [require('daisyui')],
daisyui: {
themes: [
'light',
{
dark: {
'color-scheme': 'dark',
primary: '#6366f1',
'primary-content': '#ffffff',
secondary: '#8b5cf6',
'secondary-content': '#ffffff',
accent: '#06b6d4',
'accent-content': '#ffffff',
neutral: '#2a323c',
'neutral-focus': '#242b33',
'neutral-content': '#A6ADBB',
'base-100': '#1d232a',
'base-200': '#191e24',
'base-300': '#15191e',
'base-content': '#A6ADBB',
},
},
],
},
}

View File

@@ -1,3 +1,6 @@
{
"extends": "./.nuxt/tsconfig.json"
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"strict": true
}
}

View File

@@ -10,7 +10,7 @@ export function viteUriGuard(): Plugin {
name: 'vite-uri-guard',
apply: 'serve',
configureServer(server) {
const guard = (req: any, res: any, next: () => void) => {
const guard = (req: { url?: string; originalUrl?: string }, res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, next: () => void) => {
const raw = req.url ?? req.originalUrl ?? ''
try {
decodeURI(raw)

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
globals: true,
},
resolve: {
alias: {
'~': resolve(__dirname, '.'),
'#imports': resolve(__dirname, './__mocks__/nuxt-imports.ts'),
},
},
})