Enhance map functionality and API documentation

- Updated API documentation for the `rebuildZooms` endpoint to clarify its long execution time and response behavior.
- Modified MapView component to manage tile cache invalidation after rebuilding zoom levels, ensuring fresh tile display.
- Introduced a new composable for handling tile cache invalidation state after admin actions.
- Enhanced character icon creation to reflect ownership status with distinct colors.
- Improved loading state handling in various components for better user experience during data fetching.
This commit is contained in:
2026-03-01 19:09:46 +03:00
parent 8331473808
commit 225aaa36e7
16 changed files with 236 additions and 52 deletions

View File

@@ -40,7 +40,7 @@ The API is available under the `/map/api/` prefix. Requests requiring authentica
- **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority). - **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority).
- **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility. - **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility.
- **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database. - **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database.
- **POST /map/api/admin/rebuildZooms** — rebuild tile zoom levels. - **POST /map/api/admin/rebuildZooms** — rebuild tile zoom levels from base tiles. The operation can take a long time (minutes) when there are many grids; the client should allow for request timeouts or show appropriate loading state. On success returns 200; on failure (e.g. store error) returns 500.
- **GET /map/api/admin/export** — download data export (ZIP). - **GET /map/api/admin/export** — download data export (ZIP).
- **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers). - **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers).
- **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`. - **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`.

View File

