Add initial project structure with backend and frontend setup

- Created backend structure with Go, including main application logic and API endpoints.
- Added Docker support for both development and production environments.
- Introduced frontend using Nuxt 3 with Tailwind CSS for styling.
- Included configuration files for Docker and environment variables.
- Established basic documentation for contributing, development, and deployment processes.
- Set up .gitignore and .dockerignore files to manage ignored files in the repository.
This commit is contained in:
2026-02-24 22:27:05 +03:00
commit 605a31567e
97 changed files with 18350 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
import * as L from 'leaflet'
export class Character {
constructor(characterData) {
this.name = characterData.name
this.position = characterData.position
this.type = characterData.type
this.id = characterData.id
this.map = characterData.map
this.marker = null
this.text = this.name
this.value = this.id
this.onClick = null
}
getId() {
return `${this.name}`
}
remove(mapview) {
if (this.marker) {
const layer = mapview.markerLayer ?? mapview.map
layer.removeLayer(this.marker)
this.marker = null
}
}
add(mapview) {
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, updated) {
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) {
this.onClick = callback
}
callCallback(e) {
if (this.onClick != null) this.onClick(e)
}
}

View File

@@ -0,0 +1,88 @@
import L, { Bounds, LatLng, Point } from 'leaflet'
export const TileSize = 100
export const HnHMaxZoom = 6
export const HnHMinZoom = 1
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 const GridCoordLayer = L.GridLayer.extend({
options: {
visible: true,
},
createTile(coords) {
if (!this.options.visible) {
const element = document.createElement('div')
element.style.width = TileSize + 'px'
element.style.height = TileSize + 'px'
element.classList.add('map-tile')
return element
}
const element = document.createElement('div')
element.style.width = TileSize + 'px'
element.style.height = TileSize + 'px'
element.style.position = 'relative'
element.classList.add('map-tile')
const scaleFactor = Math.pow(2, HnHMaxZoom - coords.z)
const topLeft = { x: coords.x * scaleFactor, y: coords.y * scaleFactor }
const bottomRight = { x: topLeft.x + scaleFactor - 1, y: topLeft.y + scaleFactor - 1 }
if (scaleFactor > GRID_COORD_SCALE_FACTOR_THRESHOLD) {
// Low zoom: one label per tile to avoid hundreds of thousands of DOM nodes (Reset view freeze fix)
const textElement = document.createElement('div')
textElement.classList.add('map-tile-text')
textElement.textContent = `(${topLeft.x}, ${topLeft.y})`
textElement.style.position = 'absolute'
textElement.style.left = '2px'
textElement.style.top = '2px'
textElement.style.fontSize = Math.max(8, 12 - Math.log2(scaleFactor) * 2) + 'px'
element.appendChild(textElement)
return element
}
for (let gx = topLeft.x; gx <= bottomRight.x; gx++) {
for (let gy = topLeft.y; gy <= bottomRight.y; gy++) {
const leftPx = ((gx - topLeft.x) / scaleFactor) * TileSize
const topPx = ((gy - topLeft.y) / scaleFactor) * TileSize
const textElement = document.createElement('div')
textElement.classList.add('map-tile-text')
textElement.textContent = `(${gx}, ${gy})`
textElement.style.position = 'absolute'
textElement.style.left = leftPx + 2 + 'px'
textElement.style.top = topPx + 2 + 'px'
if (scaleFactor > 1) {
textElement.style.fontSize = Math.max(8, 12 - Math.log2(scaleFactor) * 2) + 'px'
}
element.appendChild(textElement)
}
}
return element
},
})
export const ImageIcon = L.Icon.extend({
options: {
iconSize: [32, 32],
iconAnchor: [16, 16],
},
})
const latNormalization = (90.0 * TileSize) / 2500000.0
const lngNormalization = (180.0 * TileSize) / 2500000.0
const HnHProjection = {
project(latlng) {
return new Point(latlng.lat / latNormalization, latlng.lng / lngNormalization)
},
unproject(point) {
return new LatLng(point.x * latNormalization, point.y * lngNormalization)
},
bounds: (() => new Bounds([-latNormalization, -lngNormalization], [latNormalization, lngNormalization]))(),
}
export const HnHCRS = L.extend({}, L.CRS.Simple, {
projection: HnHProjection,
})

