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:
@@ -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`.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' : ''">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
30
frontend-nuxt/composables/useRebuildZoomsInvalidation.ts
Normal file
30
frontend-nuxt/composables/useRebuildZoomsInvalidation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
48
frontend-nuxt/lib/characterColors.ts
Normal file
48
frontend-nuxt/lib/characterColors.ts
Normal 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 (0–1). 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 }
|
||||
}
|
||||
@@ -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.')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user