Refactor Dockerignore and enhance Leaflet styles for improved map functionality

- Updated .dockerignore to streamline build context by ensuring unnecessary files are excluded.
- Refined CSS styles in leaflet-overrides.css to enhance visual consistency and user experience for map tooltips and popups.
- Improved map initialization and update handling in useMapApi and useMapUpdates composables for better performance and reliability.
This commit is contained in:
2026-03-04 18:16:41 +03:00
parent 3968bdc76f
commit 179357bc93
14 changed files with 3738 additions and 3738 deletions

View File

@@ -1,27 +1,27 @@
# Git and IDE # Git and IDE
.git .git
.gitignore .gitignore
.cursor .cursor
.cursorignore .cursorignore
*.md *.md
*.plan.md *.plan.md
# Old Vue 2 frontend (not used in build) # Old Vue 2 frontend (not used in build)
frontend/node_modules frontend/node_modules
frontend/dist frontend/dist
# Nuxt (built in frontendbuilder stage) # Nuxt (built in frontendbuilder stage)
frontend-nuxt/node_modules frontend-nuxt/node_modules
frontend-nuxt/.nuxt frontend-nuxt/.nuxt
frontend-nuxt/.output frontend-nuxt/.output
# Runtime data (mounted at run time, not needed for build) # Runtime data (mounted at run time, not needed for build)
grids grids
# Backup dir often has root-only permissions; exclude from build context # Backup dir often has root-only permissions; exclude from build context
backup backup
# Misc # Misc
*.log *.log
.env* .env*
.DS_Store .DS_Store

View File

@@ -1,59 +1,59 @@
/* Map container background from theme (DaisyUI base-200) */ /* Map container background from theme (DaisyUI base-200) */
.leaflet-container { .leaflet-container {
background: var(--color-base-200); background: var(--color-base-200);
} }
/* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied /* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied
(e.g. due to cache, Nuxt hydration, or load event order). */ (e.g. due to cache, Nuxt hydration, or load event order). */
.leaflet-tile { .leaflet-tile {
visibility: visible !important; visibility: visible !important;
} }
/* Subtle highlight when a tile is updated via SSE (reduced intensity to limit flicker). */ /* Subtle highlight when a tile is updated via SSE (reduced intensity to limit flicker). */
@keyframes tile-fresh-glow { @keyframes tile-fresh-glow {
0% { 0% {
opacity: 0.92; opacity: 0.92;
} }
100% { 100% {
opacity: 1; opacity: 1;
} }
} }
.leaflet-tile.tile-fresh { .leaflet-tile.tile-fresh {
animation: tile-fresh-glow 0.4s ease-out; animation: tile-fresh-glow 0.4s ease-out;
} }
/* Leaflet tooltip: use theme colors (dark/light) */ /* Leaflet tooltip: use theme colors (dark/light) */
.leaflet-tooltip { .leaflet-tooltip {
background-color: var(--color-base-100); background-color: var(--color-base-100);
color: var(--color-base-content); color: var(--color-base-content);
border-color: var(--color-base-300); border-color: var(--color-base-300);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
} }
.leaflet-tooltip-top:before { .leaflet-tooltip-top:before {
border-top-color: var(--color-base-100); border-top-color: var(--color-base-100);
} }
.leaflet-tooltip-bottom:before { .leaflet-tooltip-bottom:before {
border-bottom-color: var(--color-base-100); border-bottom-color: var(--color-base-100);
} }
.leaflet-tooltip-left:before { .leaflet-tooltip-left:before {
border-left-color: var(--color-base-100); border-left-color: var(--color-base-100);
} }
.leaflet-tooltip-right:before { .leaflet-tooltip-right:before {
border-right-color: var(--color-base-100); border-right-color: var(--color-base-100);
} }
/* Leaflet popup: use theme colors (dark/light) */ /* Leaflet popup: use theme colors (dark/light) */
.leaflet-popup-content-wrapper, .leaflet-popup-content-wrapper,
.leaflet-popup-tip { .leaflet-popup-tip {
background: var(--color-base-100); background: var(--color-base-100);
color: var(--color-base-content); color: var(--color-base-content);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.35); box-shadow: 0 3px 14px rgba(0, 0, 0, 0.35);
} }
.leaflet-container a.leaflet-popup-close-button { .leaflet-container a.leaflet-popup-close-button {
color: var(--color-base-content); color: var(--color-base-content);
opacity: 0.7; opacity: 0.7;
} }
.leaflet-container a.leaflet-popup-close-button:hover, .leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus { .leaflet-container a.leaflet-popup-close-button:focus {
opacity: 1; opacity: 1;
} }

View File

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

View File

