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** — 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`.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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' : ''">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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">
|
<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 }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
Icon: vi.fn().mockImplementation(() => ({})),
|
||||||
|
},
|
||||||
marker: vi.fn(() => markerMock),
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
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()
|
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.')
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user