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/toggle-hidden** — toggle map visibility.
- **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).
- **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`.

View File

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

View File

@@ -56,7 +56,12 @@
Display
</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' : ''">
<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>
</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' : ''">

View File

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

View File

@@ -95,6 +95,8 @@ export async function initLeafletMap(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
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({
tileSize: TileSize,
minZoom: HnHMinZoom,
@@ -103,10 +105,8 @@ export async function initLeafletMap(
visible: false,
pane: 'tilePane',
updateWhenIdle: true,
keepBuffer: 2,
keepBuffer: 4,
} as GridCoordLayerOptions)
coordLayer.addTo(map)
coordLayer.setZIndex(500)
const markerLayer = L.layerGroup()
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">
<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" />
<span class="flex-1 text-sm">Тёмная тема</span>
<span class="flex-1 text-sm">Dark theme</span>
<input
type="checkbox"
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">
<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" />
<span class="flex-1 text-sm">Тёмная тема</span>
<span class="flex-1 text-sm">Dark theme</span>
<input
type="checkbox"
class="toggle toggle-sm toggle-primary shrink-0"
@@ -244,6 +244,8 @@ const { isLoginPath } = useAppPaths()
const isLogin = computed(() => isLoginPath(route.path))
const isAdmin = computed(() => !!me.value?.auths?.includes('admin'))
let loadId = 0
async function loadMe() {
if (isLogin.value) return
try {
@@ -253,10 +255,12 @@ async function loadMe() {
}
}
async function loadConfig() {
async function loadConfig(loadToken: number) {
if (isLogin.value) return
if (loadToken !== loadId) return
try {
const config = await useMapApi().getConfig()
if (loadToken !== loadId) return
if (config?.title) title.value = config.title
} catch (_) {}
}
@@ -269,7 +273,10 @@ onMounted(() => {
watch(
() => route.path,
(path) => {
if (!isLoginPath(path)) loadMe().then(loadConfig)
if (!isLoginPath(path)) {
const currentLoadId = ++loadId
loadMe().then(() => loadConfig(currentLoadId))
}
},
{ immediate: true }
)

View File

@@ -1,21 +1,21 @@
import type L from 'leaflet'
import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
export type LeafletApi = typeof import('leaflet')
/** SVG data URL for character marker icon (teal pin, bottom-center anchor). */
const CHARACTER_ICON_URL =
'data:image/svg+xml,' +
encodeURIComponent(
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="#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"/>' +
'<circle cx="12" cy="8" r="2.5" fill="white"/>' +
'</svg>'
)
`<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)
}
function createCharacterIcon(L: LeafletApi): L.Icon {
export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon {
return new L.Icon({
iconUrl: CHARACTER_ICON_URL,
iconUrl: buildCharacterIconUrl(colors),
iconSize: [24, 32],
iconAnchor: [12, 32],
popupAnchor: [0, -32],
@@ -28,6 +28,8 @@ export interface CharacterData {
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 {
@@ -44,6 +46,7 @@ export interface MapCharacter {
map: number
text: string
value: number
ownedByMe?: boolean
leafletMarker: L.Marker | null
remove: (mapview: CharacterMapViewRef) => void
add: (mapview: CharacterMapViewRef) => void
@@ -54,7 +57,9 @@ export interface MapCharacter {
export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter {
let leafletMarker: L.Marker | 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 = {
id: data.id,
@@ -64,6 +69,12 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
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
@@ -90,6 +101,12 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
},
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)
}

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.
* coords.(x,y,z) are Leaflet tile indices and zoom; they map to game tiles as:
* scaleFactor = 2^(HnHMaxZoom - coords.z),
* topLeft = (coords.x * scaleFactor, coords.y * scaleFactor).
* This matches backend tile URL {mapid}/{z}/{x}_{y}.png (storageZ: z=6→0, Coord = tile index).
* Uses only coords.z (do not use map.getZoom()) so labels stay in sync with tiles.
* SmartTileLayer has zoomReverse: true, so URL z = 6 - coords.z; backend maps z=6 → storageZ=0.
* Game tile for this Leaflet tile: scaleFactor = 2^(HnHMaxZoom - coords.z),
* topLeft = (coords.x * scaleFactor, coords.y * scaleFactor) — same (x,y) as backend tile.
*/
export const GridCoordLayer = L.GridLayer.extend({
options: {

View File

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

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()
}
const { markRebuildDone } = useRebuildZoomsInvalidation()
async function doRebuildZooms() {
rebuildModalRef.value?.close()
rebuilding.value = true
try {
await api.adminRebuildZooms()
markRebuildDone()
toast.success('Zooms rebuilt.')
} catch (e) {
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 {
return
}
h.Admin.RebuildZooms(req.Context())
if err := h.Admin.RebuildZooms(req.Context()); err != nil {
HandleServiceError(rw, err)
return
}
rw.WriteHeader(http.StatusOK)
}

View File

@@ -349,6 +349,6 @@ func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
}
// RebuildZooms delegates to MapService.
func (s *AdminService) RebuildZooms(ctx context.Context) {
s.mapSvc.RebuildZooms(ctx)
func (s *AdminService) RebuildZooms(ctx context.Context) error {
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 {
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 nil
return s.mapSvc.RebuildZooms(ctx)
}
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)
return
}
f, err := os.Create(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())
path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
f, err := os.Create(path)
if err != nil {
slog.Error("failed to create tile file", "path", path, "error", err)
return
}
defer f.Close()
png.Encode(f, img)
if err := png.Encode(f, img); err != nil {
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.
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{}{}
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)
if b == nil {
return nil
@@ -254,7 +266,10 @@ func (s *MapService) RebuildZooms(ctx context.Context) {
})
tx.DeleteBucket(store.BucketTiles)
return nil
})
}); err != nil {
slog.Error("RebuildZooms: failed to update store", "error", err)
return err
}
for g, id := range saveGrid {
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{}{}
}
}
return nil
}
// ReportMerge sends a merge event.