@@ -1,295 +1,295 @@
import type { import type {
Character, Character,
ConfigResponse, ConfigResponse,
MapInfo, MapInfo,
MapInfoAdmin, MapInfoAdmin,
Marker, Marker,
MeResponse, MeResponse,
SettingsResponse, SettingsResponse,
} from '~/types/api' } from '~/types/api'
export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeResponse, SettingsResponse } export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeResponse, SettingsResponse }
// Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue) // Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
const onApiErrorCallbacks = new Map<symbol, () => void>() const onApiErrorCallbacks = new Map<symbol, () => void>()
// In-flight dedup: one me() request at a time; concurrent callers share the same promise. // In-flight dedup: one me() request at a time; concurrent callers share the same promise.
let mePromise: Promise<MeResponse> | null = null let mePromise: Promise<MeResponse> | null = null
// In-flight dedup for GET endpoints: same path + method shares one request across all callers. // In-flight dedup for GET endpoints: same path + method shares one request across all callers.
const inFlightByKey = new Map<string, Promise<unknown>>() const inFlightByKey = new Map<string, Promise<unknown>>()
export function useMapApi() { export function useMapApi() {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const apiBase = config.public.apiBase as string const apiBase = config.public.apiBase as string
/** Subscribe to API auth errors (401). Returns unsubscribe function. */ /** Subscribe to API auth errors (401). Returns unsubscribe function. */
function onApiError(cb: () => void): () => void { function onApiError(cb: () => void): () => void {
const id = Symbol() const id = Symbol()
onApiErrorCallbacks.set(id, cb) onApiErrorCallbacks.set(id, cb)
return () => onApiErrorCallbacks.delete(id) return () => onApiErrorCallbacks.delete(id)
} }
async function request<T>(path: string, opts?: RequestInit): Promise<T> { async function request<T>(path: string, opts?: RequestInit): Promise<T> {
const url = path.startsWith('http') ? path : `${apiBase}/${path.replace(/^\//, '')}` const url = path.startsWith('http') ? path : `${apiBase}/${path.replace(/^\//, '')}`
const res = await fetch(url, { credentials: 'include', ...opts }) const res = await fetch(url, { credentials: 'include', ...opts })
// Only redirect to login on 401 (session invalid); 403 = forbidden (no permission) // Only redirect to login on 401 (session invalid); 403 = forbidden (no permission)
if (res.status === 401) { if (res.status === 401) {
onApiErrorCallbacks.forEach((cb) => cb()) onApiErrorCallbacks.forEach((cb) => cb())
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
if (res.status === 403) throw new Error('Forbidden') if (res.status === 403) throw new Error('Forbidden')
if (!res.ok) throw new Error(`API ${res.status}`) if (!res.ok) throw new Error(`API ${res.status}`)
if (res.headers.get('content-type')?.includes('application/json')) { if (res.headers.get('content-type')?.includes('application/json')) {
return res.json() as Promise<T> return res.json() as Promise<T>
} }
return undefined as T return undefined as T
} }
function requestDeduped<T>(path: string, opts?: RequestInit): Promise<T> { function requestDeduped<T>(path: string, opts?: RequestInit): Promise<T> {
const key = path + (opts?.method ?? 'GET') const key = path + (opts?.method ?? 'GET')
const existing = inFlightByKey.get(key) const existing = inFlightByKey.get(key)
if (existing) return existing as Promise<T> if (existing) return existing as Promise<T>
const p = request<T>(path, opts).finally(() => { const p = request<T>(path, opts).finally(() => {
inFlightByKey.delete(key) inFlightByKey.delete(key)
}) })
inFlightByKey.set(key, p) inFlightByKey.set(key, p)
return p return p
} }
async function getConfig() { async function getConfig() {
return requestDeduped<ConfigResponse>('config') return requestDeduped<ConfigResponse>('config')
} }
async function getCharacters() { async function getCharacters() {
return requestDeduped<Character[]>('v1/characters') return requestDeduped<Character[]>('v1/characters')
} }
async function getMarkers() { async function getMarkers() {
return requestDeduped<Marker[]>('v1/markers') return requestDeduped<Marker[]>('v1/markers')
} }
async function getMaps() { async function getMaps() {
return requestDeduped<Record<string, MapInfo>>('maps') return requestDeduped<Record<string, MapInfo>>('maps')
} }
// Auth // Auth
async function login(user: string, pass: string) { async function login(user: string, pass: string) {
const res = await fetch(`${apiBase}/login`, { const res = await fetch(`${apiBase}/login`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, pass }), body: JSON.stringify({ user, pass }),
}) })
if (res.status === 401) { if (res.status === 401) {
const data = (await res.json().catch(() => ({}))) as { error?: string } const data = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(data.error || 'Unauthorized') throw new Error(data.error || 'Unauthorized')
} }
if (!res.ok) throw new Error(`API ${res.status}`) if (!res.ok) throw new Error(`API ${res.status}`)
return res.json() as Promise<MeResponse> return res.json() as Promise<MeResponse>
} }
/** OAuth login URL for redirect (full page navigation). */ /** OAuth login URL for redirect (full page navigation). */
function oauthLoginUrl(provider: string, redirect?: string): string { function oauthLoginUrl(provider: string, redirect?: string): string {
const url = new URL(`${apiBase}/oauth/${provider}/login`) const url = new URL(`${apiBase}/oauth/${provider}/login`)
if (redirect) url.searchParams.set('redirect', redirect) if (redirect) url.searchParams.set('redirect', redirect)
return url.toString() return url.toString()
} }
/** List of configured OAuth providers. */ /** List of configured OAuth providers. */
async function oauthProviders(): Promise<string[]> { async function oauthProviders(): Promise<string[]> {
try { try {
const res = await fetch(`${apiBase}/oauth/providers`, { credentials: 'include' }) const res = await fetch(`${apiBase}/oauth/providers`, { credentials: 'include' })
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return Array.isArray(data) ? data : [] return Array.isArray(data) ? data : []
} catch { } catch {
return [] return []
} }
} }
async function logout() { async function logout() {
mePromise = null mePromise = null
inFlightByKey.clear() inFlightByKey.clear()
await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' }) await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' })
} }
async function me() { async function me() {
if (mePromise) return mePromise if (mePromise) return mePromise
mePromise = request<MeResponse>('me').finally(() => { mePromise = request<MeResponse>('me').finally(() => {
mePromise = null mePromise = null
}) })
return mePromise return mePromise
} }
async function meUpdate(body: { email?: string }) { async function meUpdate(body: { email?: string }) {
await request('me', { await request('me', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
/** Public: whether first-time setup (no users) is required. */ /** Public: whether first-time setup (no users) is required. */
async function setupRequired(): Promise<{ setupRequired: boolean }> { async function setupRequired(): Promise<{ setupRequired: boolean }> {
const res = await fetch(`${apiBase}/setup`, { credentials: 'include' }) const res = await fetch(`${apiBase}/setup`, { credentials: 'include' })
if (!res.ok) throw new Error(`API ${res.status}`) if (!res.ok) throw new Error(`API ${res.status}`)
return res.json() as Promise<{ setupRequired: boolean }> return res.json() as Promise<{ setupRequired: boolean }>
} }
// Profile // Profile
async function meTokens() { async function meTokens() {
const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' }) const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' })
return data?.tokens ?? [] return data?.tokens ?? []
} }
async function mePassword(pass: string) { async function mePassword(pass: string) {
await request('me/password', { await request('me/password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pass }), body: JSON.stringify({ pass }),
}) })
} }
// Admin // Admin
async function adminUsers() { async function adminUsers() {
return request<string[]>('admin/users') return request<string[]>('admin/users')
} }
async function adminUserByName(name: string) { async function adminUserByName(name: string) {
return request<{ username: string; auths: string[] }>(`admin/users/${encodeURIComponent(name)}`) return request<{ username: string; auths: string[] }>(`admin/users/${encodeURIComponent(name)}`)
} }
async function adminUserPost(body: { user: string; pass?: string; auths: string[] }) { async function adminUserPost(body: { user: string; pass?: string; auths: string[] }) {
await request('admin/users', { await request('admin/users', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
async function adminUserDelete(name: string) { async function adminUserDelete(name: string) {
await request(`admin/users/${encodeURIComponent(name)}`, { method: 'DELETE' }) await request(`admin/users/${encodeURIComponent(name)}`, { method: 'DELETE' })
} }
async function adminSettings() { async function adminSettings() {
return request<SettingsResponse>('admin/settings') return request<SettingsResponse>('admin/settings')
} }
async function adminSettingsPost(body: { prefix?: string; defaultHide?: boolean; title?: string }) { async function adminSettingsPost(body: { prefix?: string; defaultHide?: boolean; title?: string }) {
await request('admin/settings', { await request('admin/settings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
async function adminMaps() { async function adminMaps() {
return request<MapInfoAdmin[]>('admin/maps') return request<MapInfoAdmin[]>('admin/maps')
} }
async function adminMapPost(id: number, body: { name: string; hidden: boolean; priority: boolean }) { async function adminMapPost(id: number, body: { name: string; hidden: boolean; priority: boolean }) {
await request(`admin/maps/${id}`, { await request(`admin/maps/${id}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
async function adminMapToggleHidden(id: number) { async function adminMapToggleHidden(id: number) {
return request<MapInfoAdmin>(`admin/maps/${id}/toggle-hidden`, { method: 'POST' }) return request<MapInfoAdmin>(`admin/maps/${id}/toggle-hidden`, { method: 'POST' })
} }
async function adminWipe() { async function adminWipe() {
await request('admin/wipe', { method: 'POST' }) await request('admin/wipe', { method: 'POST' })
} }
async function adminRebuildZooms() { async function adminRebuildZooms() {
const res = await fetch(`${apiBase}/admin/rebuildZooms`, { const res = await fetch(`${apiBase}/admin/rebuildZooms`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}) })
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
onApiErrorCallbacks.forEach((cb) => cb()) onApiErrorCallbacks.forEach((cb) => cb())
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
if (res.status !== 200 && res.status !== 202) throw new Error(`API ${res.status}`) if (res.status !== 200 && res.status !== 202) throw new Error(`API ${res.status}`)
} }
async function adminRebuildZoomsStatus(): Promise<{ running: boolean }> { async function adminRebuildZoomsStatus(): Promise<{ running: boolean }> {
return request<{ running: boolean }>('admin/rebuildZooms/status') return request<{ running: boolean }>('admin/rebuildZooms/status')
} }
function adminExportUrl() { function adminExportUrl() {
return `${apiBase}/admin/export` return `${apiBase}/admin/export`
} }
async function adminMerge(formData: FormData) { async function adminMerge(formData: FormData) {
const res = await fetch(`${apiBase}/admin/merge`, { const res = await fetch(`${apiBase}/admin/merge`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: formData, body: formData,
}) })
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
onApiErrorCallbacks.forEach((cb) => cb()) onApiErrorCallbacks.forEach((cb) => cb())
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
if (!res.ok) throw new Error(`API ${res.status}`) if (!res.ok) throw new Error(`API ${res.status}`)
} }
async function adminWipeTile(params: { map: number; x: number; y: number }) { async function adminWipeTile(params: { map: number; x: number; y: number }) {
const qs = new URLSearchParams({ map: String(params.map), x: String(params.x), y: String(params.y) }) const qs = new URLSearchParams({ map: String(params.map), x: String(params.x), y: String(params.y) })
return request(`admin/wipeTile?${qs}`) return request(`admin/wipeTile?${qs}`)
} }
async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) { async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) {
const qs = new URLSearchParams({ const qs = new URLSearchParams({
map: String(params.map), map: String(params.map),
fx: String(params.fx), fx: String(params.fx),
fy: String(params.fy), fy: String(params.fy),
tx: String(params.tx), tx: String(params.tx),
ty: String(params.ty), ty: String(params.ty),
}) })
return request(`admin/setCoords?${qs}`) return request(`admin/setCoords?${qs}`)
} }
async function adminHideMarker(params: { id: number }) { async function adminHideMarker(params: { id: number }) {
const qs = new URLSearchParams({ id: String(params.id) }) const qs = new URLSearchParams({ id: String(params.id) })
return request(`admin/hideMarker?${qs}`) return request(`admin/hideMarker?${qs}`)
} }
return { return {
apiBase, apiBase,
onApiError, onApiError,
getConfig, getConfig,
getCharacters, getCharacters,
getMarkers, getMarkers,
getMaps, getMaps,
login, login,
logout, logout,
me, me,
meUpdate, meUpdate,
oauthLoginUrl, oauthLoginUrl,
oauthProviders, oauthProviders,
setupRequired, setupRequired,
meTokens, meTokens,
mePassword, mePassword,
adminUsers, adminUsers,
adminUserByName, adminUserByName,
adminUserPost, adminUserPost,
adminUserDelete, adminUserDelete,
adminSettings, adminSettings,
adminSettingsPost, adminSettingsPost,
adminMaps, adminMaps,
adminMapPost, adminMapPost,
adminMapToggleHidden, adminMapToggleHidden,
adminWipe, adminWipe,
adminRebuildZooms, adminRebuildZooms,
adminRebuildZoomsStatus, adminRebuildZoomsStatus,
adminExportUrl, adminExportUrl,
adminMerge, adminMerge,
adminWipeTile, adminWipeTile,
adminSetCoords, adminSetCoords,
adminHideMarker, adminHideMarker,
} }
} }

View File

@@ -1,111 +1,111 @@
import type L from 'leaflet' import type L from 'leaflet'
import { HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes' import { HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { SmartTileLayer } from '~/lib/SmartTileLayer' import { SmartTileLayer } from '~/lib/SmartTileLayer'
import type { MapInfo } from '~/types/api' import type { MapInfo } from '~/types/api'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer> type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
/** Known marker icon paths (without .png) to preload so markers render without broken images. */ /** Known marker icon paths (without .png) to preload so markers render without broken images. */
const MARKER_ICON_PATHS = [ const MARKER_ICON_PATHS = [
'gfx/terobjs/mm/custom', 'gfx/terobjs/mm/custom',
'gfx/terobjs/mm/tower', 'gfx/terobjs/mm/tower',
'gfx/terobjs/mm/village', 'gfx/terobjs/mm/village',
'gfx/terobjs/mm/dungeon', 'gfx/terobjs/mm/dungeon',
'gfx/terobjs/mm/cave', 'gfx/terobjs/mm/cave',
'gfx/terobjs/mm/settlement', 'gfx/terobjs/mm/settlement',
'gfx/invobjs/small/bush', 'gfx/invobjs/small/bush',
'gfx/invobjs/small/bumling', 'gfx/invobjs/small/bumling',
] ]
/** /**
* Preloads marker icon images so they are in the browser cache before markers render. * Preloads marker icon images so they are in the browser cache before markers render.
* Call from client only. resolvePath should produce absolute URLs for static assets. * Call from client only. resolvePath should produce absolute URLs for static assets.
*/ */
export function preloadMarkerIcons(resolvePath: (path: string) => string): void { export function preloadMarkerIcons(resolvePath: (path: string) => string): void {
if (import.meta.server) return if (import.meta.server) return
for (const base of MARKER_ICON_PATHS) { for (const base of MARKER_ICON_PATHS) {
const url = resolvePath(`${base}.png`) const url = resolvePath(`${base}.png`)
const img = new Image() const img = new Image()
img.src = url img.src = url
} }
} }
export interface MapInitResult { export interface MapInitResult {
map: L.Map map: L.Map
layer: SmartTileLayerInstance layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance overlayLayer: SmartTileLayerInstance
markerLayer: L.LayerGroup markerLayer: L.LayerGroup
backendBase: string backendBase: string
} }
export async function initLeafletMap( export async function initLeafletMap(
element: HTMLElement, element: HTMLElement,
mapsList: MapInfo[], mapsList: MapInfo[],
initialMapId: number initialMapId: number
): Promise<MapInitResult> { ): Promise<MapInitResult> {
const L = (await import('leaflet')).default const L = (await import('leaflet')).default
const map = L.map(element, { const map = L.map(element, {
minZoom: HnHMinZoom, minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom, maxZoom: HnHMaxZoom,
crs: HnHCRS, crs: HnHCRS,
attributionControl: false, attributionControl: false,
zoomControl: false, zoomControl: false,
inertia: true, inertia: true,
zoomAnimation: true, zoomAnimation: true,
fadeAnimation: true, fadeAnimation: true,
markerZoomAnimation: true, markerZoomAnimation: true,
}) })
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api' const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map' const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}` const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
const layer = new SmartTileLayer(tileUrl, { const layer = new SmartTileLayer(tileUrl, {
minZoom: 1, minZoom: 1,
maxZoom: 6, maxZoom: 6,
maxNativeZoom: 6, maxNativeZoom: 6,
zoomOffset: 0, zoomOffset: 0,
zoomReverse: true, zoomReverse: true,
tileSize: TileSize, tileSize: TileSize,
updateWhenIdle: true, updateWhenIdle: true,
keepBuffer: 4, keepBuffer: 4,
}) })
layer.map = initialMapId layer.map = initialMapId
layer.invalidTile = layer.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
layer.addTo(map) layer.addTo(map)
const overlayLayer = new SmartTileLayer(tileUrl, { const overlayLayer = new SmartTileLayer(tileUrl, {
minZoom: 1, minZoom: 1,
maxZoom: 6, maxZoom: 6,
maxNativeZoom: 6, maxNativeZoom: 6,
zoomOffset: 0, zoomOffset: 0,
zoomReverse: true, zoomReverse: true,
tileSize: TileSize, tileSize: TileSize,
opacity: 0.5, opacity: 0.5,
updateWhenIdle: true, updateWhenIdle: true,
keepBuffer: 4, keepBuffer: 4,
}) })
overlayLayer.map = -1 overlayLayer.map = -1
overlayLayer.invalidTile = overlayLayer.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
overlayLayer.addTo(map) overlayLayer.addTo(map)
const markerLayer = L.layerGroup() const markerLayer = L.layerGroup()
markerLayer.addTo(map) markerLayer.addTo(map)
markerLayer.setZIndex(600) markerLayer.setZIndex(600)
const baseURL = useRuntimeConfig().app.baseURL ?? '/' const baseURL = useRuntimeConfig().app.baseURL ?? '/'
const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/' const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/'
L.Icon.Default.imagePath = markerIconPath L.Icon.Default.imagePath = markerIconPath
const resolvePath = (path: string) => { const resolvePath = (path: string) => {
const p = path.startsWith('/') ? path : `/${path}` const p = path.startsWith('/') ? path : `/${path}`
return baseURL === '/' ? p : `${baseURL.replace(/\/$/, '')}${p}` return baseURL === '/' ? p : `${baseURL.replace(/\/$/, '')}${p}`
} }
preloadMarkerIcons(resolvePath) preloadMarkerIcons(resolvePath)
return { map, layer, overlayLayer, markerLayer, backendBase } return { map, layer, overlayLayer, markerLayer, backendBase }
} }

View File

@@ -1,171 +1,171 @@
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { SmartTileLayer } from '~/lib/SmartTileLayer' import type { SmartTileLayer } from '~/lib/SmartTileLayer'
import { TileSize } from '~/lib/LeafletCustomTypes' import { TileSize } from '~/lib/LeafletCustomTypes'
import type L from 'leaflet' import type L from 'leaflet'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer> type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
export type SseConnectionState = 'connecting' | 'open' | 'error' export type SseConnectionState = 'connecting' | 'open' | 'error'
interface TileUpdate { interface TileUpdate {
M: number M: number
X: number X: number
Y: number Y: number
Z: number Z: number
T: number T: number
} }
interface MergeEvent { interface MergeEvent {
From: number From: number
To: number To: number
Shift: { x: number; y: number } Shift: { x: number; y: number }
} }
export interface UseMapUpdatesOptions { export interface UseMapUpdatesOptions {
backendBase: string backendBase: string
layer: SmartTileLayerInstance layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance overlayLayer: SmartTileLayerInstance
map: L.Map map: L.Map
getCurrentMapId: () => number getCurrentMapId: () => number
onMerge: (mapTo: number, shift: { x: number; y: number }) => void onMerge: (mapTo: number, shift: { x: number; y: number }) => void
/** Optional ref updated with SSE connection state for reconnection indicator. */ /** Optional ref updated with SSE connection state for reconnection indicator. */
connectionStateRef?: Ref<SseConnectionState> connectionStateRef?: Ref<SseConnectionState>
} }
export interface UseMapUpdatesReturn { export interface UseMapUpdatesReturn {
cleanup: () => void cleanup: () => void
} }
const RECONNECT_INITIAL_MS = 1000 const RECONNECT_INITIAL_MS = 1000
const RECONNECT_MAX_MS = 30000 const RECONNECT_MAX_MS = 30000
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn { export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options
const updatesPath = `${backendBase}/updates` const updatesPath = `${backendBase}/updates`
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
const BATCH_MS = 50 const BATCH_MS = 50
let batch: TileUpdate[] = [] let batch: TileUpdate[] = []
let batchScheduled = false let batchScheduled = false
let source: EventSource | null = null let source: EventSource | null = null
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null
let reconnectDelayMs = RECONNECT_INITIAL_MS let reconnectDelayMs = RECONNECT_INITIAL_MS
let destroyed = false let destroyed = false
const VISIBLE_TILE_BUFFER = 1 const VISIBLE_TILE_BUFFER = 1
function getVisibleTileBounds() { function getVisibleTileBounds() {
const zoom = map.getZoom() const zoom = map.getZoom()
const px = map.getPixelBounds() const px = map.getPixelBounds()
if (!px) return null if (!px) return null
return { return {
zoom, zoom,
minX: Math.floor(px.min.x / TileSize) - VISIBLE_TILE_BUFFER, minX: Math.floor(px.min.x / TileSize) - VISIBLE_TILE_BUFFER,
maxX: Math.ceil(px.max.x / TileSize) + VISIBLE_TILE_BUFFER, maxX: Math.ceil(px.max.x / TileSize) + VISIBLE_TILE_BUFFER,
minY: Math.floor(px.min.y / TileSize) - VISIBLE_TILE_BUFFER, minY: Math.floor(px.min.y / TileSize) - VISIBLE_TILE_BUFFER,
maxY: Math.ceil(px.max.y / TileSize) + VISIBLE_TILE_BUFFER, maxY: Math.ceil(px.max.y / TileSize) + VISIBLE_TILE_BUFFER,
} }
} }
function applyBatch() { function applyBatch() {
batchScheduled = false batchScheduled = false
if (batch.length === 0) return if (batch.length === 0) return
const updates = batch const updates = batch
batch = [] batch = []
for (const u of updates) { for (const u of updates) {
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}` const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
layer.cache[key] = u.T layer.cache[key] = u.T
overlayLayer.cache[key] = u.T overlayLayer.cache[key] = u.T
} }
const visible = getVisibleTileBounds() const visible = getVisibleTileBounds()
for (const u of updates) { for (const u of updates) {
if (visible && u.Z !== visible.zoom) continue if (visible && u.Z !== visible.zoom) continue
if ( if (
visible && visible &&
(u.X < visible.minX || u.X > visible.maxX || u.Y < visible.minY || u.Y > visible.maxY) (u.X < visible.minX || u.X > visible.maxX || u.Y < visible.minY || u.Y > visible.maxY)
) )
continue continue
if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z) 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) if (overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
} }
} }
function scheduleBatch() { function scheduleBatch() {
if (batchScheduled) return if (batchScheduled) return
batchScheduled = true batchScheduled = true
setTimeout(applyBatch, BATCH_MS) setTimeout(applyBatch, BATCH_MS)
} }
function connect() { function connect() {
if (destroyed || !import.meta.client) return if (destroyed || !import.meta.client) return
source = new EventSource(updatesUrl) source = new EventSource(updatesUrl)
if (connectionStateRef) connectionStateRef.value = 'connecting' if (connectionStateRef) connectionStateRef.value = 'connecting'
source.onopen = () => { source.onopen = () => {
if (connectionStateRef) connectionStateRef.value = 'open' if (connectionStateRef) connectionStateRef.value = 'open'
reconnectDelayMs = RECONNECT_INITIAL_MS reconnectDelayMs = RECONNECT_INITIAL_MS
} }
source.onerror = () => { source.onerror = () => {
if (destroyed || !source) return if (destroyed || !source) return
if (connectionStateRef) connectionStateRef.value = 'error' if (connectionStateRef) connectionStateRef.value = 'error'
source.close() source.close()
source = null source = null
if (destroyed) return if (destroyed) return
reconnectTimeoutId = setTimeout(() => { reconnectTimeoutId = setTimeout(() => {
reconnectTimeoutId = null reconnectTimeoutId = null
connect() connect()
reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS) reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS)
}, reconnectDelayMs) }, reconnectDelayMs)
} }
source.onmessage = (event: MessageEvent) => { source.onmessage = (event: MessageEvent) => {
if (connectionStateRef) connectionStateRef.value = 'open' if (connectionStateRef) connectionStateRef.value = 'open'
try { try {
const raw: unknown = event?.data const raw: unknown = event?.data
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
const updates: unknown = JSON.parse(raw) const updates: unknown = JSON.parse(raw)
if (!Array.isArray(updates)) return if (!Array.isArray(updates)) return
for (const u of updates as TileUpdate[]) { for (const u of updates as TileUpdate[]) {
batch.push(u) batch.push(u)
} }
scheduleBatch() scheduleBatch()
} catch { } catch {
// Ignore parse errors from SSE // Ignore parse errors from SSE
} }
} }
source.addEventListener('merge', (e: MessageEvent) => { source.addEventListener('merge', (e: MessageEvent) => {
try { try {
const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}') const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}')
if (getCurrentMapId() === merge.From) { if (getCurrentMapId() === merge.From) {
const point = map.project(map.getCenter(), 6) const point = map.project(map.getCenter(), 6)
const shift = { const shift = {
x: Math.floor(point.x / TileSize) + merge.Shift.x, x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y, y: Math.floor(point.y / TileSize) + merge.Shift.y,
} }
onMerge(merge.To, shift) onMerge(merge.To, shift)
} }
} catch { } catch {
// Ignore merge parse errors // Ignore merge parse errors
} }
}) })
} }
connect() connect()
function cleanup() { function cleanup() {
destroyed = true destroyed = true
if (reconnectTimeoutId != null) { if (reconnectTimeoutId != null) {
clearTimeout(reconnectTimeoutId) clearTimeout(reconnectTimeoutId)
reconnectTimeoutId = null reconnectTimeoutId = null
} }
if (source) { if (source) {
source.close() source.close()
source = null source = null
} }
} }
return { cleanup } return { cleanup }
} }

View File

@@ -1,192 +1,192 @@
import type L from 'leaflet' import type L from 'leaflet'
import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors' import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors'
import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
export type LeafletApi = L export type LeafletApi = L
function buildCharacterIconUrl(colors: CharacterColors): string { function buildCharacterIconUrl(colors: CharacterColors): string {
const svg = const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 32" width="24" height="32">' + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 32" width="24" height="32">' +
`<path fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="1" d="M12 2a6 6 0 0 1 6 6c0 4-6 10-6 10s-6-6-6-10a6 6 0 0 1 6-6z"/>` + `<path fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="1" d="M12 2a6 6 0 0 1 6 6c0 4-6 10-6 10s-6-6-6-10a6 6 0 0 1 6-6z"/>` +
'<circle cx="12" cy="8" r="2.5" fill="white"/>' + '<circle cx="12" cy="8" r="2.5" fill="white"/>' +
'</svg>' '</svg>'
return 'data:image/svg+xml,' + encodeURIComponent(svg) return 'data:image/svg+xml,' + encodeURIComponent(svg)
} }
export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon { export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon {
return new L.Icon({ return new L.Icon({
iconUrl: buildCharacterIconUrl(colors), iconUrl: buildCharacterIconUrl(colors),
iconSize: [25, 32], iconSize: [25, 32],
iconAnchor: [12, 17], iconAnchor: [12, 17],
popupAnchor: [0, -32], popupAnchor: [0, -32],
tooltipAnchor: [12, 0], tooltipAnchor: [12, 0],
}) })
} }
export interface CharacterData { export interface CharacterData {
name: string name: string
position: { x: number; y: number } position: { x: number; y: number }
type: string type: string
id: number id: number
map: number map: number
/** True when this character was last updated by one of the current user's tokens. */ /** True when this character was last updated by one of the current user's tokens. */
ownedByMe?: boolean ownedByMe?: boolean
} }
export interface CharacterMapViewRef { export interface CharacterMapViewRef {
map: L.Map map: L.Map
mapid: number mapid: number
markerLayer?: L.LayerGroup markerLayer?: L.LayerGroup
} }
export interface MapCharacter { export interface MapCharacter {
id: number id: number
name: string name: string
position: { x: number; y: number } position: { x: number; y: number }
type: string type: string
map: number map: number
text: string text: string
value: number value: number
ownedByMe?: boolean ownedByMe?: boolean
leafletMarker: L.Marker | null leafletMarker: L.Marker | null
remove: (mapview: CharacterMapViewRef) => void remove: (mapview: CharacterMapViewRef) => void
add: (mapview: CharacterMapViewRef) => void add: (mapview: CharacterMapViewRef) => void
update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => void update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => void
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
} }
const CHARACTER_MOVE_DURATION_MS = 280 const CHARACTER_MOVE_DURATION_MS = 280
function easeOutQuad(t: number): number { function easeOutQuad(t: number): number {
return t * (2 - t) return t * (2 - t)
} }
export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter { export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter {
let leafletMarker: L.Marker | null = null let leafletMarker: L.Marker | null = null
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
let ownedByMe = data.ownedByMe ?? false let ownedByMe = data.ownedByMe ?? false
let animationFrameId: number | null = null let animationFrameId: number | null = null
const colors = getColorForCharacterId(data.id, { ownedByMe }) const colors = getColorForCharacterId(data.id, { ownedByMe })
let characterIcon = createCharacterIcon(L, colors) let characterIcon = createCharacterIcon(L, colors)
const character: MapCharacter = { const character: MapCharacter = {
id: data.id, id: data.id,
name: data.name, name: data.name,
position: { ...data.position }, position: { ...data.position },
type: data.type, type: data.type,
map: data.map, map: data.map,
text: data.name, text: data.name,
value: data.id, value: data.id,
get ownedByMe() { get ownedByMe() {
return ownedByMe return ownedByMe
}, },
set ownedByMe(v: boolean | undefined) { set ownedByMe(v: boolean | undefined) {
ownedByMe = v ?? false ownedByMe = v ?? false
}, },
get leafletMarker() { get leafletMarker() {
return leafletMarker return leafletMarker
}, },
remove(mapview: CharacterMapViewRef): void { remove(mapview: CharacterMapViewRef): void {
if (animationFrameId !== null) { if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId) cancelAnimationFrame(animationFrameId)
animationFrameId = null animationFrameId = null
} }
if (leafletMarker) { if (leafletMarker) {
const layer = mapview.markerLayer ?? mapview.map const layer = mapview.markerLayer ?? mapview.map
layer.removeLayer(leafletMarker) layer.removeLayer(leafletMarker)
leafletMarker = null leafletMarker = null
} }
}, },
add(mapview: CharacterMapViewRef): void { add(mapview: CharacterMapViewRef): void {
if (character.map === mapview.mapid) { if (character.map === mapview.mapid) {
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom) const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
leafletMarker = L.marker(position, { icon: characterIcon }) leafletMarker = L.marker(position, { icon: characterIcon })
const gridX = Math.floor(character.position.x / TileSize) const gridX = Math.floor(character.position.x / TileSize)
const gridY = Math.floor(character.position.y / TileSize) const gridY = Math.floor(character.position.y / TileSize)
const tooltipContent = `${character.name} · ${gridX}, ${gridY}` const tooltipContent = `${character.name} · ${gridX}, ${gridY}`
leafletMarker.bindTooltip(tooltipContent, { leafletMarker.bindTooltip(tooltipContent, {
direction: 'top', direction: 'top',
permanent: false, permanent: false,
offset: L.point(-10.5, -18), offset: L.point(-10.5, -18),
}) })
leafletMarker.on('click', (e: L.LeafletMouseEvent) => { leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
if (onClick) onClick(e) if (onClick) onClick(e)
}) })
const targetLayer = mapview.markerLayer ?? mapview.map const targetLayer = mapview.markerLayer ?? mapview.map
leafletMarker.addTo(targetLayer) leafletMarker.addTo(targetLayer)
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.() const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
if (markerEl) markerEl.setAttribute('aria-label', character.name) if (markerEl) markerEl.setAttribute('aria-label', character.name)
} }
}, },
update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void { update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void {
const updatedOwnedByMe = (updated as { ownedByMe?: boolean }).ownedByMe ?? false const updatedOwnedByMe = (updated as { ownedByMe?: boolean }).ownedByMe ?? false
if (ownedByMe !== updatedOwnedByMe) { if (ownedByMe !== updatedOwnedByMe) {
ownedByMe = updatedOwnedByMe ownedByMe = updatedOwnedByMe
characterIcon = createCharacterIcon(L, getColorForCharacterId(character.id, { ownedByMe })) characterIcon = createCharacterIcon(L, getColorForCharacterId(character.id, { ownedByMe }))
if (leafletMarker) leafletMarker.setIcon(characterIcon) if (leafletMarker) leafletMarker.setIcon(characterIcon)
} }
if (character.map !== updated.map) { if (character.map !== updated.map) {
character.remove(mapview) character.remove(mapview)
} }
character.map = updated.map character.map = updated.map
character.position = { ...updated.position } character.position = { ...updated.position }
if (!leafletMarker && character.map === mapview.mapid) { if (!leafletMarker && character.map === mapview.mapid) {
character.add(mapview) character.add(mapview)
return return
} }
if (!leafletMarker) return if (!leafletMarker) return
const newLatLng = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) const newLatLng = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
const updateTooltip = (): void => { const updateTooltip = (): void => {
const gridX = Math.floor(character.position.x / TileSize) const gridX = Math.floor(character.position.x / TileSize)
const gridY = Math.floor(character.position.y / TileSize) const gridY = Math.floor(character.position.y / TileSize)
leafletMarker?.setTooltipContent(`${character.name} · ${gridX}, ${gridY}`) leafletMarker?.setTooltipContent(`${character.name} · ${gridX}, ${gridY}`)
} }
const from = leafletMarker.getLatLng() const from = leafletMarker.getLatLng()
const latDelta = newLatLng.lat - from.lat const latDelta = newLatLng.lat - from.lat
const lngDelta = newLatLng.lng - from.lng const lngDelta = newLatLng.lng - from.lng
const distSq = latDelta * latDelta + lngDelta * lngDelta const distSq = latDelta * latDelta + lngDelta * lngDelta
if (distSq < 1e-12) { if (distSq < 1e-12) {
updateTooltip() updateTooltip()
return return
} }
if (animationFrameId !== null) { if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId) cancelAnimationFrame(animationFrameId)
animationFrameId = null animationFrameId = null
} }
const start = typeof performance !== 'undefined' ? performance.now() : Date.now() const start = typeof performance !== 'undefined' ? performance.now() : Date.now()
const duration = CHARACTER_MOVE_DURATION_MS const duration = CHARACTER_MOVE_DURATION_MS
const tick = (): void => { const tick = (): void => {
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - start const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - start
const t = Math.min(1, elapsed / duration) const t = Math.min(1, elapsed / duration)
const eased = easeOutQuad(t) const eased = easeOutQuad(t)
leafletMarker?.setLatLng({ leafletMarker?.setLatLng({
lat: from.lat + latDelta * eased, lat: from.lat + latDelta * eased,
lng: from.lng + lngDelta * eased, lng: from.lng + lngDelta * eased,
}) })
if (t >= 1) { if (t >= 1) {
animationFrameId = null animationFrameId = null
leafletMarker?.setLatLng(newLatLng) leafletMarker?.setLatLng(newLatLng)
updateTooltip() updateTooltip()
return return
} }
animationFrameId = requestAnimationFrame(tick) animationFrameId = requestAnimationFrame(tick)
} }
animationFrameId = requestAnimationFrame(tick) animationFrameId = requestAnimationFrame(tick)
}, },
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
onClick = callback onClick = callback
}, },
} }
return character return character
} }

View File

@@ -1,187 +1,187 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import type L from 'leaflet' import type L from 'leaflet'
import type { Map, LayerGroup } from 'leaflet' import type { Map, LayerGroup } from 'leaflet'
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character' import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
const { leafletMock } = vi.hoisted(() => { const { leafletMock } = vi.hoisted(() => {
const markerMock = { const markerMock = {
on: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(), addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(), setLatLng: vi.fn().mockReturnThis(),
setIcon: vi.fn().mockReturnThis(), setIcon: vi.fn().mockReturnThis(),
bindTooltip: vi.fn().mockReturnThis(), bindTooltip: vi.fn().mockReturnThis(),
setTooltipContent: vi.fn().mockReturnThis(), setTooltipContent: vi.fn().mockReturnThis(),
getLatLng: vi.fn().mockReturnValue({ lat: 0, lng: 0 }), getLatLng: vi.fn().mockReturnValue({ lat: 0, lng: 0 }),
} }
const Icon = vi.fn().mockImplementation(function (this: unknown) { const Icon = vi.fn().mockImplementation(function (this: unknown) {
return {} return {}
}) })
const L = { const L = {
marker: vi.fn(() => markerMock), marker: vi.fn(() => markerMock),
Icon, Icon,
point: vi.fn((x: number, y: number) => ({ x, y })), point: vi.fn((x: number, y: number) => ({ x, y })),
} }
return { leafletMock: L } return { leafletMock: L }
}) })
vi.mock('leaflet', () => ({ vi.mock('leaflet', () => ({
__esModule: true, __esModule: true,
default: leafletMock, default: leafletMock,
marker: leafletMock.marker, marker: leafletMock.marker,
Icon: leafletMock.Icon, Icon: leafletMock.Icon,
})) }))
vi.mock('~/lib/LeafletCustomTypes', () => ({ vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6, HnHMaxZoom: 6,
TileSize: 100, TileSize: 100,
})) }))
function getL(): L { function getL(): L {
return leafletMock as unknown as L return leafletMock as unknown as L
} }
function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData { function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData {
return { return {
name: 'Hero', name: 'Hero',
position: { x: 100, y: 200 }, position: { x: 100, y: 200 },
type: 'player', type: 'player',
id: 1, id: 1,
map: 1, map: 1,
...overrides, ...overrides,
} }
} }
function makeMapViewRef(mapid = 1): CharacterMapViewRef { function makeMapViewRef(mapid = 1): CharacterMapViewRef {
return { return {
map: { map: {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })), unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
removeLayer: vi.fn(), removeLayer: vi.fn(),
} as unknown as Map, } as unknown as Map,
mapid, mapid,
markerLayer: { markerLayer: {
removeLayer: vi.fn(), removeLayer: vi.fn(),
addLayer: vi.fn(), addLayer: vi.fn(),
} as unknown as LayerGroup, } as unknown as LayerGroup,
} }
} }
describe('createCharacter', () => { describe('createCharacter', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
it('creates character with correct properties', () => { it('creates character with correct properties', () => {
const char = createCharacter(makeCharData(), getL()) const char = createCharacter(makeCharData(), getL())
expect(char.id).toBe(1) expect(char.id).toBe(1)
expect(char.name).toBe('Hero') expect(char.name).toBe('Hero')
expect(char.position).toEqual({ x: 100, y: 200 }) expect(char.position).toEqual({ x: 100, y: 200 })
expect(char.type).toBe('player') expect(char.type).toBe('player')
expect(char.map).toBe(1) expect(char.map).toBe(1)
expect(char.text).toBe('Hero') expect(char.text).toBe('Hero')
expect(char.value).toBe(1) expect(char.value).toBe(1)
}) })
it('starts with null leaflet marker', () => { it('starts with null leaflet marker', () => {
const char = createCharacter(makeCharData(), getL()) const char = createCharacter(makeCharData(), getL())
expect(char.leafletMarker).toBeNull() expect(char.leafletMarker).toBeNull()
}) })
it('add creates marker when character is on correct map', () => { it('add creates marker when character is on correct map', () => {
const char = createCharacter(makeCharData(), getL()) const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.add(mapview) char.add(mapview)
expect(mapview.map.unproject).toHaveBeenCalled() expect(mapview.map.unproject).toHaveBeenCalled()
}) })
it('add creates marker without title and binds Leaflet tooltip', () => { it('add creates marker without title and binds Leaflet tooltip', () => {
const char = createCharacter(makeCharData({ position: { x: 100, y: 200 } }), getL()) const char = createCharacter(makeCharData({ position: { x: 100, y: 200 } }), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.add(mapview) char.add(mapview)
expect(leafletMock.marker).toHaveBeenCalledWith( expect(leafletMock.marker).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.not.objectContaining({ title: expect.anything() }) expect.not.objectContaining({ title: expect.anything() })
) )
const marker = char.leafletMarker as { bindTooltip: ReturnType<typeof vi.fn> } const marker = char.leafletMarker as { bindTooltip: ReturnType<typeof vi.fn> }
expect(marker.bindTooltip).toHaveBeenCalledWith( expect(marker.bindTooltip).toHaveBeenCalledWith(
'Hero · 1, 2', 'Hero · 1, 2',
expect.objectContaining({ direction: 'top', permanent: false }) expect.objectContaining({ direction: 'top', permanent: false })
) )
}) })
it('add does not create marker for different map', () => { it('add does not create marker for different map', () => {
const char = createCharacter(makeCharData({ map: 2 }), getL()) const char = createCharacter(makeCharData({ map: 2 }), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.add(mapview) char.add(mapview)
expect(mapview.map.unproject).not.toHaveBeenCalled() expect(mapview.map.unproject).not.toHaveBeenCalled()
}) })
it('update changes position and map', () => { it('update changes position and map', () => {
const char = createCharacter(makeCharData(), getL()) const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.update(mapview, { char.update(mapview, {
...makeCharData(), ...makeCharData(),
position: { x: 300, y: 400 }, position: { x: 300, y: 400 },
map: 2, map: 2,
}) })
expect(char.position).toEqual({ x: 300, y: 400 }) expect(char.position).toEqual({ x: 300, y: 400 })
expect(char.map).toBe(2) expect(char.map).toBe(2)
}) })
it('remove on a character without leaflet marker does nothing', () => { it('remove on a character without leaflet marker does nothing', () => {
const char = createCharacter(makeCharData(), getL()) const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.remove(mapview) // should not throw char.remove(mapview) // should not throw
expect(char.leafletMarker).toBeNull() expect(char.leafletMarker).toBeNull()
}) })
it('setClickCallback works', () => { it('setClickCallback works', () => {
const char = createCharacter(makeCharData(), getL()) const char = createCharacter(makeCharData(), getL())
const cb = vi.fn() const cb = vi.fn()
char.setClickCallback(cb) char.setClickCallback(cb)
}) })
it('update with changed ownedByMe updates marker icon', () => { it('update with changed ownedByMe updates marker icon', () => {
const char = createCharacter(makeCharData({ ownedByMe: false }), getL()) const char = createCharacter(makeCharData({ ownedByMe: false }), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.add(mapview) char.add(mapview)
const marker = char.leafletMarker as { setIcon: ReturnType<typeof vi.fn> } const marker = char.leafletMarker as { setIcon: ReturnType<typeof vi.fn> }
expect(marker.setIcon).not.toHaveBeenCalled() expect(marker.setIcon).not.toHaveBeenCalled()
char.update(mapview, makeCharData({ ownedByMe: true })) char.update(mapview, makeCharData({ ownedByMe: true }))
expect(marker.setIcon).toHaveBeenCalledTimes(1) expect(marker.setIcon).toHaveBeenCalledTimes(1)
}) })
it('update with position change updates tooltip content when marker exists', () => { it('update with position change updates tooltip content when marker exists', () => {
const char = createCharacter(makeCharData(), getL()) const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.add(mapview) char.add(mapview)
const marker = char.leafletMarker as { setTooltipContent: ReturnType<typeof vi.fn> } const marker = char.leafletMarker as { setTooltipContent: ReturnType<typeof vi.fn> }
marker.setTooltipContent.mockClear() marker.setTooltipContent.mockClear()
char.update(mapview, makeCharData({ position: { x: 350, y: 450 } })) char.update(mapview, makeCharData({ position: { x: 350, y: 450 } }))
expect(marker.setTooltipContent).toHaveBeenCalledWith('Hero · 3, 4') expect(marker.setTooltipContent).toHaveBeenCalledWith('Hero · 3, 4')
}) })
it('remove cancels active position animation', () => { it('remove cancels active position animation', () => {
const cancelSpy = vi.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {}) const cancelSpy = vi.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
let rafCallback: (() => void) | null = null let rafCallback: (() => void) | null = null
vi.spyOn(global, 'requestAnimationFrame').mockImplementation((cb: (() => void) | (FrameRequestCallback)) => { vi.spyOn(global, 'requestAnimationFrame').mockImplementation((cb: (() => void) | (FrameRequestCallback)) => {
rafCallback = typeof cb === 'function' ? cb : () => {} rafCallback = typeof cb === 'function' ? cb : () => {}
return 1 return 1
}) })
const char = createCharacter(makeCharData(), getL()) const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
mapview.map.unproject = vi.fn(() => ({ lat: 1, lng: 1 })) mapview.map.unproject = vi.fn(() => ({ lat: 1, lng: 1 }))
char.add(mapview) char.add(mapview)
const marker = char.leafletMarker as { getLatLng: ReturnType<typeof vi.fn> } const marker = char.leafletMarker as { getLatLng: ReturnType<typeof vi.fn> }
marker.getLatLng.mockReturnValue({ lat: 0, lng: 0 }) marker.getLatLng.mockReturnValue({ lat: 0, lng: 0 })
char.update(mapview, makeCharData({ position: { x: 200, y: 200 } })) char.update(mapview, makeCharData({ position: { x: 200, y: 200 } }))
expect(rafCallback).not.toBeNull() expect(rafCallback).not.toBeNull()
cancelSpy.mockClear() cancelSpy.mockClear()
char.remove(mapview) char.remove(mapview)
expect(cancelSpy).toHaveBeenCalledWith(1) expect(cancelSpy).toHaveBeenCalledWith(1)
cancelSpy.mockRestore() cancelSpy.mockRestore()
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
}) })

View File

@@ -1,90 +1,90 @@
<template> <template>
<div class="container mx-auto p-4 max-w-2xl min-w-0"> <div class="container mx-auto p-4 max-w-2xl min-w-0">
<h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1> <h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1>
<form v-if="map" class="flex flex-col gap-4" @submit.prevent="submit"> <form v-if="map" class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="name">Name</label> <label class="label" for="name">Name</label>
<input id="name" v-model="form.name" type="text" class="input min-h-11 touch-manipulation" required > <input id="name" v-model="form.name" type="text" class="input min-h-11 touch-manipulation" required >
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label cursor-pointer gap-2"> <label class="label cursor-pointer gap-2">
<input v-model="form.hidden" type="checkbox" class="checkbox" > <input v-model="form.hidden" type="checkbox" class="checkbox" >
<span>Hidden</span> <span>Hidden</span>
</label> </label>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label cursor-pointer gap-2"> <label class="label cursor-pointer gap-2">
<input v-model="form.priority" type="checkbox" class="checkbox" > <input v-model="form.priority" type="checkbox" class="checkbox" >
<span>Priority</span> <span>Priority</span>
</label> </label>
</fieldset> </fieldset>
<p v-if="error" class="text-error text-sm">{{ error }}</p> <p v-if="error" class="text-error text-sm">{{ error }}</p>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="submit" class="btn btn-primary min-h-11 touch-manipulation" :disabled="loading"> <button type="submit" class="btn btn-primary min-h-11 touch-manipulation" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-sm" /> <span v-if="loading" class="loading loading-spinner loading-sm" />
<span v-else>Save</span> <span v-else>Save</span>
</button> </button>
<NuxtLink to="/admin" class="btn btn-ghost min-h-11 touch-manipulation">Back</NuxtLink> <NuxtLink to="/admin" class="btn btn-ghost min-h-11 touch-manipulation">Back</NuxtLink>
</div> </div>
</form> </form>
<template v-else-if="mapsLoaded"> <template v-else-if="mapsLoaded">
<p class="text-base-content/70">Map not found.</p> <p class="text-base-content/70">Map not found.</p>
<NuxtLink to="/admin" class="btn btn-ghost mt-2 min-h-11 touch-manipulation">Back to Admin</NuxtLink> <NuxtLink to="/admin" class="btn btn-ghost mt-2 min-h-11 touch-manipulation">Back to Admin</NuxtLink>
</template> </template>
<p v-else class="text-base-content/70">Loading</p> <p v-else class="text-base-content/70">Loading</p>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfoAdmin } from '~/types/api' import type { MapInfoAdmin } from '~/types/api'
definePageMeta({ middleware: 'admin' }) definePageMeta({ middleware: 'admin' })
useHead({ title: 'Edit map HnH Map' }) useHead({ title: 'Edit map HnH Map' })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const api = useMapApi() const api = useMapApi()
const id = computed(() => parseInt(route.params.id as string, 10)) const id = computed(() => parseInt(route.params.id as string, 10))
const map = ref<MapInfoAdmin | null>(null) const map = ref<MapInfoAdmin | null>(null)
const mapsLoaded = ref(false) const mapsLoaded = ref(false)
const form = ref({ name: '', hidden: false, priority: false }) const form = ref({ name: '', hidden: false, priority: false })
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const adminMapName = useState<string | null>('admin-breadcrumb-map-name', () => null) const adminMapName = useState<string | null>('admin-breadcrumb-map-name', () => null)
onMounted(async () => { onMounted(async () => {
adminMapName.value = null adminMapName.value = null
try { try {
const maps = await api.adminMaps() const maps = await api.adminMaps()
mapsLoaded.value = true mapsLoaded.value = true
const found = maps.find((m) => m.ID === id.value) const found = maps.find((m) => m.ID === id.value)
if (found) { if (found) {
map.value = found map.value = found
form.value = { name: found.Name, hidden: found.Hidden, priority: found.Priority } form.value = { name: found.Name, hidden: found.Hidden, priority: found.Priority }
adminMapName.value = found.Name adminMapName.value = found.Name
} }
} catch { } catch {
mapsLoaded.value = true mapsLoaded.value = true
error.value = 'Failed to load map' error.value = 'Failed to load map'
} }
}) })
onUnmounted(() => { onUnmounted(() => {
adminMapName.value = null adminMapName.value = null
}) })
async function submit() { async function submit() {
if (!map.value) return if (!map.value) return
error.value = '' error.value = ''
loading.value = true loading.value = true
try { try {
await api.adminMapPost(map.value.ID, form.value) await api.adminMapPost(map.value.ID, form.value)
await router.push('/admin') await router.push('/admin')
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed' error.value = e instanceof Error ? e.message : 'Failed'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
</script> </script>

View File

@@ -1,450 +1,450 @@
package handlers package handlers
import ( import (
"archive/zip" "archive/zip"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
) )
type mapInfoJSON struct { type mapInfoJSON struct {
ID int `json:"ID"` ID int `json:"ID"`
Name string `json:"Name"` Name string `json:"Name"`
Hidden bool `json:"Hidden"` Hidden bool `json:"Hidden"`
Priority bool `json:"Priority"` Priority bool `json:"Priority"`
} }
// APIAdminUsers handles GET/POST /map/api/admin/users. // APIAdminUsers handles GET/POST /map/api/admin/users.
func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context() ctx := req.Context()
if req.Method == http.MethodGet { if req.Method == http.MethodGet {
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
list, err := h.Admin.ListUsers(ctx) list, err := h.Admin.ListUsers(ctx)
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
JSON(rw, http.StatusOK, list) JSON(rw, http.StatusOK, list)
return return
} }
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
s := h.requireAdmin(rw, req) s := h.requireAdmin(rw, req)
if s == nil { if s == nil {
return return
} }
var body struct { var body struct {
User string `json:"user"` User string `json:"user"`
Pass string `json:"pass"` Pass string `json:"pass"`
Auths []string `json:"auths"` Auths []string `json:"auths"`
} }
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" { if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
adminCreated, err := h.Admin.CreateOrUpdateUser(ctx, body.User, body.Pass, body.Auths) adminCreated, err := h.Admin.CreateOrUpdateUser(ctx, body.User, body.Pass, body.Auths)
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
if body.User == s.Username { if body.User == s.Username {
s.Auths = body.Auths s.Auths = body.Auths
} }
if adminCreated && s.Username == "admin" { if adminCreated && s.Username == "admin" {
h.Auth.DeleteSession(ctx, s) h.Auth.DeleteSession(ctx, s)
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminUserByName handles GET /map/api/admin/users/:name. // APIAdminUserByName handles GET /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) { func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
if !h.requireMethod(rw, req, http.MethodGet) { if !h.requireMethod(rw, req, http.MethodGet) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
auths, found, err := h.Admin.GetUser(req.Context(), name) auths, found, err := h.Admin.GetUser(req.Context(), name)
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
out := struct { out := struct {
Username string `json:"username"` Username string `json:"username"`
Auths []string `json:"auths"` Auths []string `json:"auths"`
}{Username: name} }{Username: name}
if found { if found {
out.Auths = auths out.Auths = auths
} }
JSON(rw, http.StatusOK, out) JSON(rw, http.StatusOK, out)
} }
// APIAdminUserDelete handles DELETE /map/api/admin/users/:name. // APIAdminUserDelete handles DELETE /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) { func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
if !h.requireMethod(rw, req, http.MethodDelete) { if !h.requireMethod(rw, req, http.MethodDelete) {
return return
} }
s := h.requireAdmin(rw, req) s := h.requireAdmin(rw, req)
if s == nil { if s == nil {
return return
} }
ctx := req.Context() ctx := req.Context()
if err := h.Admin.DeleteUser(ctx, name); err != nil { if err := h.Admin.DeleteUser(ctx, name); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
if name == s.Username { if name == s.Username {
h.Auth.DeleteSession(ctx, s) h.Auth.DeleteSession(ctx, s)
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminSettingsGet handles GET /map/api/admin/settings. // APIAdminSettingsGet handles GET /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) { if !h.requireMethod(rw, req, http.MethodGet) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
prefix, defaultHide, title, err := h.Admin.GetSettings(req.Context()) prefix, defaultHide, title, err := h.Admin.GetSettings(req.Context())
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
JSON(rw, http.StatusOK, struct { JSON(rw, http.StatusOK, struct {
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
DefaultHide bool `json:"defaultHide"` DefaultHide bool `json:"defaultHide"`
Title string `json:"title"` Title string `json:"title"`
}{Prefix: prefix, DefaultHide: defaultHide, Title: title}) }{Prefix: prefix, DefaultHide: defaultHide, Title: title})
} }
// APIAdminSettingsPost handles POST /map/api/admin/settings. // APIAdminSettingsPost handles POST /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
var body struct { var body struct {
Prefix *string `json:"prefix"` Prefix *string `json:"prefix"`
DefaultHide *bool `json:"defaultHide"` DefaultHide *bool `json:"defaultHide"`
Title *string `json:"title"` Title *string `json:"title"`
} }
if err := json.NewDecoder(req.Body).Decode(&body); err != nil { if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
if err := h.Admin.UpdateSettings(req.Context(), body.Prefix, body.DefaultHide, body.Title); err != nil { if err := h.Admin.UpdateSettings(req.Context(), body.Prefix, body.DefaultHide, body.Title); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminMaps handles GET /map/api/admin/maps. // APIAdminMaps handles GET /map/api/admin/maps.
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) { if !h.requireMethod(rw, req, http.MethodGet) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
maps, err := h.Admin.ListMaps(req.Context()) maps, err := h.Admin.ListMaps(req.Context())
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
out := make([]mapInfoJSON, len(maps)) out := make([]mapInfoJSON, len(maps))
for i, m := range maps { for i, m := range maps {
out[i] = mapInfoJSON{ID: m.ID, Name: m.Name, Hidden: m.Hidden, Priority: m.Priority} out[i] = mapInfoJSON{ID: m.ID, Name: m.Name, Hidden: m.Hidden, Priority: m.Priority}
} }
JSON(rw, http.StatusOK, out) JSON(rw, http.StatusOK, out)
} }
// APIAdminMapByID handles POST /map/api/admin/maps/:id. // APIAdminMapByID handles POST /map/api/admin/maps/:id.
func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) { func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
var body struct { var body struct {
Name string `json:"name"` Name string `json:"name"`
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
Priority bool `json:"priority"` Priority bool `json:"priority"`
} }
if err := json.NewDecoder(req.Body).Decode(&body); err != nil { if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
if err := h.Admin.UpdateMap(req.Context(), id, body.Name, body.Hidden, body.Priority); err != nil { if err := h.Admin.UpdateMap(req.Context(), id, body.Name, body.Hidden, body.Priority); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminMapToggleHidden handles POST /map/api/admin/maps/:id/toggle-hidden. // APIAdminMapToggleHidden handles POST /map/api/admin/maps/:id/toggle-hidden.
func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) { func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
mi, err := h.Admin.ToggleMapHidden(req.Context(), id) mi, err := h.Admin.ToggleMapHidden(req.Context(), id)
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
JSON(rw, http.StatusOK, mapInfoJSON{ JSON(rw, http.StatusOK, mapInfoJSON{
ID: mi.ID, ID: mi.ID,
Name: mi.Name, Name: mi.Name,
Hidden: mi.Hidden, Hidden: mi.Hidden,
Priority: mi.Priority, Priority: mi.Priority,
}) })
} }
// APIAdminWipe handles POST /map/api/admin/wipe. // APIAdminWipe handles POST /map/api/admin/wipe.
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
if err := h.Admin.Wipe(req.Context()); err != nil { if err := h.Admin.Wipe(req.Context()); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminWipeTile handles POST /map/api/admin/wipeTile. // APIAdminWipeTile handles POST /map/api/admin/wipeTile.
func (h *Handlers) APIAdminWipeTile(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminWipeTile(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
mapid, err := strconv.Atoi(req.FormValue("map")) mapid, err := strconv.Atoi(req.FormValue("map"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
x, err := strconv.Atoi(req.FormValue("x")) x, err := strconv.Atoi(req.FormValue("x"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
y, err := strconv.Atoi(req.FormValue("y")) y, err := strconv.Atoi(req.FormValue("y"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
if err := h.Admin.WipeTile(req.Context(), mapid, x, y); err != nil { if err := h.Admin.WipeTile(req.Context(), mapid, x, y); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminSetCoords handles POST /map/api/admin/setCoords. // APIAdminSetCoords handles POST /map/api/admin/setCoords.
func (h *Handlers) APIAdminSetCoords(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminSetCoords(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
mapid, err := strconv.Atoi(req.FormValue("map")) mapid, err := strconv.Atoi(req.FormValue("map"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
fx, err := strconv.Atoi(req.FormValue("fx")) fx, err := strconv.Atoi(req.FormValue("fx"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
fy, err := strconv.Atoi(req.FormValue("fy")) fy, err := strconv.Atoi(req.FormValue("fy"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
tx, err := strconv.Atoi(req.FormValue("tx")) tx, err := strconv.Atoi(req.FormValue("tx"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
ty, err := strconv.Atoi(req.FormValue("ty")) ty, err := strconv.Atoi(req.FormValue("ty"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
if err := h.Admin.SetCoords(req.Context(), mapid, fx, fy, tx, ty); err != nil { if err := h.Admin.SetCoords(req.Context(), mapid, fx, fy, tx, ty); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminHideMarker handles POST /map/api/admin/hideMarker. // APIAdminHideMarker handles POST /map/api/admin/hideMarker.
func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
markerID := req.FormValue("id") markerID := req.FormValue("id")
if markerID == "" { if markerID == "" {
JSONError(rw, http.StatusBadRequest, "missing id", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "missing id", "BAD_REQUEST")
return return
} }
if err := h.Admin.HideMarker(req.Context(), markerID); err != nil { if err := h.Admin.HideMarker(req.Context(), markerID); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms. // APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms.
// It starts the rebuild in the background and returns 202 Accepted immediately. // It starts the rebuild in the background and returns 202 Accepted immediately.
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
h.Admin.StartRebuildZooms() h.Admin.StartRebuildZooms()
rw.WriteHeader(http.StatusAccepted) rw.WriteHeader(http.StatusAccepted)
} }
// APIAdminRebuildZoomsStatus handles GET /map/api/admin/rebuildZooms/status. // APIAdminRebuildZoomsStatus handles GET /map/api/admin/rebuildZooms/status.
// Returns {"running": true|false} so the client can poll until the rebuild finishes. // Returns {"running": true|false} so the client can poll until the rebuild finishes.
func (h *Handlers) APIAdminRebuildZoomsStatus(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminRebuildZoomsStatus(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet { if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED") JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
running := h.Admin.RebuildZoomsRunning() running := h.Admin.RebuildZoomsRunning()
JSON(rw, http.StatusOK, map[string]bool{"running": running}) JSON(rw, http.StatusOK, map[string]bool{"running": running})
} }
// APIAdminExport handles GET /map/api/admin/export. // APIAdminExport handles GET /map/api/admin/export.
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) { if !h.requireMethod(rw, req, http.MethodGet) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
rw.Header().Set("Content-Type", "application/zip") rw.Header().Set("Content-Type", "application/zip")
rw.Header().Set("Content-Disposition", `attachment; filename="griddata.zip"`) rw.Header().Set("Content-Disposition", `attachment; filename="griddata.zip"`)
if err := h.Export.Export(req.Context(), rw); err != nil { if err := h.Export.Export(req.Context(), rw); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
} }
} }
// APIAdminMerge handles POST /map/api/admin/merge. // APIAdminMerge handles POST /map/api/admin/merge.
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
if err := req.ParseMultipartForm(app.MergeMaxMemory); err != nil { if err := req.ParseMultipartForm(app.MergeMaxMemory); err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return return
} }
mergef, hdr, err := req.FormFile("merge") mergef, hdr, err := req.FormFile("merge")
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return return
} }
zr, err := zip.NewReader(mergef, hdr.Size) zr, err := zip.NewReader(mergef, hdr.Size)
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return return
} }
if err := h.Export.Merge(req.Context(), zr); err != nil { if err := h.Export.Merge(req.Context(), zr); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminRoute routes /map/api/admin/* sub-paths. // APIAdminRoute routes /map/api/admin/* sub-paths.
func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path string) { func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path string) {
switch { switch {
case path == "wipeTile": case path == "wipeTile":
h.APIAdminWipeTile(rw, req) h.APIAdminWipeTile(rw, req)
case path == "setCoords": case path == "setCoords":
h.APIAdminSetCoords(rw, req) h.APIAdminSetCoords(rw, req)
case path == "hideMarker": case path == "hideMarker":
h.APIAdminHideMarker(rw, req) h.APIAdminHideMarker(rw, req)
case path == "users": case path == "users":
h.APIAdminUsers(rw, req) h.APIAdminUsers(rw, req)
case strings.HasPrefix(path, "users/"): case strings.HasPrefix(path, "users/"):
name := strings.TrimPrefix(path, "users/") name := strings.TrimPrefix(path, "users/")
if name == "" { if name == "" {
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND") JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
return return
} }
if req.Method == http.MethodDelete { if req.Method == http.MethodDelete {
h.APIAdminUserDelete(rw, req, name) h.APIAdminUserDelete(rw, req, name)
} else { } else {
h.APIAdminUserByName(rw, req, name) h.APIAdminUserByName(rw, req, name)
} }
case path == "settings": case path == "settings":
if req.Method == http.MethodGet { if req.Method == http.MethodGet {
h.APIAdminSettingsGet(rw, req) h.APIAdminSettingsGet(rw, req)
} else { } else {
h.APIAdminSettingsPost(rw, req) h.APIAdminSettingsPost(rw, req)
} }
case path == "maps": case path == "maps":
h.APIAdminMaps(rw, req) h.APIAdminMaps(rw, req)
case strings.HasPrefix(path, "maps/"): case strings.HasPrefix(path, "maps/"):
rest := strings.TrimPrefix(path, "maps/") rest := strings.TrimPrefix(path, "maps/")
parts := strings.SplitN(rest, "/", 2) parts := strings.SplitN(rest, "/", 2)
idStr := parts[0] idStr := parts[0]
if len(parts) == 2 && parts[1] == "toggle-hidden" { if len(parts) == 2 && parts[1] == "toggle-hidden" {
h.APIAdminMapToggleHidden(rw, req, idStr) h.APIAdminMapToggleHidden(rw, req, idStr)
return return
} }
if len(parts) == 1 { if len(parts) == 1 {
h.APIAdminMapByID(rw, req, idStr) h.APIAdminMapByID(rw, req, idStr)
return return
} }
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND") JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
case path == "wipe": case path == "wipe":
h.APIAdminWipe(rw, req) h.APIAdminWipe(rw, req)
case path == "rebuildZooms": case path == "rebuildZooms":
h.APIAdminRebuildZooms(rw, req) h.APIAdminRebuildZooms(rw, req)
case path == "rebuildZooms/status": case path == "rebuildZooms/status":
h.APIAdminRebuildZoomsStatus(rw, req) h.APIAdminRebuildZoomsStatus(rw, req)
case path == "export": case path == "export":
h.APIAdminExport(rw, req) h.APIAdminExport(rw, req)
case path == "merge": case path == "merge":
h.APIAdminMerge(rw, req) h.APIAdminMerge(rw, req)
default: default:
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND") JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,423 +1,423 @@
package services package services
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv" "strconv"
"sync" "sync"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store" "github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// AdminService handles admin business logic (users, settings, maps, wipe, tile ops). // AdminService handles admin business logic (users, settings, maps, wipe, tile ops).
type AdminService struct { type AdminService struct {
st *store.Store st *store.Store
mapSvc *MapService mapSvc *MapService
rebuildMu sync.Mutex rebuildMu sync.Mutex
rebuildRunning bool rebuildRunning bool
} }
// NewAdminService creates an AdminService with the given store and map service. // NewAdminService creates an AdminService with the given store and map service.
// Uses direct args (two dependencies) rather than a deps struct. // Uses direct args (two dependencies) rather than a deps struct.
func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService { func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService {
return &AdminService{st: st, mapSvc: mapSvc} return &AdminService{st: st, mapSvc: mapSvc}
} }
// ListUsers returns all usernames. // ListUsers returns all usernames.
func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) { func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
var list []string var list []string
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachUser(tx, func(k, _ []byte) error { return s.st.ForEachUser(tx, func(k, _ []byte) error {
list = append(list, string(k)) list = append(list, string(k))
return nil return nil
}) })
}) })
return list, err return list, err
} }
// GetUser returns a user's permissions by username. // GetUser returns a user's permissions by username.
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool, err error) { func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool, err error) {
err = s.st.View(ctx, func(tx *bbolt.Tx) error { err = s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username) raw := s.st.GetUser(tx, username)
if raw == nil { if raw == nil {
return nil return nil
} }
var u app.User var u app.User
if err := json.Unmarshal(raw, &u); err != nil { if err := json.Unmarshal(raw, &u); err != nil {
return err return err
} }
auths = u.Auths auths = u.Auths
found = true found = true
return nil return nil
}) })
return auths, found, err return auths, found, err
} }
// CreateOrUpdateUser creates or updates a user. // CreateOrUpdateUser creates or updates a user.
// Returns (true, nil) when admin user was created fresh (temp admin bootstrap). // Returns (true, nil) when admin user was created fresh (temp admin bootstrap).
func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string, pass string, auths app.Auths) (adminCreated bool, err error) { func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string, pass string, auths app.Auths) (adminCreated bool, err error) {
err = s.st.Update(ctx, func(tx *bbolt.Tx) error { err = s.st.Update(ctx, func(tx *bbolt.Tx) error {
existed := s.st.GetUser(tx, username) != nil existed := s.st.GetUser(tx, username) != nil
u := app.User{} u := app.User{}
raw := s.st.GetUser(tx, username) raw := s.st.GetUser(tx, username)
if raw != nil { if raw != nil {
if err := json.Unmarshal(raw, &u); err != nil { if err := json.Unmarshal(raw, &u); err != nil {
return err return err
} }
} }
if pass != "" { if pass != "" {
hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if e != nil { if e != nil {
return e return e
} }
u.Pass = hash u.Pass = hash
} }
u.Auths = auths u.Auths = auths
raw, _ = json.Marshal(u) raw, _ = json.Marshal(u)
if e := s.st.PutUser(tx, username, raw); e != nil { if e := s.st.PutUser(tx, username, raw); e != nil {
return e return e
} }
if username == "admin" && !existed { if username == "admin" && !existed {
adminCreated = true adminCreated = true
} }
return nil return nil
}) })
return adminCreated, err return adminCreated, err
} }
// DeleteUser removes a user and their tokens. // DeleteUser removes a user and their tokens.
func (s *AdminService) DeleteUser(ctx context.Context, username string) error { func (s *AdminService) DeleteUser(ctx context.Context, username string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error { return s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username) uRaw := s.st.GetUser(tx, username)
if uRaw != nil { if uRaw != nil {
var u app.User var u app.User
if err := json.Unmarshal(uRaw, &u); err != nil { if err := json.Unmarshal(uRaw, &u); err != nil {
return err return err
} }
for _, tok := range u.Tokens { for _, tok := range u.Tokens {
if err := s.st.DeleteToken(tx, tok); err != nil { if err := s.st.DeleteToken(tx, tok); err != nil {
return err return err
} }
} }
} }
return s.st.DeleteUser(tx, username) return s.st.DeleteUser(tx, username)
}) })
} }
// GetSettings returns the current server settings. // GetSettings returns the current server settings.
func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultHide bool, title string, err error) { func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultHide bool, title string, err error) {
err = s.st.View(ctx, func(tx *bbolt.Tx) error { err = s.st.View(ctx, func(tx *bbolt.Tx) error {
if v := s.st.GetConfig(tx, "prefix"); v != nil { if v := s.st.GetConfig(tx, "prefix"); v != nil {
prefix = string(v) prefix = string(v)
} }
if v := s.st.GetConfig(tx, "defaultHide"); v != nil { if v := s.st.GetConfig(tx, "defaultHide"); v != nil {
defaultHide = true defaultHide = true
} }
if v := s.st.GetConfig(tx, "title"); v != nil { if v := s.st.GetConfig(tx, "title"); v != nil {
title = string(v) title = string(v)
} }
return nil return nil
}) })
return prefix, defaultHide, title, err return prefix, defaultHide, title, err
} }
// UpdateSettings updates the specified server settings (nil fields are skipped). // UpdateSettings updates the specified server settings (nil fields are skipped).
func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error { func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error { return s.st.Update(ctx, func(tx *bbolt.Tx) error {
if prefix != nil { if prefix != nil {
if err := s.st.PutConfig(tx, "prefix", []byte(*prefix)); err != nil { if err := s.st.PutConfig(tx, "prefix", []byte(*prefix)); err != nil {
return err return err
} }
} }
if defaultHide != nil { if defaultHide != nil {
if *defaultHide { if *defaultHide {
if err := s.st.PutConfig(tx, "defaultHide", []byte("1")); err != nil { if err := s.st.PutConfig(tx, "defaultHide", []byte("1")); err != nil {
return err return err
} }
} else { } else {
if err := s.st.DeleteConfig(tx, "defaultHide"); err != nil { if err := s.st.DeleteConfig(tx, "defaultHide"); err != nil {
return err return err
} }
} }
} }
if title != nil { if title != nil {
if err := s.st.PutConfig(tx, "title", []byte(*title)); err != nil { if err := s.st.PutConfig(tx, "title", []byte(*title)); err != nil {
return err return err
} }
} }
return nil return nil
}) })
} }
// ListMaps returns all maps for the admin panel. // ListMaps returns all maps for the admin panel.
func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) { func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
var maps []app.MapInfo var maps []app.MapInfo
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error { return s.st.ForEachMap(tx, func(k, v []byte) error {
mi := app.MapInfo{} mi := app.MapInfo{}
if err := json.Unmarshal(v, &mi); err != nil { if err := json.Unmarshal(v, &mi); err != nil {
return err return err
} }
if id, err := strconv.Atoi(string(k)); err == nil { if id, err := strconv.Atoi(string(k)); err == nil {
mi.ID = id mi.ID = id
} }
maps = append(maps, mi) maps = append(maps, mi)
return nil return nil
}) })
}) })
return maps, err return maps, err
} }
// GetMap returns a map by ID. // GetMap returns a map by ID.
func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool, error) { func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool, error) {
var mi *app.MapInfo var mi *app.MapInfo
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id) raw := s.st.GetMap(tx, id)
if raw != nil { if raw != nil {
mi = &app.MapInfo{} mi = &app.MapInfo{}
return json.Unmarshal(raw, mi) return json.Unmarshal(raw, mi)
} }
return nil return nil
}) })
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
if mi != nil { if mi != nil {
mi.ID = id mi.ID = id
} }
return mi, mi != nil, nil return mi, mi != nil, nil
} }
// UpdateMap updates a map's name, hidden, and priority fields. // UpdateMap updates a map's name, hidden, and priority fields.
func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidden, priority bool) error { func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidden, priority bool) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error { return s.st.Update(ctx, func(tx *bbolt.Tx) error {
mi := app.MapInfo{} mi := app.MapInfo{}
raw := s.st.GetMap(tx, id) raw := s.st.GetMap(tx, id)
if raw != nil { if raw != nil {
if err := json.Unmarshal(raw, &mi); err != nil { if err := json.Unmarshal(raw, &mi); err != nil {
return err return err
} }
} }
mi.ID = id mi.ID = id
mi.Name = name mi.Name = name
mi.Hidden = hidden mi.Hidden = hidden
mi.Priority = priority mi.Priority = priority
raw, _ = json.Marshal(mi) raw, _ = json.Marshal(mi)
return s.st.PutMap(tx, id, raw) return s.st.PutMap(tx, id, raw)
}) })
} }
// ToggleMapHidden toggles the hidden flag of a map and returns the updated map. // ToggleMapHidden toggles the hidden flag of a map and returns the updated map.
func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInfo, error) { func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInfo, error) {
var mi *app.MapInfo var mi *app.MapInfo
err := s.st.Update(ctx, func(tx *bbolt.Tx) error { err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id) raw := s.st.GetMap(tx, id)
mi = &app.MapInfo{} mi = &app.MapInfo{}
if raw != nil { if raw != nil {
if err := json.Unmarshal(raw, mi); err != nil { if err := json.Unmarshal(raw, mi); err != nil {
return err return err
} }
} }
mi.ID = id mi.ID = id
mi.Hidden = !mi.Hidden mi.Hidden = !mi.Hidden
raw, _ = json.Marshal(mi) raw, _ = json.Marshal(mi)
return s.st.PutMap(tx, id, raw) return s.st.PutMap(tx, id, raw)
}) })
return mi, err return mi, err
} }
// Wipe deletes all grids, markers, tiles, and maps from the database. // Wipe deletes all grids, markers, tiles, and maps from the database.
func (s *AdminService) Wipe(ctx context.Context) error { func (s *AdminService) Wipe(ctx context.Context) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error { return s.st.Update(ctx, func(tx *bbolt.Tx) error {
for _, b := range [][]byte{ for _, b := range [][]byte{
store.BucketGrids, store.BucketGrids,
store.BucketMarkers, store.BucketMarkers,
store.BucketTiles, store.BucketTiles,
store.BucketMaps, store.BucketMaps,
} { } {
if s.st.BucketExists(tx, b) { if s.st.BucketExists(tx, b) {
if err := s.st.DeleteBucket(tx, b); err != nil { if err := s.st.DeleteBucket(tx, b); err != nil {
return err return err
} }
} }
} }
return nil return nil
}) })
} }
// WipeTile removes a tile at the given coordinates and rebuilds zoom levels. // WipeTile removes a tile at the given coordinates and rebuilds zoom levels.
func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error { func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error {
c := app.Coord{X: x, Y: y} c := app.Coord{X: x, Y: y}
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids) grids := tx.Bucket(store.BucketGrids)
if grids == nil { if grids == nil {
return nil return nil
} }
var ids [][]byte var ids [][]byte
err := grids.ForEach(func(k, v []byte) error { err := grids.ForEach(func(k, v []byte) error {
g := app.GridData{} g := app.GridData{}
if err := json.Unmarshal(v, &g); err != nil { if err := json.Unmarshal(v, &g); err != nil {
return err return err
} }
if g.Coord == c && g.Map == mapid { if g.Coord == c && g.Map == mapid {
ids = append(ids, k) ids = append(ids, k)
} }
return nil return nil
}) })
if err != nil { if err != nil {
return err return err
} }
for _, id := range ids { for _, id := range ids {
if err := grids.Delete(id); err != nil { if err := grids.Delete(id); err != nil {
return err return err
} }
} }
return nil return nil
}); err != nil { }); err != nil {
return err return err
} }
s.mapSvc.SaveTile(ctx, mapid, c, 0, "", -1) s.mapSvc.SaveTile(ctx, mapid, c, 0, "", -1)
zc := c zc := c
for z := 1; z <= app.MaxZoomLevel; z++ { for z := 1; z <= app.MaxZoomLevel; z++ {
zc = zc.Parent() zc = zc.Parent()
s.mapSvc.UpdateZoomLevel(ctx, mapid, zc, z) s.mapSvc.UpdateZoomLevel(ctx, mapid, zc, z)
} }
return nil return nil
} }
// SetCoords shifts all grid and tile coordinates by a delta. // SetCoords shifts all grid and tile coordinates by a delta.
func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int) error { func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int) error {
fc := app.Coord{X: fx, Y: fy} fc := app.Coord{X: fx, Y: fy}
tc := app.Coord{X: tx2, Y: ty} tc := app.Coord{X: tx2, Y: ty}
diff := app.Coord{X: tc.X - fc.X, Y: tc.Y - fc.Y} diff := app.Coord{X: tc.X - fc.X, Y: tc.Y - fc.Y}
var tds []*app.TileData var tds []*app.TileData
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
grids := tx.Bucket(store.BucketGrids) grids := tx.Bucket(store.BucketGrids)
if grids == nil { if grids == nil {
return nil return nil
} }
tiles := tx.Bucket(store.BucketTiles) tiles := tx.Bucket(store.BucketTiles)
if tiles == nil { if tiles == nil {
return nil return nil
} }
mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid))) mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid)))
if mapZooms == nil { if mapZooms == nil {
return nil return nil
} }
mapTiles := mapZooms.Bucket([]byte("0")) mapTiles := mapZooms.Bucket([]byte("0"))
if err := grids.ForEach(func(k, v []byte) error { if err := grids.ForEach(func(k, v []byte) error {
g := app.GridData{} g := app.GridData{}
if err := json.Unmarshal(v, &g); err != nil { if err := json.Unmarshal(v, &g); err != nil {
return err return err
} }
if g.Map == mapid { if g.Map == mapid {
g.Coord.X += diff.X g.Coord.X += diff.X
g.Coord.Y += diff.Y g.Coord.Y += diff.Y
raw, _ := json.Marshal(g) raw, _ := json.Marshal(g)
if err := grids.Put(k, raw); err != nil { if err := grids.Put(k, raw); err != nil {
return err return err
} }
} }
return nil return nil
}); err != nil { }); err != nil {
return err return err
} }
if err := mapTiles.ForEach(func(k, v []byte) error { if err := mapTiles.ForEach(func(k, v []byte) error {
td := &app.TileData{} td := &app.TileData{}
if err := json.Unmarshal(v, td); err != nil { if err := json.Unmarshal(v, td); err != nil {
return err return err
} }
td.Coord.X += diff.X td.Coord.X += diff.X
td.Coord.Y += diff.Y td.Coord.Y += diff.Y
tds = append(tds, td) tds = append(tds, td)
return nil return nil
}); err != nil { }); err != nil {
return err return err
} }
return tiles.DeleteBucket([]byte(strconv.Itoa(mapid))) return tiles.DeleteBucket([]byte(strconv.Itoa(mapid)))
}); err != nil { }); err != nil {
return err return err
} }
ops := make([]TileOp, len(tds)) ops := make([]TileOp, len(tds))
for i, td := range tds { for i, td := range tds {
ops[i] = TileOp{MapID: td.MapID, X: td.Coord.X, Y: td.Coord.Y, File: td.File} ops[i] = TileOp{MapID: td.MapID, X: td.Coord.X, Y: td.Coord.Y, File: td.File}
} }
s.mapSvc.ProcessZoomLevels(ctx, ops) s.mapSvc.ProcessZoomLevels(ctx, ops)
return nil return nil
} }
// HideMarker marks a marker as hidden. // HideMarker marks a marker as hidden.
func (s *AdminService) HideMarker(ctx context.Context, markerID string) error { func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error { return s.st.Update(ctx, func(tx *bbolt.Tx) error {
_, idB, err := s.st.CreateMarkersBuckets(tx) _, idB, err := s.st.CreateMarkersBuckets(tx)
if err != nil { if err != nil {
return err return err
} }
grid := s.st.GetMarkersGridBucket(tx) grid := s.st.GetMarkersGridBucket(tx)
if grid == nil { if grid == nil {
return fmt.Errorf("markers grid bucket not found") return fmt.Errorf("markers grid bucket not found")
} }
key := idB.Get([]byte(markerID)) key := idB.Get([]byte(markerID))
if key == nil { if key == nil {
slog.Warn("marker not found", "id", markerID) slog.Warn("marker not found", "id", markerID)
return nil return nil
} }
raw := grid.Get(key) raw := grid.Get(key)
if raw == nil { if raw == nil {
return nil return nil
} }
m := app.Marker{} m := app.Marker{}
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &m); err != nil {
return err return err
} }
m.Hidden = true m.Hidden = true
raw, _ = json.Marshal(m) raw, _ = json.Marshal(m)
if err := grid.Put(key, raw); err != nil { if err := grid.Put(key, raw); err != nil {
return err return err
} }
return nil return nil
}) })
} }
// RebuildZooms delegates to MapService. // RebuildZooms delegates to MapService.
func (s *AdminService) RebuildZooms(ctx context.Context) error { func (s *AdminService) RebuildZooms(ctx context.Context) error {
return s.mapSvc.RebuildZooms(ctx) return s.mapSvc.RebuildZooms(ctx)
} }
// StartRebuildZooms starts RebuildZooms in a goroutine and returns immediately. // StartRebuildZooms starts RebuildZooms in a goroutine and returns immediately.
// RebuildZoomsRunning returns true while the rebuild is in progress. // RebuildZoomsRunning returns true while the rebuild is in progress.
func (s *AdminService) StartRebuildZooms() { func (s *AdminService) StartRebuildZooms() {
s.rebuildMu.Lock() s.rebuildMu.Lock()
if s.rebuildRunning { if s.rebuildRunning {
s.rebuildMu.Unlock() s.rebuildMu.Unlock()
return return
} }
s.rebuildRunning = true s.rebuildRunning = true
s.rebuildMu.Unlock() s.rebuildMu.Unlock()
go func() { go func() {
defer func() { defer func() {
s.rebuildMu.Lock() s.rebuildMu.Lock()
s.rebuildRunning = false s.rebuildRunning = false
s.rebuildMu.Unlock() s.rebuildMu.Unlock()
}() }()
if err := s.mapSvc.RebuildZooms(context.Background()); err != nil { if err := s.mapSvc.RebuildZooms(context.Background()); err != nil {
slog.Error("RebuildZooms background failed", "error", err) slog.Error("RebuildZooms background failed", "error", err)
} }
}() }()
} }
// RebuildZoomsRunning returns true if a rebuild is currently in progress. // RebuildZoomsRunning returns true if a rebuild is currently in progress.
func (s *AdminService) RebuildZoomsRunning() bool { func (s *AdminService) RebuildZoomsRunning() bool {
s.rebuildMu.Lock() s.rebuildMu.Lock()
defer s.rebuildMu.Unlock() defer s.rebuildMu.Unlock()
return s.rebuildRunning return s.rebuildRunning
} }

View File

@@ -1,308 +1,308 @@
package services_test package services_test
import ( import (
"context" "context"
"testing" "testing"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services" "github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store" "github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
func newTestAdmin(t *testing.T) (*services.AdminService, *store.Store) { func newTestAdmin(t *testing.T) (*services.AdminService, *store.Store) {
t.Helper() t.Helper()
db := newTestDB(t) db := newTestDB(t)
st := store.New(db) st := store.New(db)
mapSvc := services.NewMapService(services.MapServiceDeps{ mapSvc := services.NewMapService(services.MapServiceDeps{
Store: st, Store: st,
GridStorage: t.TempDir(), GridStorage: t.TempDir(),
GridUpdates: &app.Topic[app.TileData]{}, GridUpdates: &app.Topic[app.TileData]{},
}) })
return services.NewAdminService(st, mapSvc), st return services.NewAdminService(st, mapSvc), st
} }
func TestListUsers_Empty(t *testing.T) { func TestListUsers_Empty(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
users, err := admin.ListUsers(context.Background()) users, err := admin.ListUsers(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(users) != 0 { if len(users) != 0 {
t.Fatalf("expected 0 users, got %d", len(users)) t.Fatalf("expected 0 users, got %d", len(users))
} }
} }
func TestListUsers_WithUsers(t *testing.T) { func TestListUsers_WithUsers(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
createUser(t, st, "alice", "pass", nil) createUser(t, st, "alice", "pass", nil)
createUser(t, st, "bob", "pass", nil) createUser(t, st, "bob", "pass", nil)
users, err := admin.ListUsers(ctx) users, err := admin.ListUsers(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(users) != 2 { if len(users) != 2 {
t.Fatalf("expected 2 users, got %d", len(users)) t.Fatalf("expected 2 users, got %d", len(users))
} }
} }
func TestAdminGetUser_Found(t *testing.T) { func TestAdminGetUser_Found(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD}) createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
auths, found, err := admin.GetUser(context.Background(), "alice") auths, found, err := admin.GetUser(context.Background(), "alice")
if err != nil || !found { if err != nil || !found {
t.Fatalf("expected found, err=%v", err) t.Fatalf("expected found, err=%v", err)
} }
if !auths.Has(app.AUTH_MAP) { if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth") t.Fatal("expected map auth")
} }
} }
func TestAdminGetUser_NotFound(t *testing.T) { func TestAdminGetUser_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
_, found, err := admin.GetUser(context.Background(), "ghost") _, found, err := admin.GetUser(context.Background(), "ghost")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if found { if found {
t.Fatal("expected not found") t.Fatal("expected not found")
} }
} }
func TestCreateOrUpdateUser_New(t *testing.T) { func TestCreateOrUpdateUser_New(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
_, err := admin.CreateOrUpdateUser(ctx, "bob", "secret", app.Auths{app.AUTH_MAP}) _, err := admin.CreateOrUpdateUser(ctx, "bob", "secret", app.Auths{app.AUTH_MAP})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
auths, found, err := admin.GetUser(ctx, "bob") auths, found, err := admin.GetUser(ctx, "bob")
if err != nil || !found { if err != nil || !found {
t.Fatalf("expected user to exist, err=%v", err) t.Fatalf("expected user to exist, err=%v", err)
} }
if !auths.Has(app.AUTH_MAP) { if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth") t.Fatal("expected map auth")
} }
} }
func TestCreateOrUpdateUser_Update(t *testing.T) { func TestCreateOrUpdateUser_Update(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
createUser(t, st, "alice", "old", app.Auths{app.AUTH_MAP}) createUser(t, st, "alice", "old", app.Auths{app.AUTH_MAP})
_, err := admin.CreateOrUpdateUser(ctx, "alice", "new", app.Auths{app.AUTH_ADMIN, app.AUTH_MAP}) _, err := admin.CreateOrUpdateUser(ctx, "alice", "new", app.Auths{app.AUTH_ADMIN, app.AUTH_MAP})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
auths, found, err := admin.GetUser(ctx, "alice") auths, found, err := admin.GetUser(ctx, "alice")
if err != nil || !found { if err != nil || !found {
t.Fatalf("expected user, err=%v", err) t.Fatalf("expected user, err=%v", err)
} }
if !auths.Has(app.AUTH_ADMIN) { if !auths.Has(app.AUTH_ADMIN) {
t.Fatal("expected admin auth after update") t.Fatal("expected admin auth after update")
} }
} }
func TestCreateOrUpdateUser_AdminBootstrap(t *testing.T) { func TestCreateOrUpdateUser_AdminBootstrap(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
adminCreated, err := admin.CreateOrUpdateUser(ctx, "admin", "pass", app.Auths{app.AUTH_ADMIN}) adminCreated, err := admin.CreateOrUpdateUser(ctx, "admin", "pass", app.Auths{app.AUTH_ADMIN})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !adminCreated { if !adminCreated {
t.Fatal("expected adminCreated=true for new admin user") t.Fatal("expected adminCreated=true for new admin user")
} }
adminCreated, err = admin.CreateOrUpdateUser(ctx, "admin", "pass2", app.Auths{app.AUTH_ADMIN}) adminCreated, err = admin.CreateOrUpdateUser(ctx, "admin", "pass2", app.Auths{app.AUTH_ADMIN})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if adminCreated { if adminCreated {
t.Fatal("expected adminCreated=false for existing admin user") t.Fatal("expected adminCreated=false for existing admin user")
} }
} }
func TestDeleteUser(t *testing.T) { func TestDeleteUser(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
auth := services.NewAuthService(st) auth := services.NewAuthService(st)
auth.GenerateTokenForUser(ctx, "alice") auth.GenerateTokenForUser(ctx, "alice")
if err := admin.DeleteUser(ctx, "alice"); err != nil { if err := admin.DeleteUser(ctx, "alice"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, found, err := admin.GetUser(ctx, "alice") _, found, err := admin.GetUser(ctx, "alice")
if err != nil || found { if err != nil || found {
t.Fatalf("expected user to be deleted, err=%v", err) t.Fatalf("expected user to be deleted, err=%v", err)
} }
} }
func TestGetSettings_Defaults(t *testing.T) { func TestGetSettings_Defaults(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
prefix, defaultHide, title, err := admin.GetSettings(context.Background()) prefix, defaultHide, title, err := admin.GetSettings(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if prefix != "" || defaultHide || title != "" { if prefix != "" || defaultHide || title != "" {
t.Fatalf("expected empty defaults, got prefix=%q defaultHide=%v title=%q", prefix, defaultHide, title) t.Fatalf("expected empty defaults, got prefix=%q defaultHide=%v title=%q", prefix, defaultHide, title)
} }
} }
func TestUpdateSettings(t *testing.T) { func TestUpdateSettings(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
p := "pfx" p := "pfx"
dh := true dh := true
ti := "My Map" ti := "My Map"
if err := admin.UpdateSettings(ctx, &p, &dh, &ti); err != nil { if err := admin.UpdateSettings(ctx, &p, &dh, &ti); err != nil {
t.Fatal(err) t.Fatal(err)
} }
prefix, defaultHide, title, err := admin.GetSettings(ctx) prefix, defaultHide, title, err := admin.GetSettings(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if prefix != "pfx" { if prefix != "pfx" {
t.Fatalf("expected pfx, got %s", prefix) t.Fatalf("expected pfx, got %s", prefix)
} }
if !defaultHide { if !defaultHide {
t.Fatal("expected defaultHide=true") t.Fatal("expected defaultHide=true")
} }
if title != "My Map" { if title != "My Map" {
t.Fatalf("expected My Map, got %s", title) t.Fatalf("expected My Map, got %s", title)
} }
dh2 := false dh2 := false
if err := admin.UpdateSettings(ctx, nil, &dh2, nil); err != nil { if err := admin.UpdateSettings(ctx, nil, &dh2, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, defaultHide2, _, _ := admin.GetSettings(ctx) _, defaultHide2, _, _ := admin.GetSettings(ctx)
if defaultHide2 { if defaultHide2 {
t.Fatal("expected defaultHide=false after update") t.Fatal("expected defaultHide=false after update")
} }
} }
func TestListMaps_Empty(t *testing.T) { func TestListMaps_Empty(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
maps, err := admin.ListMaps(context.Background()) maps, err := admin.ListMaps(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(maps) != 0 { if len(maps) != 0 {
t.Fatalf("expected 0 maps, got %d", len(maps)) t.Fatalf("expected 0 maps, got %d", len(maps))
} }
} }
func TestMapCRUD(t *testing.T) { func TestMapCRUD(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
if err := admin.UpdateMap(ctx, 1, "world", false, false); err != nil { if err := admin.UpdateMap(ctx, 1, "world", false, false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
mi, found, err := admin.GetMap(ctx, 1) mi, found, err := admin.GetMap(ctx, 1)
if err != nil || !found || mi == nil { if err != nil || !found || mi == nil {
t.Fatalf("expected map, err=%v", err) t.Fatalf("expected map, err=%v", err)
} }
if mi.Name != "world" { if mi.Name != "world" {
t.Fatalf("expected world, got %s", mi.Name) t.Fatalf("expected world, got %s", mi.Name)
} }
maps, err := admin.ListMaps(ctx) maps, err := admin.ListMaps(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(maps) != 1 { if len(maps) != 1 {
t.Fatalf("expected 1 map, got %d", len(maps)) t.Fatalf("expected 1 map, got %d", len(maps))
} }
} }
func TestToggleMapHidden(t *testing.T) { func TestToggleMapHidden(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
_ = admin.UpdateMap(ctx, 1, "world", false, false) _ = admin.UpdateMap(ctx, 1, "world", false, false)
mi, err := admin.ToggleMapHidden(ctx, 1) mi, err := admin.ToggleMapHidden(ctx, 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !mi.Hidden { if !mi.Hidden {
t.Fatal("expected hidden=true after toggle") t.Fatal("expected hidden=true after toggle")
} }
mi, err = admin.ToggleMapHidden(ctx, 1) mi, err = admin.ToggleMapHidden(ctx, 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if mi.Hidden { if mi.Hidden {
t.Fatal("expected hidden=false after second toggle") t.Fatal("expected hidden=false after second toggle")
} }
} }
func TestWipe(t *testing.T) { func TestWipe(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
if err := st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if err := st.PutGrid(tx, "g1", []byte("data")); err != nil { if err := st.PutGrid(tx, "g1", []byte("data")); err != nil {
return err return err
} }
if err := st.PutMap(tx, 1, []byte("data")); err != nil { if err := st.PutMap(tx, 1, []byte("data")); err != nil {
return err return err
} }
if err := st.PutTile(tx, 1, 0, "0_0", []byte("data")); err != nil { if err := st.PutTile(tx, 1, 0, "0_0", []byte("data")); err != nil {
return err return err
} }
_, _, err := st.CreateMarkersBuckets(tx) _, _, err := st.CreateMarkersBuckets(tx)
return err return err
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := admin.Wipe(ctx); err != nil { if err := admin.Wipe(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if st.GetGrid(tx, "g1") != nil { if st.GetGrid(tx, "g1") != nil {
t.Fatal("expected grids wiped") t.Fatal("expected grids wiped")
} }
if st.GetMap(tx, 1) != nil { if st.GetMap(tx, 1) != nil {
t.Fatal("expected maps wiped") t.Fatal("expected maps wiped")
} }
if st.GetTile(tx, 1, 0, "0_0") != nil { if st.GetTile(tx, 1, 0, "0_0") != nil {
t.Fatal("expected tiles wiped") t.Fatal("expected tiles wiped")
} }
if st.GetMarkersGridBucket(tx) != nil { if st.GetMarkersGridBucket(tx) != nil {
t.Fatal("expected markers wiped") t.Fatal("expected markers wiped")
} }
return nil return nil
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestGetMap_NotFound(t *testing.T) { func TestGetMap_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
_, found, err := admin.GetMap(context.Background(), 999) _, found, err := admin.GetMap(context.Background(), 999)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if found { if found {
t.Fatal("expected not found") t.Fatal("expected not found")
} }
} }

View File

@@ -1,422 +1,422 @@
package services package services
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image" "image"
"image/png" "image/png"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time" "time"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store" "github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
"golang.org/x/image/draw" "golang.org/x/image/draw"
) )
type zoomproc struct { type zoomproc struct {
c app.Coord c app.Coord
m int m int
} }
// MapService handles map, markers, grids, tiles business logic. // MapService handles map, markers, grids, tiles business logic.
type MapService struct { type MapService struct {
st *store.Store st *store.Store
gridStorage string gridStorage string
gridUpdates *app.Topic[app.TileData] gridUpdates *app.Topic[app.TileData]
mergeUpdates *app.Topic[app.Merge] mergeUpdates *app.Topic[app.Merge]
getChars func() []app.Character getChars func() []app.Character
} }
// MapServiceDeps holds dependencies for MapService construction. // MapServiceDeps holds dependencies for MapService construction.
type MapServiceDeps struct { type MapServiceDeps struct {
Store *store.Store Store *store.Store
GridStorage string GridStorage string
GridUpdates *app.Topic[app.TileData] GridUpdates *app.Topic[app.TileData]
MergeUpdates *app.Topic[app.Merge] MergeUpdates *app.Topic[app.Merge]
GetChars func() []app.Character GetChars func() []app.Character
} }
// NewMapService creates a MapService with the given dependencies. // NewMapService creates a MapService with the given dependencies.
func NewMapService(d MapServiceDeps) *MapService { func NewMapService(d MapServiceDeps) *MapService {
return &MapService{ return &MapService{
st: d.Store, st: d.Store,
gridStorage: d.GridStorage, gridStorage: d.GridStorage,
gridUpdates: d.GridUpdates, gridUpdates: d.GridUpdates,
mergeUpdates: d.MergeUpdates, mergeUpdates: d.MergeUpdates,
getChars: d.GetChars, getChars: d.GetChars,
} }
} }
// GridStorage returns the grid storage directory path. // GridStorage returns the grid storage directory path.
func (s *MapService) GridStorage() string { return s.gridStorage } func (s *MapService) GridStorage() string { return s.gridStorage }
// GetCharacters returns all current characters. // GetCharacters returns all current characters.
func (s *MapService) GetCharacters() []app.Character { func (s *MapService) GetCharacters() []app.Character {
if s.getChars == nil { if s.getChars == nil {
return nil return nil
} }
return s.getChars() return s.getChars()
} }
// GetMarkers returns all markers with computed map positions. // GetMarkers returns all markers with computed map positions.
func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) { func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) {
var markers []app.FrontendMarker var markers []app.FrontendMarker
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
grid := s.st.GetMarkersGridBucket(tx) grid := s.st.GetMarkersGridBucket(tx)
if grid == nil { if grid == nil {
return nil return nil
} }
grids := tx.Bucket(store.BucketGrids) grids := tx.Bucket(store.BucketGrids)
if grids == nil { if grids == nil {
return nil return nil
} }
return grid.ForEach(func(k, v []byte) error { return grid.ForEach(func(k, v []byte) error {
marker := app.Marker{} marker := app.Marker{}
if err := json.Unmarshal(v, &marker); err != nil { if err := json.Unmarshal(v, &marker); err != nil {
return err return err
} }
graw := grids.Get([]byte(marker.GridID)) graw := grids.Get([]byte(marker.GridID))
if graw == nil { if graw == nil {
return nil return nil
} }
g := app.GridData{} g := app.GridData{}
if err := json.Unmarshal(graw, &g); err != nil { if err := json.Unmarshal(graw, &g); err != nil {
return err return err
} }
markers = append(markers, app.FrontendMarker{ markers = append(markers, app.FrontendMarker{
Image: marker.Image, Image: marker.Image,
Hidden: marker.Hidden, Hidden: marker.Hidden,
ID: marker.ID, ID: marker.ID,
Name: marker.Name, Name: marker.Name,
Map: g.Map, Map: g.Map,
Position: app.Position{ Position: app.Position{
X: marker.Position.X + g.Coord.X*app.GridSize, X: marker.Position.X + g.Coord.X*app.GridSize,
Y: marker.Position.Y + g.Coord.Y*app.GridSize, Y: marker.Position.Y + g.Coord.Y*app.GridSize,
}, },
}) })
return nil return nil
}) })
}) })
return markers, err return markers, err
} }
// GetMaps returns all maps, optionally including hidden ones. // GetMaps returns all maps, optionally including hidden ones.
func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) { func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) {
maps := make(map[int]*app.MapInfo) maps := make(map[int]*app.MapInfo)
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error { return s.st.ForEachMap(tx, func(k, v []byte) error {
mapid, err := strconv.Atoi(string(k)) mapid, err := strconv.Atoi(string(k))
if err != nil { if err != nil {
return nil return nil
} }
mi := &app.MapInfo{} mi := &app.MapInfo{}
if err := json.Unmarshal(v, mi); err != nil { if err := json.Unmarshal(v, mi); err != nil {
return err return err
} }
if mi.Hidden && !showHidden { if mi.Hidden && !showHidden {
return nil return nil
} }
maps[mapid] = mi maps[mapid] = mi
return nil return nil
}) })
}) })
return maps, err return maps, err
} }
// GetConfig returns the application config for the frontend. // GetConfig returns the application config for the frontend.
func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) { func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) {
config := app.Config{Auths: auths} config := app.Config{Auths: auths}
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title") title := s.st.GetConfig(tx, "title")
if title != nil { if title != nil {
config.Title = string(title) config.Title = string(title)
} }
return nil return nil
}) })
return config, err return config, err
} }
// GetPage returns page metadata (title). // GetPage returns page metadata (title).
func (s *MapService) GetPage(ctx context.Context) (app.Page, error) { func (s *MapService) GetPage(ctx context.Context) (app.Page, error) {
p := app.Page{} p := app.Page{}
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title") title := s.st.GetConfig(tx, "title")
if title != nil { if title != nil {
p.Title = string(title) p.Title = string(title)
} }
return nil return nil
}) })
return p, err return p, err
} }
// GetGrid returns a grid by its ID. // GetGrid returns a grid by its ID.
func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) { func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) {
var gd *app.GridData var gd *app.GridData
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, id) raw := s.st.GetGrid(tx, id)
if raw == nil { if raw == nil {
return nil return nil
} }
gd = &app.GridData{} gd = &app.GridData{}
return json.Unmarshal(raw, gd) return json.Unmarshal(raw, gd)
}) })
return gd, err return gd, err
} }
// GetTile returns a tile by map ID, coordinate, and zoom level. // GetTile returns a tile by map ID, coordinate, and zoom level.
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData { func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
var td *app.TileData var td *app.TileData
if err := s.st.View(ctx, func(tx *bbolt.Tx) error { if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetTile(tx, mapID, zoom, c.Name()) raw := s.st.GetTile(tx, mapID, zoom, c.Name())
if raw != nil { if raw != nil {
td = &app.TileData{} td = &app.TileData{}
return json.Unmarshal(raw, td) return json.Unmarshal(raw, td)
} }
return nil return nil
}); err != nil { }); err != nil {
return nil return nil
} }
return td return td
} }
// getSubTiles returns up to 4 tile data for the given parent coord at zoom z-1 (sub-tiles at z). // getSubTiles returns up to 4 tile data for the given parent coord at zoom z-1 (sub-tiles at z).
// Order: (0,0), (1,0), (0,1), (1,1) to match the 2x2 loop in UpdateZoomLevel. // Order: (0,0), (1,0), (0,1), (1,1) to match the 2x2 loop in UpdateZoomLevel.
func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z int) []*app.TileData { func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z int) []*app.TileData {
coords := []app.Coord{ coords := []app.Coord{
{X: c.X*2 + 0, Y: c.Y*2 + 0}, {X: c.X*2 + 0, Y: c.Y*2 + 0},
{X: c.X*2 + 1, Y: c.Y*2 + 0}, {X: c.X*2 + 1, Y: c.Y*2 + 0},
{X: c.X*2 + 0, Y: c.Y*2 + 1}, {X: c.X*2 + 0, Y: c.Y*2 + 1},
{X: c.X*2 + 1, Y: c.Y*2 + 1}, {X: c.X*2 + 1, Y: c.Y*2 + 1},
} }
keys := make([]string, len(coords)) keys := make([]string, len(coords))
for i := range coords { for i := range coords {
keys[i] = coords[i].Name() keys[i] = coords[i].Name()
} }
var rawMap map[string][]byte var rawMap map[string][]byte
if err := s.st.View(ctx, func(tx *bbolt.Tx) error { if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
rawMap = s.st.GetTiles(tx, mapid, z-1, keys) rawMap = s.st.GetTiles(tx, mapid, z-1, keys)
return nil return nil
}); err != nil { }); err != nil {
return nil return nil
} }
result := make([]*app.TileData, 4) result := make([]*app.TileData, 4)
for i, k := range keys { for i, k := range keys {
if raw, ok := rawMap[k]; ok && len(raw) > 0 { if raw, ok := rawMap[k]; ok && len(raw) > 0 {
td := &app.TileData{} td := &app.TileData{}
if json.Unmarshal(raw, td) == nil { if json.Unmarshal(raw, td) == nil {
result[i] = td result[i] = td
} }
} }
} }
return result return result
} }
// SaveTile persists a tile and broadcasts the update. // SaveTile persists a tile and broadcasts the update.
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) { func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
_ = s.st.Update(ctx, func(tx *bbolt.Tx) error { _ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
td := &app.TileData{ td := &app.TileData{
MapID: mapid, MapID: mapid,
Coord: c, Coord: c,
Zoom: z, Zoom: z,
File: f, File: f,
Cache: t, Cache: t,
} }
raw, err := json.Marshal(td) raw, err := json.Marshal(td)
if err != nil { if err != nil {
return err return err
} }
s.gridUpdates.Send(td) s.gridUpdates.Send(td)
return s.st.PutTile(tx, mapid, z, c.Name(), raw) return s.st.PutTile(tx, mapid, z, c.Name(), raw)
}) })
} }
// UpdateZoomLevel composes a zoom tile from 4 sub-tiles (one View for all 4 tile reads). // UpdateZoomLevel composes a zoom tile from 4 sub-tiles (one View for all 4 tile reads).
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) { func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
subTiles := s.getSubTiles(ctx, mapid, c, z) subTiles := s.getSubTiles(ctx, mapid, c, z)
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize)) img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src) draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
td := subTiles[i] td := subTiles[i]
if td == nil || td.File == "" { if td == nil || td.File == "" {
continue continue
} }
x := i % 2 x := i % 2
y := i / 2 y := i / 2
subf, err := os.Open(filepath.Join(s.gridStorage, td.File)) subf, err := os.Open(filepath.Join(s.gridStorage, td.File))
if err != nil { if err != nil {
continue continue
} }
subimg, _, err := image.Decode(subf) subimg, _, err := image.Decode(subf)
subf.Close() subf.Close()
if err != nil { if err != nil {
continue continue
} }
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil) draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
} }
if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil { if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil {
slog.Error("failed to create zoom dir", "error", err) slog.Error("failed to create zoom dir", "error", err)
return return
} }
path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name()) path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name()) relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
f, err := os.Create(path) f, err := os.Create(path)
if err != nil { if err != nil {
slog.Error("failed to create tile file", "path", path, "error", err) slog.Error("failed to create tile file", "path", path, "error", err)
return return
} }
if err := png.Encode(f, img); err != nil { if err := png.Encode(f, img); err != nil {
f.Close() f.Close()
os.Remove(path) os.Remove(path)
slog.Error("failed to encode tile PNG", "path", path, "error", err) slog.Error("failed to encode tile PNG", "path", path, "error", err)
return return
} }
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
slog.Error("failed to close tile file", "path", path, "error", err) slog.Error("failed to close tile file", "path", path, "error", err)
return return
} }
s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano()) s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano())
} }
// RebuildZooms rebuilds all zoom levels from base tiles. // RebuildZooms rebuilds all zoom levels from base tiles.
// It can take a long time for many grids; the client should account for request timeouts. // It can take a long time for many grids; the client should account for request timeouts.
func (s *MapService) RebuildZooms(ctx context.Context) error { func (s *MapService) RebuildZooms(ctx context.Context) error {
needProcess := map[zoomproc]struct{}{} needProcess := map[zoomproc]struct{}{}
saveGrid := map[zoomproc]string{} saveGrid := map[zoomproc]string{}
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketGrids) b := tx.Bucket(store.BucketGrids)
if b == nil { if b == nil {
return nil return nil
} }
if err := b.ForEach(func(k, v []byte) error { if err := b.ForEach(func(k, v []byte) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
default: default:
} }
grid := app.GridData{} grid := app.GridData{}
if err := json.Unmarshal(v, &grid); err != nil { if err := json.Unmarshal(v, &grid); err != nil {
return err return err
} }
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{} needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
return nil return nil
}); err != nil { }); err != nil {
return err return err
} }
if err := tx.DeleteBucket(store.BucketTiles); err != nil { if err := tx.DeleteBucket(store.BucketTiles); err != nil {
return err return err
} }
return nil return nil
}); err != nil { }); err != nil {
slog.Error("RebuildZooms: failed to update store", "error", err) slog.Error("RebuildZooms: failed to update store", "error", err)
return err return err
} }
for g, id := range saveGrid { for g, id := range saveGrid {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()
} }
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id) f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
if _, err := os.Stat(f); err != nil { if _, err := os.Stat(f); err != nil {
continue continue
} }
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano()) s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
} }
for z := 1; z <= app.MaxZoomLevel; z++ { for z := 1; z <= app.MaxZoomLevel; z++ {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()
} }
process := needProcess process := needProcess
needProcess = map[zoomproc]struct{}{} needProcess = map[zoomproc]struct{}{}
for p := range process { for p := range process {
s.UpdateZoomLevel(ctx, p.m, p.c, z) s.UpdateZoomLevel(ctx, p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{} needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
} }
} }
return nil return nil
} }
// ReportMerge sends a merge event. // ReportMerge sends a merge event.
func (s *MapService) ReportMerge(from, to int, shift app.Coord) { func (s *MapService) ReportMerge(from, to int, shift app.Coord) {
s.mergeUpdates.Send(&app.Merge{ s.mergeUpdates.Send(&app.Merge{
From: from, From: from,
To: to, To: to,
Shift: shift, Shift: shift,
}) })
} }
// WatchTiles creates a channel that receives tile updates. // WatchTiles creates a channel that receives tile updates.
func (s *MapService) WatchTiles() chan *app.TileData { func (s *MapService) WatchTiles() chan *app.TileData {
c := make(chan *app.TileData, app.SSETileChannelSize) c := make(chan *app.TileData, app.SSETileChannelSize)
s.gridUpdates.Watch(c) s.gridUpdates.Watch(c)
return c return c
} }
// WatchMerges creates a channel that receives merge updates. // WatchMerges creates a channel that receives merge updates.
func (s *MapService) WatchMerges() chan *app.Merge { func (s *MapService) WatchMerges() chan *app.Merge {
c := make(chan *app.Merge, app.SSEMergeChannelSize) c := make(chan *app.Merge, app.SSEMergeChannelSize)
s.mergeUpdates.Watch(c) s.mergeUpdates.Watch(c)
return c return c
} }
// GetAllTileCache returns all tiles for the initial SSE cache dump. // GetAllTileCache returns all tiles for the initial SSE cache dump.
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache { func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
var cache []TileCache var cache []TileCache
_ = s.st.View(ctx, func(tx *bbolt.Tx) error { _ = s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error { return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
default: default:
} }
td := app.TileData{} td := app.TileData{}
if err := json.Unmarshal(v, &td); err != nil { if err := json.Unmarshal(v, &td); err != nil {
return err return err
} }
cache = append(cache, TileCache{ cache = append(cache, TileCache{
M: td.MapID, M: td.MapID,
X: td.Coord.X, X: td.Coord.X,
Y: td.Coord.Y, Y: td.Coord.Y,
Z: td.Zoom, Z: td.Zoom,
T: int(td.Cache), T: int(td.Cache),
}) })
return nil return nil
}) })
}) })
return cache return cache
} }
// TileCache represents a minimal tile entry for SSE streaming. // TileCache represents a minimal tile entry for SSE streaming.
type TileCache struct { type TileCache struct {
M, X, Y, Z, T int M, X, Y, Z, T int
} }
// ProcessZoomLevels processes zoom levels for a set of tile operations. // ProcessZoomLevels processes zoom levels for a set of tile operations.
func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) { func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) {
needProcess := map[zoomproc]struct{}{} needProcess := map[zoomproc]struct{}{}
for _, op := range ops { for _, op := range ops {
s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano()) s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{} needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{}
} }
for z := 1; z <= app.MaxZoomLevel; z++ { for z := 1; z <= app.MaxZoomLevel; z++ {
process := needProcess process := needProcess
needProcess = map[zoomproc]struct{}{} needProcess = map[zoomproc]struct{}{}
for p := range process { for p := range process {
s.UpdateZoomLevel(ctx, p.m, p.c, z) s.UpdateZoomLevel(ctx, p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{} needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
} }
} }
} }
// TileOp represents a tile save operation. // TileOp represents a tile save operation.
type TileOp struct { type TileOp struct {
MapID int MapID int
X, Y int X, Y int
File string File string
} }