View File

@@ -0,0 +1,89 @@
import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes'
import * as L from 'leaflet'
function detectType(name) {
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 {
constructor(markerData) {
this.id = markerData.id
this.position = markerData.position
this.name = markerData.name
this.image = markerData.image
this.type = detectType(this.image)
this.marker = null
this.text = this.name
this.value = this.id
this.hidden = markerData.hidden
this.map = markerData.map
this.onClick = null
this.onContext = null
}
remove(mapview) {
if (this.marker) {
this.marker.remove()
this.marker = null
}
}
add(mapview) {
if (!this.hidden) {
let 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] })
}
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))
}
}
update(mapview, updated) {
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) {
if (this.marker) {
const position = map.unproject([this.position.x, this.position.y], HnHMaxZoom)
this.marker.setLatLng(position)
}
}
setClickCallback(callback) {
this.onClick = callback
}
callClickCallback(e) {
if (this.onClick != null) this.onClick(e)
}
setContextMenu(callback) {
this.onContext = callback
}
callContextCallback(e) {
if (this.onContext != null) this.onContext(e)
}
}

View File

@@ -0,0 +1,73 @@
import L, { Util, Browser } from 'leaflet'
export const SmartTileLayer = L.TileLayer.extend({
cache: {},
invalidTile: '',
map: 0,
getTileUrl(coords) {
if (!this._map) return this.invalidTile
let zoom
try {
zoom = this._getZoomForUrl()
} catch {
return this.invalidTile
}
return this.getTrueTileUrl(coords, zoom)
},
getTrueTileUrl(coords, zoom) {
const data = {
r: Browser.retina ? '@2x' : '',
s: this._getSubdomain(coords),
x: coords.x,
y: coords.y,
map: this.map,
z: zoom,
}
if (this._map && !this._map.options.crs.infinite) {
const invertedY = this._globalTileRange.max.y - coords.y
if (this.options.tms) {
data.y = invertedY
}
data['-y'] = invertedY
}
const cacheKey = `${data.map}:${data.x}:${data.y}:${data.z}`
data.cache = this.cache[cacheKey]
// Don't request tiles for invalid/unknown map (avoids 404 spam in console)
if (data.map === undefined || data.map === null || data.map < 1) {
return this.invalidTile
}
// Only use placeholder when server explicitly marks tile as invalid (-1)
if (data.cache === -1) {
return this.invalidTile
}
// Allow tile request when map is valid even if SSE snapshot hasn't arrived yet
// (avoids empty map when proxy/SSE delays or drops first message)
if (data.cache === undefined || data.cache === null) {
data.cache = 0
}
return Util.template(this._url, Util.extend(data, this.options))
},
refresh(x, y, z) {
let zoom = z
const maxZoom = this.options.maxZoom
const zoomReverse = this.options.zoomReverse
const zoomOffset = this.options.zoomOffset
if (zoomReverse) {
zoom = maxZoom - zoom
}
zoom += zoomOffset
const key = `${x}:${y}:${zoom}`
const tile = this._tiles[key]
if (tile) {
tile.el.src = this.getTrueTileUrl({ x, y }, z)
}
},
})

View File

@@ -0,0 +1,39 @@
/**
* Elements should have unique field "id"
*/
export class UniqueList {
constructor() {
this.elements = {}
}
update(dataList, addCallback, removeCallback, updateCallback) {
const elementsToAdd = dataList.filter((it) => this.elements[it.id] === undefined)
const elementsToRemove = Object.keys(this.elements)
.filter((it) => dataList.find((up) => String(up.id) === it) === undefined)
.map((id) => this.elements[id])
if (removeCallback) {
elementsToRemove.forEach((it) => removeCallback(it))
}
if (updateCallback) {
dataList.forEach((newElement) => {
const oldElement = this.elements[newElement.id]
if (oldElement) {
updateCallback(oldElement, newElement)
}
})
}
if (addCallback) {
elementsToAdd.forEach((it) => addCallback(it))
}
elementsToRemove.forEach((it) => delete this.elements[it.id])
elementsToAdd.forEach((it) => (this.elements[it.id] = it))
}
getElements() {
return Object.values(this.elements)
}
byId(id) {
return this.elements[id]
}
}