@@ -195,6 +195,8 @@ let intervalId: ReturnType<typeof setInterval> | null = null
let autoMode = false let autoMode = false
let mapContainer: HTMLElement | null = null let mapContainer: HTMLElement | null = null
let contextMenuHandler: ((ev: MouseEvent) => void) | null = null let contextMenuHandler: ((ev: MouseEvent) => void) | null = null
let mounted = false
let visibilityChangeHandler: (() => void) | null = null
function toLatLng(x: number, y: number) { function toLatLng(x: number, y: number) {
return leafletMap!.unproject([x, y], HnHMaxZoom) return leafletMap!.unproject([x, y], HnHMaxZoom)
@@ -264,6 +266,7 @@ function onKeydown(e: KeyboardEvent) {
} }
onMounted(async () => { onMounted(async () => {
mounted = true
if (import.meta.client) { if (import.meta.client) {
window.addEventListener('keydown', onKeydown) window.addEventListener('keydown', onKeydown)
} }
@@ -353,9 +356,11 @@ onMounted(async () => {
getCurrentMapId: () => mapLogic.state.mapid.value, getCurrentMapId: () => mapLogic.state.mapid.value,
connectionStateRef: sseConnectionState, connectionStateRef: sseConnectionState,
onMerge: (mapTo: number, shift: { x: number; y: number }) => { onMerge: (mapTo: number, shift: { x: number; y: number }) => {
if (!mounted) return
const latLng = toLatLng(shift.x * 100, shift.y * 100) const latLng = toLatLng(shift.x * 100, shift.y * 100)
layersManager!.changeMap(mapTo) layersManager!.changeMap(mapTo)
api.getMarkers().then((body) => { api.getMarkers().then((body) => {
if (!mounted) return
layersManager!.updateMarkers(Array.isArray(body) ? body : []) layersManager!.updateMarkers(Array.isArray(body) ? body : [])
questGivers.value = layersManager!.getQuestGivers() questGivers.value = layersManager!.getQuestGivers()
}) })
@@ -363,6 +368,17 @@ onMounted(async () => {
}, },
}) })
const { shouldInvalidateTileCache, clearRebuildDoneFlag } = useRebuildZoomsInvalidation()
if (shouldInvalidateTileCache()) {
mapInit.layer.cache = {}
mapInit.overlayLayer.cache = {}
clearRebuildDoneFlag()
nextTick(() => {
mapInit.layer.redraw()
mapInit.overlayLayer.redraw()
})
}
const charsList = Array.isArray(charactersData) ? charactersData : [] const charsList = Array.isArray(charactersData) ? charactersData : []
layersManager.updateCharacters(charsList) layersManager.updateCharacters(charsList)
players.value = layersManager.getPlayers() players.value = layersManager.getPlayers()
@@ -395,6 +411,7 @@ onMounted(async () => {
// Markers load asynchronously after map is visible. // Markers load asynchronously after map is visible.
api.getMarkers().then((body) => { api.getMarkers().then((body) => {
if (!mounted) return
layersManager!.updateMarkers(Array.isArray(body) ? body : []) layersManager!.updateMarkers(Array.isArray(body) ? body : [])
questGivers.value = layersManager!.getQuestGivers() questGivers.value = layersManager!.getQuestGivers()
}) })
@@ -406,6 +423,7 @@ onMounted(async () => {
api api
.getCharacters() .getCharacters()
.then((body) => { .then((body) => {
if (!mounted) return
const list = Array.isArray(body) ? body : [] const list = Array.isArray(body) ? body : []
layersManager!.updateCharacters(list) layersManager!.updateCharacters(list)
players.value = layersManager!.getPlayers() players.value = layersManager!.getPlayers()
@@ -429,17 +447,31 @@ onMounted(async () => {
startCharacterPoll() startCharacterPoll()
if (import.meta.client) { if (import.meta.client) {
document.addEventListener('visibilitychange', () => { visibilityChangeHandler = () => {
startCharacterPoll() startCharacterPoll()
}) }
document.addEventListener('visibilitychange', visibilityChangeHandler)
} }
watch(mapLogic.state.showGridCoordinates, (v) => { watch(mapLogic.state.showGridCoordinates, (v) => {
if (mapInit?.coordLayer) { if (!mapInit?.coordLayer || !leafletMap) return
;(mapInit.coordLayer.options as { visible?: boolean }).visible = v const coordLayer = mapInit.coordLayer
mapInit.coordLayer.setOpacity(v ? 1 : 0) const layerWithMap = coordLayer as L.GridLayer & { _map?: L.Map }
mapInit.coordLayer.redraw?.() if (v) {
if (v) mapInit.coordLayer.bringToFront?.() ;(coordLayer.options as { visible?: boolean }).visible = true
if (!layerWithMap._map) {
coordLayer.addTo(leafletMap)
coordLayer.setZIndex(500)
coordLayer.setOpacity(1)
coordLayer.bringToFront?.()
}
leafletMap.invalidateSize()
nextTick(() => {
coordLayer.redraw?.()
})
} else {
coordLayer.setOpacity(0)
coordLayer.remove()
} }
}) })
@@ -553,8 +585,12 @@ onMounted(async () => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
mounted = false
if (import.meta.client) { if (import.meta.client) {
window.removeEventListener('keydown', onKeydown) window.removeEventListener('keydown', onKeydown)
if (visibilityChangeHandler) {
document.removeEventListener('visibilitychange', visibilityChangeHandler)
}
} }
mapNavigate.setGoTo(null) mapNavigate.setGoTo(null)
if (contextMenuHandler) { if (contextMenuHandler) {

View File

@@ -56,7 +56,12 @@
Display Display
</h3> </h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''"> <label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" /> <input
v-model="showGridCoordinates"
type="checkbox"
class="checkbox checkbox-sm"
data-testid="show-grid-coordinates"
/>
<span>Show grid coordinates</span> <span>Show grid coordinates</span>
</label> </label>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''"> <label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">

View File

@@ -117,7 +117,7 @@ export function useMapApi() {
// Profile // Profile
async function meTokens() { async function meTokens() {
const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' }) const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' })
return data!.tokens return data?.tokens ?? []
} }
async function mePassword(pass: string) { async function mePassword(pass: string) {

View File

@@ -95,6 +95,8 @@ export async function initLeafletMap(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
overlayLayer.addTo(map) overlayLayer.addTo(map)
// Not added to map here — added in MapView when user enables "Show grid coordinates"
// so createTile() always runs with visible: true and labels render (no empty tiles from init).
const coordLayer = new GridCoordLayer({ const coordLayer = new GridCoordLayer({
tileSize: TileSize, tileSize: TileSize,
minZoom: HnHMinZoom, minZoom: HnHMinZoom,
@@ -103,10 +105,8 @@ export async function initLeafletMap(
visible: false, visible: false,
pane: 'tilePane', pane: 'tilePane',
updateWhenIdle: true, updateWhenIdle: true,
keepBuffer: 2, keepBuffer: 4,
} as GridCoordLayerOptions) } as GridCoordLayerOptions)
coordLayer.addTo(map)
coordLayer.setZIndex(500)
const markerLayer = L.layerGroup() const markerLayer = L.layerGroup()
markerLayer.addTo(map) markerLayer.addTo(map)

View File

@@ -0,0 +1,30 @@
/**
* Shared state for tile cache invalidation after admin "Rebuild zooms".
* Admin sets the timestamp on success; MapView on mount clears tile caches
* when a rebuild was done recently, so the map shows fresh tiles even if
* SSE was missed (e.g. admin in another tab).
*/
const REBUILD_DONE_TTL_MS = 5 * 60 * 1000 // 5 minutes
export function useRebuildZoomsInvalidation() {
const rebuildZoomsDoneAt = useState('rebuildZoomsDoneAt', () => 0)
function markRebuildDone() {
rebuildZoomsDoneAt.value = Date.now()
}
function shouldInvalidateTileCache(): boolean {
if (rebuildZoomsDoneAt.value <= 0) return false
return Date.now() - rebuildZoomsDoneAt.value < REBUILD_DONE_TTL_MS
}
function clearRebuildDoneFlag() {
rebuildZoomsDoneAt.value = 0
}
return {
markRebuildDone,
shouldInvalidateTileCache,
clearRebuildDoneFlag,
}
}

View File

@@ -85,7 +85,7 @@
<label class="flex cursor-pointer items-center gap-3 py-2 px-2 rounded-lg hover:bg-base-300/50 w-full min-h-0"> <label class="flex cursor-pointer items-center gap-3 py-2 px-2 rounded-lg hover:bg-base-300/50 w-full min-h-0">
<icons-icon-sun v-if="!dark" class="size-4 shrink-0 opacity-80" /> <icons-icon-sun v-if="!dark" class="size-4 shrink-0 opacity-80" />
<icons-icon-moon v-else class="size-4 shrink-0 opacity-80" /> <icons-icon-moon v-else class="size-4 shrink-0 opacity-80" />
<span class="flex-1 text-sm">Тёмная тема</span> <span class="flex-1 text-sm">Dark theme</span>
<input <input
type="checkbox" type="checkbox"
class="toggle toggle-sm toggle-primary shrink-0" class="toggle toggle-sm toggle-primary shrink-0"
@@ -183,7 +183,7 @@
<label class="flex cursor-pointer items-center gap-3 py-3 px-2 rounded-lg hover:bg-base-300/50 w-full min-h-0 touch-manipulation"> <label class="flex cursor-pointer items-center gap-3 py-3 px-2 rounded-lg hover:bg-base-300/50 w-full min-h-0 touch-manipulation">
<icons-icon-sun v-if="!dark" class="size-4 shrink-0 opacity-80" /> <icons-icon-sun v-if="!dark" class="size-4 shrink-0 opacity-80" />
<icons-icon-moon v-else class="size-4 shrink-0 opacity-80" /> <icons-icon-moon v-else class="size-4 shrink-0 opacity-80" />
<span class="flex-1 text-sm">Тёмная тема</span> <span class="flex-1 text-sm">Dark theme</span>
<input <input
type="checkbox" type="checkbox"
class="toggle toggle-sm toggle-primary shrink-0" class="toggle toggle-sm toggle-primary shrink-0"
@@ -244,6 +244,8 @@ const { isLoginPath } = useAppPaths()
const isLogin = computed(() => isLoginPath(route.path)) const isLogin = computed(() => isLoginPath(route.path))
const isAdmin = computed(() => !!me.value?.auths?.includes('admin')) const isAdmin = computed(() => !!me.value?.auths?.includes('admin'))
let loadId = 0
async function loadMe() { async function loadMe() {
if (isLogin.value) return if (isLogin.value) return
try { try {
@@ -253,10 +255,12 @@ async function loadMe() {
} }
} }
async function loadConfig() { async function loadConfig(loadToken: number) {
if (isLogin.value) return if (isLogin.value) return
if (loadToken !== loadId) return
try { try {
const config = await useMapApi().getConfig() const config = await useMapApi().getConfig()
if (loadToken !== loadId) return
if (config?.title) title.value = config.title if (config?.title) title.value = config.title
} catch (_) {} } catch (_) {}
} }
@@ -269,7 +273,10 @@ onMounted(() => {
watch( watch(
() => route.path, () => route.path,
(path) => { (path) => {
if (!isLoginPath(path)) loadMe().then(loadConfig) if (!isLoginPath(path)) {
const currentLoadId = ++loadId
loadMe().then(() => loadConfig(currentLoadId))
}
}, },
{ immediate: true } { immediate: true }
) )

View File

@@ -1,21 +1,21 @@
import type L from 'leaflet' import type L from 'leaflet'
import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
export type LeafletApi = typeof import('leaflet') export type LeafletApi = typeof import('leaflet')
/** SVG data URL for character marker icon (teal pin, bottom-center anchor). */ function buildCharacterIconUrl(colors: CharacterColors): string {
const CHARACTER_ICON_URL = const svg =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 32" width="24" height="32">' + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 32" width="24" height="32">' +
'<path fill="#0d9488" stroke="#0f766e" stroke-width="1" d="M12 2a6 6 0 0 1 6 6c0 4-6 10-6 10s-6-6-6-10a6 6 0 0 1 6-6z"/>' + `<path fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="1" d="M12 2a6 6 0 0 1 6 6c0 4-6 10-6 10s-6-6-6-10a6 6 0 0 1 6-6z"/>` +
'<circle cx="12" cy="8" r="2.5" fill="white"/>' + '<circle cx="12" cy="8" r="2.5" fill="white"/>' +
'</svg>' '</svg>'
) return 'data:image/svg+xml,' + encodeURIComponent(svg)
}
function createCharacterIcon(L: LeafletApi): L.Icon { export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon {
return new L.Icon({ return new L.Icon({
iconUrl: CHARACTER_ICON_URL, iconUrl: buildCharacterIconUrl(colors),
iconSize: [24, 32], iconSize: [24, 32],
iconAnchor: [12, 32], iconAnchor: [12, 32],
popupAnchor: [0, -32], popupAnchor: [0, -32],
@@ -28,6 +28,8 @@ export interface CharacterData {
type: string type: string
id: number id: number
map: number map: number
/** True when this character was last updated by one of the current user's tokens. */
ownedByMe?: boolean
} }
export interface CharacterMapViewRef { export interface CharacterMapViewRef {
@@ -44,6 +46,7 @@ export interface MapCharacter {
map: number map: number
text: string text: string
value: number value: number
ownedByMe?: boolean
leafletMarker: L.Marker | null leafletMarker: L.Marker | null
remove: (mapview: CharacterMapViewRef) => void remove: (mapview: CharacterMapViewRef) => void
add: (mapview: CharacterMapViewRef) => void add: (mapview: CharacterMapViewRef) => void
@@ -54,7 +57,9 @@ export interface MapCharacter {
export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter { export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter {
let leafletMarker: L.Marker | null = null let leafletMarker: L.Marker | null = null
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
const characterIcon = createCharacterIcon(L) let ownedByMe = data.ownedByMe ?? false
const colors = getColorForCharacterId(data.id, { ownedByMe })
let characterIcon = createCharacterIcon(L, colors)
const character: MapCharacter = { const character: MapCharacter = {
id: data.id, id: data.id,
@@ -64,6 +69,12 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
map: data.map, map: data.map,
text: data.name, text: data.name,
value: data.id, value: data.id,
get ownedByMe() {
return ownedByMe
},
set ownedByMe(v: boolean | undefined) {
ownedByMe = v ?? false
},
get leafletMarker() { get leafletMarker() {
return leafletMarker return leafletMarker
@@ -90,6 +101,12 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
}, },
update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void { 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) { if (character.map !== updated.map) {
character.remove(mapview) character.remove(mapview)
} }

View File

@@ -11,10 +11,10 @@ export interface GridCoordLayerOptions extends L.GridLayerOptions {
/** /**
* Grid layer that draws one coordinate label per Leaflet tile in the top-left corner. * Grid layer that draws one coordinate label per Leaflet tile in the top-left corner.
* coords.(x,y,z) are Leaflet tile indices and zoom; they map to game tiles as: * Uses only coords.z (do not use map.getZoom()) so labels stay in sync with tiles.
* scaleFactor = 2^(HnHMaxZoom - coords.z), * SmartTileLayer has zoomReverse: true, so URL z = 6 - coords.z; backend maps z=6 → storageZ=0.
* topLeft = (coords.x * scaleFactor, coords.y * scaleFactor). * Game tile for this Leaflet tile: scaleFactor = 2^(HnHMaxZoom - coords.z),
* This matches backend tile URL {mapid}/{z}/{x}_{y}.png (storageZ: z=6→0, Coord = tile index). * topLeft = (coords.x * scaleFactor, coords.y * scaleFactor) — same (x,y) as backend tile.
*/ */
export const GridCoordLayer = L.GridLayer.extend({ export const GridCoordLayer = L.GridLayer.extend({
options: { options: {

View File

@@ -5,10 +5,15 @@ vi.mock('leaflet', () => {
on: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(), addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(), setLatLng: vi.fn().mockReturnThis(),
setIcon: vi.fn().mockReturnThis(),
} }
return { return {
default: { marker: vi.fn(() => markerMock) }, default: {
marker: vi.fn(() => markerMock), marker: vi.fn(() => markerMock),
Icon: vi.fn().mockImplementation(() => ({})),
},
marker: vi.fn(() => markerMock),
Icon: vi.fn().mockImplementation(() => ({})),
} }
}) })
@@ -16,8 +21,13 @@ vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6, HnHMaxZoom: 6,
})) }))
import type L from 'leaflet'
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character' import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
function getL(): L {
return require('leaflet').default
}
function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData { function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData {
return { return {
name: 'Hero', name: 'Hero',
@@ -49,7 +59,7 @@ describe('createCharacter', () => {
}) })
it('creates character with correct properties', () => { it('creates character with correct properties', () => {
const char = createCharacter(makeCharData()) const char = createCharacter(makeCharData(), getL())
expect(char.id).toBe(1) expect(char.id).toBe(1)
expect(char.name).toBe('Hero') expect(char.name).toBe('Hero')
expect(char.position).toEqual({ x: 100, y: 200 }) expect(char.position).toEqual({ x: 100, y: 200 })
@@ -60,26 +70,26 @@ describe('createCharacter', () => {
}) })
it('starts with null leaflet marker', () => { it('starts with null leaflet marker', () => {
const char = createCharacter(makeCharData()) const char = createCharacter(makeCharData(), getL())
expect(char.leafletMarker).toBeNull() expect(char.leafletMarker).toBeNull()
}) })
it('add creates marker when character is on correct map', () => { it('add creates marker when character is on correct map', () => {
const char = createCharacter(makeCharData()) const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.add(mapview) char.add(mapview)
expect(mapview.map.unproject).toHaveBeenCalled() expect(mapview.map.unproject).toHaveBeenCalled()
}) })
it('add does not create marker for different map', () => { it('add does not create marker for different map', () => {
const char = createCharacter(makeCharData({ map: 2 })) const char = createCharacter(makeCharData({ map: 2 }), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.add(mapview) char.add(mapview)
expect(mapview.map.unproject).not.toHaveBeenCalled() expect(mapview.map.unproject).not.toHaveBeenCalled()
}) })
it('update changes position and map', () => { it('update changes position and map', () => {
const char = createCharacter(makeCharData()) const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.update(mapview, { char.update(mapview, {
@@ -93,15 +103,25 @@ describe('createCharacter', () => {
}) })
it('remove on a character without leaflet marker does nothing', () => { it('remove on a character without leaflet marker does nothing', () => {
const char = createCharacter(makeCharData()) const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
char.remove(mapview) // should not throw char.remove(mapview) // should not throw
expect(char.leafletMarker).toBeNull() expect(char.leafletMarker).toBeNull()
}) })
it('setClickCallback works', () => { it('setClickCallback works', () => {
const char = createCharacter(makeCharData()) const char = createCharacter(makeCharData(), getL())
const cb = vi.fn() const cb = vi.fn()
char.setClickCallback(cb) char.setClickCallback(cb)
}) })
it('update with changed ownedByMe updates marker icon', () => {
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)
})
}) })

View File

@@ -0,0 +1,48 @@
/**
* Palette of distinguishable colors for character markers (readable on typical map tiles).
* Same character id always maps to the same color via id % palette.length.
*/
const CHARACTER_PALETTE_FILL: string[] = [
'#0d9488', // teal
'#dc2626', // red
'#2563eb', // blue
'#16a34a', // green
'#ea580c', // orange
'#7c3aed', // violet
'#db2777', // pink
'#ca8a04', // yellow/amber
'#0891b2', // cyan
'#65a30d', // lime
'#4f46e5', // indigo
'#b45309', // brown/amber-dark
]
/** Darken a hex color by a factor (01). Used for stroke. */
function darkenHex(hex: string, factor: number): string {
const n = hex.slice(1)
const r = Math.max(0, parseInt(n.slice(0, 2), 16) * (1 - factor))
const g = Math.max(0, parseInt(n.slice(2, 4), 16) * (1 - factor))
const b = Math.max(0, parseInt(n.slice(4, 6), 16) * (1 - factor))
return '#' + [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, '0')).join('')
}
export interface CharacterColors {
fill: string
stroke: string
}
/**
* Returns stable fill and stroke colors for a character by id.
* Optionally use a distinct style for "owned by me" (e.g. brighter stroke).
*/
export function getColorForCharacterId(
id: number,
options?: { ownedByMe?: boolean }
): CharacterColors {
const fill = CHARACTER_PALETTE_FILL[Math.abs(id) % CHARACTER_PALETTE_FILL.length]
const stroke = darkenHex(fill, 0.25)
if (options?.ownedByMe) {
return { fill, stroke: '#1e293b' }
}
return { fill, stroke }
}

View File

@@ -389,11 +389,14 @@ function confirmRebuildZooms() {
rebuildModalRef.value?.showModal() rebuildModalRef.value?.showModal()
} }
const { markRebuildDone } = useRebuildZoomsInvalidation()
async function doRebuildZooms() { async function doRebuildZooms() {
rebuildModalRef.value?.close() rebuildModalRef.value?.close()
rebuilding.value = true rebuilding.value = true
try { try {
await api.adminRebuildZooms() await api.adminRebuildZooms()
markRebuildDone()
toast.success('Zooms rebuilt.') toast.success('Zooms rebuilt.')
} catch (e) { } catch (e) {
toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.') toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.')

View File

@@ -334,7 +334,10 @@ func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Reques
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
h.Admin.RebuildZooms(req.Context()) if err := h.Admin.RebuildZooms(req.Context()); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }

View File

@@ -349,6 +349,6 @@ func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
} }
// RebuildZooms delegates to MapService. // RebuildZooms delegates to MapService.
func (s *AdminService) RebuildZooms(ctx context.Context) { func (s *AdminService) RebuildZooms(ctx context.Context) error {
s.mapSvc.RebuildZooms(ctx) return s.mapSvc.RebuildZooms(ctx)
} }

View File

@@ -207,8 +207,7 @@ func (s *ExportService) Merge(ctx context.Context, zr *zip.Reader) error {
for _, op := range ops { for _, op := range ops {
s.mapSvc.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano()) s.mapSvc.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
} }
s.mapSvc.RebuildZooms(ctx) return s.mapSvc.RebuildZooms(ctx)
return nil
} }
func (s *ExportService) processMergeJSON( func (s *ExportService) processMergeJSON(

View File

@@ -226,21 +226,33 @@ func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord
slog.Error("failed to create zoom dir", "error", err) slog.Error("failed to create zoom dir", "error", err)
return return
} }
f, err := os.Create(fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())) path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
s.SaveTile(ctx, mapid, c, z, fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name()), time.Now().UnixNano()) relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
f, err := os.Create(path)
if err != nil { if err != nil {
slog.Error("failed to create tile file", "path", path, "error", err)
return return
} }
defer f.Close() if err := png.Encode(f, img); err != nil {
png.Encode(f, img) f.Close()
os.Remove(path)
slog.Error("failed to encode tile PNG", "path", path, "error", err)
return
}
if err := f.Close(); err != nil {
slog.Error("failed to close tile file", "path", path, "error", err)
return
}
s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano())
} }
// RebuildZooms rebuilds all zoom levels from base tiles. // RebuildZooms rebuilds all zoom levels from base tiles.
func (s *MapService) RebuildZooms(ctx context.Context) { // It can take a long time for many grids; the client should account for request timeouts.
func (s *MapService) RebuildZooms(ctx context.Context) error {
needProcess := map[zoomproc]struct{}{} needProcess := map[zoomproc]struct{}{}
saveGrid := map[zoomproc]string{} saveGrid := map[zoomproc]string{}
s.st.Update(ctx, func(tx *bbolt.Tx) error { if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketGrids) b := tx.Bucket(store.BucketGrids)
if b == nil { if b == nil {
return nil return nil
@@ -254,7 +266,10 @@ func (s *MapService) RebuildZooms(ctx context.Context) {
}) })
tx.DeleteBucket(store.BucketTiles) tx.DeleteBucket(store.BucketTiles)
return nil return nil
}) }); err != nil {
slog.Error("RebuildZooms: failed to update store", "error", err)
return err
}
for g, id := range saveGrid { for g, id := range saveGrid {
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id) f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
@@ -271,6 +286,7 @@ func (s *MapService) RebuildZooms(ctx context.Context) {
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{} needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
} }
} }
return nil
} }
// ReportMerge sends a merge event. // ReportMerge sends a merge event.