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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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