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

@@ -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>