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:
@@ -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' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user