Enhance map functionality with bookmark features and UI improvements

- Introduced a new MapBookmarkNameModal for adding and editing bookmarks.
- Updated MapView to manage selected markers for bookmarking and handle bookmark name submissions.
- Enhanced MapContextMenu with an option to add markers to bookmarks.
- Improved MapBookmarks component to support editing bookmark names and adding selected markers.
- Refactored MapControls and MapControlsContent to integrate selected marker functionality for bookmarks.
- Updated useMapBookmarks composable to include bookmark updating logic.
- Removed unused grid coordinates toggle from the UI for a cleaner interface.
This commit is contained in:
2026-03-03 20:05:42 +03:00
parent d27eb2651e
commit 52c34ef8f2
15 changed files with 425 additions and 244 deletions

View File

@@ -34,14 +34,20 @@
/> />
<div <div
v-if="sseConnectionState === 'error' && mapReady" v-if="sseConnectionState === 'error' && mapReady"
class="absolute bottom-4 left-1/2 z-[501] -translate-x-1/2 rounded-lg px-3 py-2 text-sm bg-warning/90 text-warning-content shadow" class="absolute bottom-4 left-1/2 z-[501] -translate-x-1/2 rounded-lg px-3 py-2 text-sm bg-warning/90 text-warning-content shadow flex items-center gap-2"
role="status" role="status"
title="Live updates will resume when reconnected"
> >
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<span class="inline-block size-2 animate-pulse rounded-full bg-current" aria-hidden="true" /> <span class="inline-block size-2 animate-pulse rounded-full bg-current" aria-hidden="true" />
Reconnecting Connection lost. Reconnecting
</span> </span>
<button
type="button"
class="btn btn-ghost btn-xs text-warning-content hover:bg-warning-content/20"
@click="reloadPage"
>
Reload
</button>
</div> </div>
<div class="absolute top-2 right-2 z-[501] flex flex-col gap-1"> <div class="absolute top-2 right-2 z-[501] flex flex-col gap-1">
<button <button
@@ -78,8 +84,6 @@
</template> </template>
</div> </div>
<MapControls <MapControls
:show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
:hide-markers="mapLogic.state.hideMarkers.value" :hide-markers="mapLogic.state.hideMarkers.value"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)" @update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
:selected-map-id="mapLogic.state.selectedMapId.value" :selected-map-id="mapLogic.state.selectedMapId.value"
@@ -95,6 +99,7 @@
:players="players" :players="players"
:current-map-id="mapLogic.state.mapid.value" :current-map-id="mapLogic.state.mapid.value"
:current-coords="mapLogic.state.displayCoords.value" :current-coords="mapLogic.state.displayCoords.value"
:selected-marker-for-bookmark="selectedMarkerForBookmark"
@zoom-in="mapLogic.zoomIn(leafletMap)" @zoom-in="mapLogic.zoomIn(leafletMap)"
@zoom-out="mapLogic.zoomOutControl(leafletMap)" @zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(leafletMap)" @reset-view="mapLogic.resetView(leafletMap)"
@@ -102,9 +107,11 @@
/> />
<MapContextMenu <MapContextMenu
:context-menu="mapLogic.contextMenu" :context-menu="mapLogic.contextMenu"
:is-admin="auths.includes('admin')"
@wipe-tile="onWipeTile" @wipe-tile="onWipeTile"
@rewrite-coords="onRewriteCoords" @rewrite-coords="onRewriteCoords"
@hide-marker="onHideMarker" @hide-marker="onHideMarker"
@add-to-bookmarks="onAddMarkerToBookmarks"
/> />
<MapCoordSetModal <MapCoordSetModal
:coord-set-from="mapLogic.coordSetFrom.value" :coord-set-from="mapLogic.coordSetFrom.value"
@@ -113,6 +120,13 @@
@close="mapLogic.closeCoordSetModal()" @close="mapLogic.closeCoordSetModal()"
@submit="onSubmitCoordSet" @submit="onSubmitCoordSet"
/> />
<MapBookmarkNameModal
:open="bookmarkNameModalOpen"
:default-name="bookmarkNameModalDefaultName"
:title="bookmarkNameModalTitle"
@close="bookmarkNameModalOpen = false"
@submit="onBookmarkNameModalSubmit"
/>
<MapShortcutsOverlay :open="showShortcutsOverlay" @close="showShortcutsOverlay = false" /> <MapShortcutsOverlay :open="showShortcutsOverlay" @close="showShortcutsOverlay = false" />
</div> </div>
</template> </template>
@@ -122,6 +136,7 @@ import MapControls from '~/components/map/MapControls.vue'
import MapCoordsDisplay from '~/components/map/MapCoordsDisplay.vue' import MapCoordsDisplay from '~/components/map/MapCoordsDisplay.vue'
import MapContextMenu from '~/components/map/MapContextMenu.vue' import MapContextMenu from '~/components/map/MapContextMenu.vue'
import MapCoordSetModal from '~/components/map/MapCoordSetModal.vue' import MapCoordSetModal from '~/components/map/MapCoordSetModal.vue'
import MapBookmarkNameModal from '~/components/map/MapBookmarkNameModal.vue'
import MapShortcutsOverlay from '~/components/map/MapShortcutsOverlay.vue' import MapShortcutsOverlay from '~/components/map/MapShortcutsOverlay.vue'
import { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes' import { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit' import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
@@ -146,10 +161,59 @@ const props = withDefaults(
const mapContainerRef = ref<HTMLElement | null>(null) const mapContainerRef = ref<HTMLElement | null>(null)
const mapRef = ref<HTMLElement | null>(null) const mapRef = ref<HTMLElement | null>(null)
const api = useMapApi() const api = useMapApi()
const toast = useToast()
/** Global state for navbar Live/Offline: true when at least one character is owned by current user's tokens. */ /** Global state for navbar Live/Offline: true when at least one character is owned by current user's tokens. */
const mapLive = useState<boolean>('mapLive', () => false) const mapLive = useState<boolean>('mapLive', () => false)
const mapLogic = useMapLogic() const mapLogic = useMapLogic()
const mapBookmarks = useMapBookmarks()
const { resolvePath } = useAppPaths() const { resolvePath } = useAppPaths()
/** Payload for bookmark name modal: add new bookmark or edit existing by id. */
type BookmarkModalPayload =
| { kind: 'add'; mapId: number; x: number; y: number; zoom?: number }
| { kind: 'edit'; editId: string }
const bookmarkNameModalOpen = ref(false)
const bookmarkNameModalDefaultName = ref('')
const bookmarkNameModalTitle = ref('Add bookmark')
const bookmarkNameModalPendingData = ref<BookmarkModalPayload | null>(null)
function openBookmarkModal(defaultName: string, title: string, data: BookmarkModalPayload) {
bookmarkNameModalDefaultName.value = defaultName
bookmarkNameModalTitle.value = title
bookmarkNameModalPendingData.value = data
bookmarkNameModalOpen.value = true
}
function onBookmarkNameModalSubmit(name: string) {
const data = bookmarkNameModalPendingData.value
bookmarkNameModalOpen.value = false
bookmarkNameModalPendingData.value = null
if (!data) return
if (data.kind === 'add') {
mapBookmarks.add({ name, mapId: data.mapId, x: data.x, y: data.y, zoom: data.zoom })
} else {
mapBookmarks.update(data.editId, { name })
}
}
function onAddMarkerToBookmarks() {
mapLogic.closeContextMenus()
const id = mapLogic.contextMenu.marker.data?.id
if (id == null || !layersManager) return
const marker = layersManager.findMarkerById(id)
if (!marker) return
const x = Math.floor(marker.position.x / TileSize)
const y = Math.floor(marker.position.y / TileSize)
openBookmarkModal(marker.name, 'Add bookmark', {
kind: 'add',
mapId: marker.map,
x,
y,
})
}
provide('openBookmarkModal', openBookmarkModal)
const mapNavigate = useMapNavigate() const mapNavigate = useMapNavigate()
const fullscreen = useFullscreen(mapContainerRef) const fullscreen = useFullscreen(mapContainerRef)
const showShortcutsOverlay = ref(false) const showShortcutsOverlay = ref(false)
@@ -185,12 +249,36 @@ const maps = ref<MapInfo[]>([])
const mapsLoaded = ref(false) const mapsLoaded = ref(false)
const questGivers = ref<Array<{ id: number; name: string }>>([]) const questGivers = ref<Array<{ id: number; name: string }>>([])
const players = ref<Array<{ id: number; name: string }>>([]) const players = ref<Array<{ id: number; name: string }>>([])
const auths = ref<string[]>([]) /** Single source of truth: layout updates me, we derive auths for context menu. */
const me = useState<MeResponse | null>('me', () => null)
const auths = computed(() => me.value?.auths ?? [])
let leafletMap: L.Map | null = null let leafletMap: L.Map | null = null
let mapInit: MapInitResult | null = null let mapInit: MapInitResult | null = null
let updatesHandle: UseMapUpdatesReturn | null = null let updatesHandle: UseMapUpdatesReturn | null = null
let layersManager: MapLayersManager | null = null let layersManager: MapLayersManager | null = null
/** Selected marker as bookmark target: grid coords + name, or null. Passed to MapControls → MapBookmarks. */
const selectedMarkerForBookmark = ref<{ mapId: number; x: number; y: number; name: string } | null>(null)
function updateSelectedMarkerForBookmark() {
const id = mapLogic.state.selectedMarkerId.value
if (id == null || !layersManager) {
selectedMarkerForBookmark.value = null
return
}
const marker = layersManager.findMarkerById(id)
if (!marker) {
selectedMarkerForBookmark.value = null
return
}
selectedMarkerForBookmark.value = {
mapId: marker.map,
x: Math.floor(marker.position.x / TileSize),
y: Math.floor(marker.position.y / TileSize),
name: marker.name,
}
}
let intervalId: ReturnType<typeof setInterval> | null = null let intervalId: ReturnType<typeof setInterval> | null = null
let autoMode = false let autoMode = false
let mapContainer: HTMLElement | null = null let mapContainer: HTMLElement | null = null
@@ -202,10 +290,16 @@ function toLatLng(x: number, y: number) {
return leafletMap!.unproject([x, y], HnHMaxZoom) return leafletMap!.unproject([x, y], HnHMaxZoom)
} }
function onWipeTile(coords: { x: number; y: number } | undefined) { async function onWipeTile(coords: { x: number; y: number } | undefined) {
if (!coords) return if (!coords) return
mapLogic.closeContextMenus() mapLogic.closeContextMenus()
api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y }) try {
await api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y })
toast.success('Tile wiped')
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to wipe tile'
toast.error(msg === 'Forbidden' ? 'No permission' : msg)
}
} }
function onRewriteCoords(coords: { x: number; y: number } | undefined) { function onRewriteCoords(coords: { x: number; y: number } | undefined) {
@@ -214,23 +308,39 @@ function onRewriteCoords(coords: { x: number; y: number } | undefined) {
mapLogic.openCoordSet(coords) mapLogic.openCoordSet(coords)
} }
function onHideMarker(id: number | undefined) { async function onHideMarker(id: number | undefined) {
if (id == null) return if (id == null) return
mapLogic.closeContextMenus() mapLogic.closeContextMenus()
api.adminHideMarker({ id }) try {
const m = layersManager?.findMarkerById(id) await api.adminHideMarker({ id })
if (m) m.remove({ map: leafletMap!, markerLayer: mapInit!.markerLayer, mapid: mapLogic.state.mapid.value }) const m = layersManager?.findMarkerById(id)
if (m) m.remove({ map: leafletMap!, markerLayer: mapInit!.markerLayer, mapid: mapLogic.state.mapid.value })
toast.success('Marker hidden')
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to hide marker'
toast.error(msg === 'Forbidden' ? 'No permission' : msg)
}
} }
function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) { async function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
api.adminSetCoords({ try {
map: mapLogic.state.mapid.value, await api.adminSetCoords({
fx: from.x, map: mapLogic.state.mapid.value,
fy: from.y, fx: from.x,
tx: to.x, fy: from.y,
ty: to.y, tx: to.x,
}) ty: to.y,
mapLogic.closeCoordSetModal() })
mapLogic.closeCoordSetModal()
toast.success('Coordinates updated')
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to set coordinates'
toast.error(msg === 'Forbidden' ? 'No permission' : msg)
}
}
function reloadPage() {
if (import.meta.client) window.location.reload()
} }
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
@@ -253,13 +363,18 @@ function onKeydown(e: KeyboardEvent) {
e.preventDefault() e.preventDefault()
return return
} }
if (e.key === 'G') { if (e.key === 'f' || e.key === 'F') {
mapLogic.state.showGridCoordinates.value = !mapLogic.state.showGridCoordinates.value mapNavigate.focusSearch()
e.preventDefault() e.preventDefault()
return return
} }
if (e.key === 'f' || e.key === 'F') { if (e.key === '+' || e.key === '=') {
mapNavigate.focusSearch() mapLogic.zoomIn(leafletMap)
e.preventDefault()
return
}
if (e.key === '-') {
mapLogic.zoomOutControl(leafletMap)
e.preventDefault() e.preventDefault()
return return
} }
@@ -274,12 +389,11 @@ onMounted(async () => {
const L = (await import('leaflet')).default const L = (await import('leaflet')).default
// Load maps, characters, config and me in parallel so map can show sooner. // Load maps, characters and config in parallel. User (me) is loaded by layout.
const [charactersData, mapsData, config, user] = await Promise.all([ const [charactersData, mapsData, config] = await Promise.all([
api.getCharacters().then((d) => (Array.isArray(d) ? d : [])).catch(() => []), api.getCharacters().then((d) => (Array.isArray(d) ? d : [])).catch(() => []),
api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})), api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})),
api.getConfig().catch(() => ({})) as Promise<ConfigResponse>, api.getConfig().catch(() => ({})) as Promise<ConfigResponse>,
api.me().catch(() => null) as Promise<MeResponse | null>,
]) ])
const mapsList: MapInfo[] = [] const mapsList: MapInfo[] = []
@@ -302,7 +416,6 @@ onMounted(async () => {
mapsLoaded.value = true mapsLoaded.value = true
if (config?.title) document.title = config.title if (config?.title) document.title = config.title
auths.value = user?.auths ?? config?.auths ?? []
const initialMapId = const initialMapId =
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0 props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
@@ -316,8 +429,9 @@ onMounted(async () => {
contextMenuHandler = (ev: MouseEvent) => { contextMenuHandler = (ev: MouseEvent) => {
const target = ev.target as Node const target = ev.target as Node
if (!mapContainer?.contains(target)) return if (!mapContainer?.contains(target)) return
// Right-click on a marker: let the marker's context menu open instead of tile menu
if ((target as Element).closest?.('.leaflet-marker-icon')) return
const isAdmin = auths.value.includes('admin') const isAdmin = auths.value.includes('admin')
if (import.meta.dev) console.log('[MapView contextmenu]', { isAdmin, auths: auths.value })
if (isAdmin) { if (isAdmin) {
ev.preventDefault() ev.preventDefault()
ev.stopPropagation() ev.stopPropagation()
@@ -414,6 +528,7 @@ onMounted(async () => {
if (!mounted) return if (!mounted) return
layersManager!.updateMarkers(Array.isArray(body) ? body : []) layersManager!.updateMarkers(Array.isArray(body) ? body : [])
questGivers.value = layersManager!.getQuestGivers() questGivers.value = layersManager!.getQuestGivers()
updateSelectedMarkerForBookmark()
}) })
const CHARACTER_POLL_MS = 4000 const CHARACTER_POLL_MS = 4000
@@ -453,85 +568,73 @@ onMounted(async () => {
document.addEventListener('visibilitychange', visibilityChangeHandler) document.addEventListener('visibilitychange', visibilityChangeHandler)
} }
watch(mapLogic.state.showGridCoordinates, (v) => {
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()
}
})
watch( watch(
() => fullscreen.isFullscreen.value, () => fullscreen.isFullscreen.value,
() => { () => {
if (!mounted) return
nextTick(() => { nextTick(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (leafletMap) leafletMap.invalidateSize() if (mounted && leafletMap) leafletMap.invalidateSize()
}) })
}) })
} }
) )
watch(mapLogic.state.hideMarkers, (v) => { watch(mapLogic.state.hideMarkers, (v) => {
layersManager?.refreshMarkersVisibility(v) if (!mounted || !layersManager) return
layersManager.refreshMarkersVisibility(v)
}) })
watch(mapLogic.state.trackingCharacterId, (value) => { watch(mapLogic.state.trackingCharacterId, (value) => {
if (!mounted || !leafletMap || !layersManager) return
if (value === -1) return if (value === -1) return
const character = layersManager?.findCharacterById(value) const character = layersManager.findCharacterById(value)
if (character) { if (character) {
layersManager!.changeMap(character.map) layersManager.changeMap(character.map)
const latlng = leafletMap!.unproject([character.position.x, character.position.y], HnHMaxZoom) const latlng = leafletMap.unproject([character.position.x, character.position.y], HnHMaxZoom)
leafletMap!.setView(latlng, HnHMaxZoom) leafletMap.setView(latlng, HnHMaxZoom)
autoMode = true autoMode = true
} else { } else {
leafletMap!.setView([0, 0], HnHMinZoom) leafletMap.setView([0, 0], HnHMinZoom)
mapLogic.state.trackingCharacterId.value = -1 mapLogic.state.trackingCharacterId.value = -1
} }
}) })
watch(mapLogic.state.selectedMapId, (value) => { watch(mapLogic.state.selectedMapId, (value) => {
if (!mounted || !leafletMap || !layersManager) return
if (value == null) return if (value == null) return
layersManager?.changeMap(value) layersManager.changeMap(value)
const zoom = leafletMap!.getZoom() const zoom = leafletMap.getZoom()
leafletMap!.setView([0, 0], zoom) leafletMap.setView([0, 0], zoom)
}) })
watch(mapLogic.state.overlayMapId, (value) => { watch(mapLogic.state.overlayMapId, (value) => {
layersManager?.refreshOverlayMarkers(value ?? -1) if (!mounted || !layersManager) return
layersManager.refreshOverlayMarkers(value ?? -1)
}) })
watch(mapLogic.state.selectedMarkerId, (value) => { watch(mapLogic.state.selectedMarkerId, (value) => {
if (!mounted) return
updateSelectedMarkerForBookmark()
if (!leafletMap || !layersManager) return
if (value == null) return if (value == null) return
const marker = layersManager?.findMarkerById(value) const marker = layersManager.findMarkerById(value)
if (marker?.leafletMarker) leafletMap!.setView(marker.leafletMarker.getLatLng(), leafletMap!.getZoom()) if (marker?.leafletMarker) leafletMap.setView(marker.leafletMarker.getLatLng(), leafletMap.getZoom())
}) })
watch(mapLogic.state.selectedPlayerId, (value) => { watch(mapLogic.state.selectedPlayerId, (value) => {
if (!mounted) return
if (value != null) mapLogic.state.trackingCharacterId.value = value if (value != null) mapLogic.state.trackingCharacterId.value = value
}) })
mapNavigate.setGoTo((mapId: number, x: number, y: number, zoom?: number) => { mapNavigate.setGoTo((mapId: number, x: number, y: number, zoom?: number) => {
if (!mounted || !leafletMap) return
if (mapLogic.state.mapid.value !== mapId && layersManager) { if (mapLogic.state.mapid.value !== mapId && layersManager) {
layersManager.changeMap(mapId) layersManager.changeMap(mapId)
mapLogic.state.selectedMapId.value = mapId mapLogic.state.selectedMapId.value = mapId
} }
const latLng = toLatLng(x * TileSize, y * TileSize) const latLng = toLatLng(x * TileSize, y * TileSize)
leafletMap!.setView(latLng, zoom ?? leafletMap!.getZoom(), { animate: true }) leafletMap.setView(latLng, zoom ?? leafletMap.getZoom(), { animate: true })
}) })
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap)) leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
@@ -567,6 +670,7 @@ onMounted(async () => {
}) })
watch([measurePointA, measurePointB], () => { watch([measurePointA, measurePointB], () => {
if (!mounted || !leafletMap) return
const layer = measureLayer.value const layer = measureLayer.value
if (!layer) return if (!layer) return
layer.clearLayers() layer.clearLayers()
@@ -611,23 +715,4 @@ onBeforeUnmount(() => {
mix-blend-mode: normal; mix-blend-mode: normal;
visibility: visible !important; visibility: visible !important;
} }
:deep(.map-tile) {
width: 100px;
height: 100px;
min-width: 100px;
min-height: 100px;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
border-right: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
font-size: 11px;
line-height: 1.2;
pointer-events: none;
text-shadow: 0 0 2px #000, 0 1px 2px #000;
}
:deep(.map-tile-text) {
position: absolute;
left: 2px;
top: 2px;
}
</style> </style>

View File

@@ -0,0 +1,74 @@
<template>
<dialog ref="modalRef" class="modal" @cancel="$emit('close')">
<div class="modal-box transition-all duration-200" @click.stop>
<h3 class="font-bold text-lg">{{ title }}</h3>
<div class="py-2">
<label class="label py-0"><span>Name</span></label>
<input
ref="inputRef"
v-model="localName"
type="text"
class="input input-bordered w-full"
placeholder="Bookmark name"
@keydown.enter.prevent="onSubmit"
/>
</div>
<div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" @click="$emit('close')">Cancel</button>
</form>
</div>
</div>
<div class="modal-backdrop cursor-pointer" aria-label="Close" @click="$emit('close')" />
</dialog>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
open: boolean
defaultName: string
title?: string
}>(),
{ title: 'Add bookmark' }
)
const emit = defineEmits<{
close: []
submit: [name: string]
}>()
const modalRef = ref<HTMLDialogElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const localName = ref(props.defaultName)
watch(
() => props.open,
(open) => {
if (open) {
localName.value = props.defaultName || ''
nextTick(() => {
modalRef.value?.showModal()
inputRef.value?.focus()
})
} else {
modalRef.value?.close()
}
},
{ immediate: true }
)
watch(
() => props.defaultName,
(name) => {
if (props.open) localName.value = name || ''
}
)
function onSubmit() {
const name = localName.value.trim() || props.defaultName || 'Bookmark'
emit('submit', name)
emit('close')
}
</script>

View File

@@ -22,6 +22,15 @@
> >
<span class="truncate">{{ b.name }}</span> <span class="truncate">{{ b.name }}</span>
</button> </button>
<button
v-if="openBookmarkModal"
type="button"
class="btn btn-ghost btn-xs btn-square shrink-0 opacity-70 hover:opacity-100"
aria-label="Edit bookmark name"
@click="openBookmarkModal(b.name, 'Edit bookmark name', { kind: 'edit', editId: b.id })"
>
<icons-icon-pencil class="size-3.5" />
</button>
<button <button
type="button" type="button"
class="btn btn-ghost btn-xs btn-square shrink-0 opacity-70 hover:opacity-100 hover:text-error" class="btn btn-ghost btn-xs btn-square shrink-0 opacity-70 hover:opacity-100 hover:text-error"
@@ -33,17 +42,30 @@
</div> </div>
</template> </template>
</div> </div>
<button <div v-if="openBookmarkModal" class="flex flex-col gap-1">
type="button" <button
class="btn btn-outline btn-sm w-full" type="button"
:class="touchFriendly ? 'min-h-11' : ''" class="btn btn-primary btn-sm w-full"
:disabled="!canAddCurrent" :class="touchFriendly ? 'min-h-11' : ''"
title="Save current map position" :disabled="!selectedMarkerForBookmark"
@click="onAddCurrent" title="Add selected quest giver as bookmark"
> @click="onAddSelectedMarker"
<icons-icon-plus class="size-4" /> >
Add current location <icons-icon-plus class="size-4" />
</button> Add selected marker
</button>
<button
type="button"
class="btn btn-outline btn-sm w-full"
:class="touchFriendly ? 'min-h-11' : ''"
:disabled="!canAddCurrent"
title="Save current map position"
@click="onAddCurrent"
>
<icons-icon-plus class="size-4" />
Add current location
</button>
</div>
</section> </section>
</template> </template>
@@ -52,18 +74,27 @@ import type { MapInfo } from '~/types/api'
import { useMapBookmarks } from '~/composables/useMapBookmarks' import { useMapBookmarks } from '~/composables/useMapBookmarks'
import { useMapNavigate } from '~/composables/useMapNavigate' import { useMapNavigate } from '~/composables/useMapNavigate'
export type SelectedMarkerForBookmark = { mapId: number; x: number; y: number; name: string } | null
type BookmarkModalPayload =
| { kind: 'add'; mapId: number; x: number; y: number; zoom?: number }
| { kind: 'edit'; editId: string }
type OpenBookmarkModalFn = (defaultName: string, title: string, data: BookmarkModalPayload) => void
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
maps: MapInfo[] maps: MapInfo[]
currentMapId: number | null currentMapId: number | null
currentCoords: { x: number; y: number; z: number } | null currentCoords: { x: number; y: number; z: number } | null
selectedMarkerForBookmark?: SelectedMarkerForBookmark
touchFriendly?: boolean touchFriendly?: boolean
}>(), }>(),
{ touchFriendly: false } { selectedMarkerForBookmark: null, touchFriendly: false }
) )
const { bookmarks, add, remove } = useMapBookmarks() const { bookmarks, remove } = useMapBookmarks()
const { goToCoords } = useMapNavigate() const { goToCoords } = useMapNavigate()
const openBookmarkModal = inject<OpenBookmarkModalFn>('openBookmarkModal')
const canAddCurrent = computed( const canAddCurrent = computed(
() => () =>
@@ -80,17 +111,20 @@ function onRemove(id: string) {
remove(id) remove(id)
} }
function onAddSelectedMarker() {
const m = props.selectedMarkerForBookmark
if (!m || !openBookmarkModal) return
openBookmarkModal(m.name, 'Add bookmark', { kind: 'add', mapId: m.mapId, x: m.x, y: m.y })
}
function onAddCurrent() { function onAddCurrent() {
if (!canAddCurrent.value) return if (!canAddCurrent.value || !openBookmarkModal) return
const mapId = props.currentMapId! const mapId = props.currentMapId!
const { x, y, z } = props.currentCoords! const { x, y, z } = props.currentCoords!
const mapName = props.maps.find((m) => m.ID === mapId)?.Name ?? `Map ${mapId}` openBookmarkModal(
add({ `${props.maps.find((map) => map.ID === mapId)?.Name ?? `Map ${mapId}`} ${x}, ${y}`,
name: `${mapName} ${x}, ${y}`, 'Add bookmark',
mapId, { kind: 'add', mapId, x, y, zoom: z }
x, )
y,
zoom: z,
})
} }
</script> </script>

View File

@@ -28,6 +28,14 @@
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }" :style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
> >
<button <button
type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onAddToBookmarks"
>
Add to bookmarks
</button>
<button
v-if="isAdmin"
type="button" type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onHideMarker(contextMenu.marker.data?.id)" @click="onHideMarker(contextMenu.marker.data?.id)"
@@ -41,14 +49,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ContextMenuState } from '~/composables/useMapLogic' import type { ContextMenuState } from '~/composables/useMapLogic'
defineProps<{ withDefaults(
contextMenu: ContextMenuState defineProps<{
}>() contextMenu: ContextMenuState
isAdmin?: boolean
}>(),
{ isAdmin: false }
)
const emit = defineEmits<{ const emit = defineEmits<{
wipeTile: [coords: { x: number; y: number } | undefined] wipeTile: [coords: { x: number; y: number } | undefined]
rewriteCoords: [coords: { x: number; y: number } | undefined] rewriteCoords: [coords: { x: number; y: number } | undefined]
hideMarker: [id: number | undefined] hideMarker: [id: number | undefined]
addToBookmarks: []
}>() }>()
function onWipeTile(coords: { x: number; y: number } | undefined) { function onWipeTile(coords: { x: number; y: number } | undefined) {
@@ -59,6 +72,10 @@ function onRewriteCoords(coords: { x: number; y: number } | undefined) {
if (coords) emit('rewriteCoords', coords) if (coords) emit('rewriteCoords', coords)
} }
function onAddToBookmarks() {
emit('addToBookmarks')
}
function onHideMarker(id: number | undefined) { function onHideMarker(id: number | undefined) {
if (id != null) emit('hideMarker', id) if (id != null) emit('hideMarker', id)
} }

View File

@@ -37,7 +37,6 @@
class="flex flex-col p-4 gap-4 flex-1 min-w-0 overflow-hidden" class="flex flex-col p-4 gap-4 flex-1 min-w-0 overflow-hidden"
> >
<MapControlsContent <MapControlsContent
v-model:show-grid-coordinates="showGridCoordinates"
v-model:hide-markers="hideMarkers" v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect" :selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)" @update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
@@ -52,6 +51,7 @@
:players="players" :players="players"
:current-map-id="currentMapId ?? undefined" :current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords" :current-coords="currentCoords"
:selected-marker-for-bookmark="selectedMarkerForBookmark"
@zoom-in="$emit('zoomIn')" @zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')" @zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')" @reset-view="$emit('resetView')"
@@ -128,7 +128,6 @@
</div> </div>
<div class="overflow-y-auto overscroll-contain flex-1 p-4 pb-8"> <div class="overflow-y-auto overscroll-contain flex-1 p-4 pb-8">
<MapControlsContent <MapControlsContent
v-model:show-grid-coordinates="showGridCoordinates"
v-model:hide-markers="hideMarkers" v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect" :selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)" @update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
@@ -143,6 +142,7 @@
:players="players" :players="players"
:current-map-id="currentMapId ?? undefined" :current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords" :current-coords="currentCoords"
:selected-marker-for-bookmark="selectedMarkerForBookmark"
:touch-friendly="true" :touch-friendly="true"
@zoom-in="$emit('zoomIn')" @zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')" @zoom-out="$emit('zoomOut')"
@@ -167,6 +167,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo } from '~/types/api'
import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue'
import MapControlsContent from '~/components/map/MapControlsContent.vue' import MapControlsContent from '~/components/map/MapControlsContent.vue'
interface QuestGiver { interface QuestGiver {
@@ -186,8 +187,16 @@ const props = withDefaults(
players: Player[] players: Player[]
currentMapId?: number | null currentMapId?: number | null
currentCoords?: { x: number; y: number; z: number } | null currentCoords?: { x: number; y: number; z: number } | null
selectedMarkerForBookmark?: SelectedMarkerForBookmark
}>(), }>(),
{ maps: () => [], questGivers: () => [], players: () => [], currentMapId: null, currentCoords: null } {
maps: () => [],
questGivers: () => [],
players: () => [],
currentMapId: null,
currentCoords: null,
selectedMarkerForBookmark: null,
}
) )
defineEmits<{ defineEmits<{
@@ -197,7 +206,6 @@ defineEmits<{
jumpToMarker: [id: number] jumpToMarker: [id: number]
}>() }>()
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })
const hideMarkers = defineModel<boolean>('hideMarkers', { default: false }) const hideMarkers = defineModel<boolean>('hideMarkers', { default: false })
const panelCollapsed = ref(false) const panelCollapsed = ref(false)
const sheetOpen = ref(false) const sheetOpen = ref(false)
@@ -233,6 +241,8 @@ const selectedPlayerIdSelect = computed({
selectedPlayerId.value = v === '' ? null : Number(v) selectedPlayerId.value = v === '' ? null : Number(v)
}, },
}) })
const selectedMarkerForBookmark = toRef(props, 'selectedMarkerForBookmark')
</script> </script>
<style scoped> <style scoped>

View File

@@ -55,15 +55,6 @@
<icons-icon-eye class="size-3.5 opacity-80" aria-hidden="true" /> <icons-icon-eye class="size-3.5 opacity-80" aria-hidden="true" />
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' : ''">
<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' : ''"> <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="hideMarkers" type="checkbox" class="checkbox checkbox-sm" /> <input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span>Hide markers</span> <span>Hide markers</span>
@@ -125,6 +116,7 @@
:maps="maps" :maps="maps"
:current-map-id="currentMapId ?? null" :current-map-id="currentMapId ?? null"
:current-coords="currentCoords ?? null" :current-coords="currentCoords ?? null"
:selected-marker-for-bookmark="selectedMarkerForBookmark ?? null"
:touch-friendly="touchFriendly" :touch-friendly="touchFriendly"
/> />
</div> </div>
@@ -132,6 +124,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo } from '~/types/api'
import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue'
import MapBookmarks from '~/components/map/MapBookmarks.vue' import MapBookmarks from '~/components/map/MapBookmarks.vue'
interface QuestGiver { interface QuestGiver {
@@ -156,8 +149,9 @@ const props = withDefaults(
selectedPlayerIdSelect: string selectedPlayerIdSelect: string
currentMapId?: number currentMapId?: number
currentCoords?: { x: number; y: number; z: number } | null currentCoords?: { x: number; y: number; z: number } | null
selectedMarkerForBookmark?: SelectedMarkerForBookmark
}>(), }>(),
{ touchFriendly: false, currentMapId: 0, currentCoords: null } { touchFriendly: false, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null }
) )
const emit = defineEmits<{ const emit = defineEmits<{
@@ -165,7 +159,6 @@ const emit = defineEmits<{
zoomOut: [] zoomOut: []
resetView: [] resetView: []
jumpToMarker: [id: number] jumpToMarker: [id: number]
'update:showGridCoordinates': [v: boolean]
'update:hideMarkers': [v: boolean] 'update:hideMarkers': [v: boolean]
'update:selectedMapIdSelect': [v: string] 'update:selectedMapIdSelect': [v: string]
'update:overlayMapId': [v: number] 'update:overlayMapId': [v: number]
@@ -173,7 +166,6 @@ const emit = defineEmits<{
'update:selectedPlayerIdSelect': [v: string] 'update:selectedPlayerIdSelect': [v: string]
}>() }>()
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { required: true })
const hideMarkers = defineModel<boolean>('hideMarkers', { required: true }) const hideMarkers = defineModel<boolean>('hideMarkers', { required: true })
const selectedMapIdSelect = computed({ const selectedMapIdSelect = computed({

View File

@@ -40,10 +40,6 @@
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">H</kbd></dt> <dt class="text-base-content/80"><kbd class="kbd kbd-sm">H</kbd></dt>
<dd>Toggle markers visibility</dd> <dd>Toggle markers visibility</dd>
</div> </div>
<div class="flex justify-between gap-4 py-1 border-b border-base-300/50">
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">G</kbd></dt>
<dd>Toggle grid coordinates</dd>
</div>
<div class="flex justify-between gap-4 py-1 border-b border-base-300/50"> <div class="flex justify-between gap-4 py-1 border-b border-base-300/50">
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">F</kbd></dt> <dt class="text-base-content/80"><kbd class="kbd kbd-sm">F</kbd></dt>
<dd>Focus search</dd> <dd>Focus search</dd>

View File

@@ -13,7 +13,6 @@ describe('useMapLogic', () => {
it('initializes with default state', () => { it('initializes with default state', () => {
const { state } = useMapLogic() const { state } = useMapLogic()
expect(state.showGridCoordinates.value).toBe(false)
expect(state.hideMarkers.value).toBe(false) expect(state.hideMarkers.value).toBe(false)
expect(state.panelCollapsed.value).toBe(false) expect(state.panelCollapsed.value).toBe(false)
expect(state.trackingCharacterId.value).toBe(-1) expect(state.trackingCharacterId.value).toBe(-1)

View File

@@ -53,6 +53,16 @@ export function useMapBookmarks() {
return id return id
} }
function update(id: string, patch: { name: string }) {
const list = loadBookmarks()
const idx = list.findIndex((b) => b.id === id)
if (idx < 0) return false
list[idx] = { ...list[idx], name: patch.name }
saveBookmarks(list)
refresh()
return true
}
function remove(id: string) { function remove(id: string) {
const list = loadBookmarks().filter((b) => b.id !== id) const list = loadBookmarks().filter((b) => b.id !== id)
saveBookmarks(list) saveBookmarks(list)
@@ -70,6 +80,7 @@ export function useMapBookmarks() {
bookmarks: readonly(bookmarks), bookmarks: readonly(bookmarks),
refresh, refresh,
add, add,
update,
remove, remove,
clear, clear,
} }

View File

@@ -1,6 +1,5 @@
import type L from 'leaflet' import type L from 'leaflet'
import { GridCoordLayer, HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes' import { HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import type { GridCoordLayerOptions } from '~/lib/LeafletCustomTypes'
import { SmartTileLayer } from '~/lib/SmartTileLayer' import { SmartTileLayer } from '~/lib/SmartTileLayer'
import type { MapInfo } from '~/types/api' import type { MapInfo } from '~/types/api'
@@ -35,7 +34,6 @@ export interface MapInitResult {
map: L.Map map: L.Map
layer: SmartTileLayerInstance layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance overlayLayer: SmartTileLayerInstance
coordLayer: L.GridLayer
markerLayer: L.LayerGroup markerLayer: L.LayerGroup
backendBase: string backendBase: string
} }
@@ -95,19 +93,6 @@ 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({
tileSize: TileSize,
minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom,
opacity: 0,
visible: false,
pane: 'tilePane',
updateWhenIdle: true,
keepBuffer: 4,
} as GridCoordLayerOptions)
const markerLayer = L.layerGroup() const markerLayer = L.layerGroup()
markerLayer.addTo(map) markerLayer.addTo(map)
markerLayer.setZIndex(600) markerLayer.setZIndex(600)
@@ -122,5 +107,5 @@ export async function initLeafletMap(
} }
preloadMarkerIcons(resolvePath) preloadMarkerIcons(resolvePath)
return { map, layer, overlayLayer, coordLayer, markerLayer, backendBase } return { map, layer, overlayLayer, markerLayer, backendBase }
} }

View File

@@ -115,11 +115,9 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
if (marker.leafletMarker) map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom) if (marker.leafletMarker) map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom)
}) })
marker.setContextMenu((mev: L.LeafletMouseEvent) => { marker.setContextMenu((mev: L.LeafletMouseEvent) => {
if (getAuths().includes('admin')) { mev.originalEvent.preventDefault()
mev.originalEvent.preventDefault() mev.originalEvent.stopPropagation()
mev.originalEvent.stopPropagation() onMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name)
onMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name)
}
}) })
}, },
(marker: MapMarker) => marker.remove(ctx), (marker: MapMarker) => marker.remove(ctx),

View File

@@ -2,7 +2,6 @@ import type L from 'leaflet'
import { HnHMinZoom, HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes' import { HnHMinZoom, HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
export interface MapLogicState { export interface MapLogicState {
showGridCoordinates: Ref<boolean>
hideMarkers: Ref<boolean> hideMarkers: Ref<boolean>
panelCollapsed: Ref<boolean> panelCollapsed: Ref<boolean>
trackingCharacterId: Ref<number> trackingCharacterId: Ref<number>
@@ -48,7 +47,6 @@ export interface CoordSetState {
* Map instance is passed to functions that need it (set by MapView after init). * Map instance is passed to functions that need it (set by MapView after init).
*/ */
export function useMapLogic() { export function useMapLogic() {
const showGridCoordinates = ref(false)
const hideMarkers = ref(false) const hideMarkers = ref(false)
const panelCollapsed = ref(false) const panelCollapsed = ref(false)
const trackingCharacterId = ref(-1) const trackingCharacterId = ref(-1)
@@ -127,7 +125,6 @@ export function useMapLogic() {
} }
const state: MapLogicState = { const state: MapLogicState = {
showGridCoordinates,
hideMarkers, hideMarkers,
panelCollapsed, panelCollapsed,
trackingCharacterId, trackingCharacterId,

View File

@@ -36,26 +36,22 @@ export interface UseMapUpdatesReturn {
cleanup: () => void cleanup: () => void
} }
const RECONNECT_INITIAL_MS = 1000
const RECONNECT_MAX_MS = 30000
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn { export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options
const updatesPath = `${backendBase}/updates` const updatesPath = `${backendBase}/updates`
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
const source = new EventSource(updatesUrl)
if (connectionStateRef) {
connectionStateRef.value = 'connecting'
}
source.onopen = () => {
if (connectionStateRef) connectionStateRef.value = 'open'
}
source.onerror = () => {
if (connectionStateRef) connectionStateRef.value = 'error'
}
const BATCH_MS = 50 const BATCH_MS = 50
let batch: TileUpdate[] = [] let batch: TileUpdate[] = []
let batchScheduled = false let batchScheduled = false
let source: EventSource | null = null
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null
let reconnectDelayMs = RECONNECT_INITIAL_MS
let destroyed = false
const VISIBLE_TILE_BUFFER = 1 const VISIBLE_TILE_BUFFER = 1
@@ -101,42 +97,74 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
setTimeout(applyBatch, BATCH_MS) setTimeout(applyBatch, BATCH_MS)
} }
source.onmessage = (event: MessageEvent) => { function connect() {
if (connectionStateRef) connectionStateRef.value = 'open' if (destroyed || !import.meta.client) return
try { source = new EventSource(updatesUrl)
const raw: unknown = event?.data if (connectionStateRef) connectionStateRef.value = 'connecting'
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
const updates: unknown = JSON.parse(raw) source.onopen = () => {
if (!Array.isArray(updates)) return if (connectionStateRef) connectionStateRef.value = 'open'
for (const u of updates as TileUpdate[]) { reconnectDelayMs = RECONNECT_INITIAL_MS
batch.push(u)
}
scheduleBatch()
} catch {
// Ignore parse errors from SSE
} }
source.onerror = () => {
if (destroyed || !source) return
if (connectionStateRef) connectionStateRef.value = 'error'
source.close()
source = null
if (destroyed) return
reconnectTimeoutId = setTimeout(() => {
reconnectTimeoutId = null
connect()
reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS)
}, reconnectDelayMs)
}
source.onmessage = (event: MessageEvent) => {
if (connectionStateRef) connectionStateRef.value = 'open'
try {
const raw: unknown = event?.data
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
const updates: unknown = JSON.parse(raw)
if (!Array.isArray(updates)) return
for (const u of updates as TileUpdate[]) {
batch.push(u)
}
scheduleBatch()
} catch {
// Ignore parse errors from SSE
}
}
source.addEventListener('merge', (e: MessageEvent) => {
try {
const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}')
if (getCurrentMapId() === merge.From) {
const point = map.project(map.getCenter(), 6)
const shift = {
x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y,
}
onMerge(merge.To, shift)
}
} catch {
// Ignore merge parse errors
}
})
} }
source.onerror = () => {} connect()
source.addEventListener('merge', (e: MessageEvent) => {
try {
const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}')
if (getCurrentMapId() === merge.From) {
const point = map.project(map.getCenter(), 6)
const shift = {
x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y,
}
onMerge(merge.To, shift)
}
} catch {
// Ignore merge parse errors
}
})
function cleanup() { function cleanup() {
source.close() destroyed = true
if (reconnectTimeoutId != null) {
clearTimeout(reconnectTimeoutId)
reconnectTimeoutId = null
}
if (source) {
source.close()
source = null
}
} }
return { cleanup } return { cleanup }

View File

@@ -5,51 +5,6 @@ export const HnHMaxZoom = 6
export const HnHMinZoom = 1 export const HnHMinZoom = 1
export const HnHDefaultZoom = 6 export const HnHDefaultZoom = 6
export interface GridCoordLayerOptions extends L.GridLayerOptions {
visible?: boolean
}
/**
* Grid layer that draws one coordinate label per Leaflet tile in the top-left corner.
* 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: {
visible: true,
},
createTile(coords: { x: number; y: number; z: number }) {
if (!this.options.visible) {
const element = document.createElement('div')
element.style.width = TileSize + 'px'
element.style.height = TileSize + 'px'
element.classList.add('map-tile')
return element
}
const element = document.createElement('div')
element.style.width = TileSize + 'px'
element.style.height = TileSize + 'px'
element.style.position = 'relative'
element.classList.add('map-tile')
const scaleFactor = Math.pow(2, HnHMaxZoom - coords.z)
const topLeft = { x: coords.x * scaleFactor, y: coords.y * scaleFactor }
// One label per Leaflet tile at top-left (2px, 2px); same (x,y) as backend tile for this coords.
const textElement = document.createElement('div')
textElement.classList.add('map-tile-text')
textElement.textContent = `(${topLeft.x}, ${topLeft.y})`
textElement.style.position = 'absolute'
textElement.style.left = '2px'
textElement.style.top = '2px'
textElement.style.fontSize = Math.max(8, 12 - Math.log2(scaleFactor) * 2) + 'px'
element.appendChild(textElement)
return element
},
}) as unknown as new (options?: GridCoordLayerOptions) => L.GridLayer
export interface ImageIconOptions extends L.IconOptions { export interface ImageIconOptions extends L.IconOptions {
/** When the main icon image fails to load, use this URL (e.g. data URL or default marker). */ /** When the main icon image fails to load, use this URL (e.g. data URL or default marker). */
fallbackIconUrl?: string fallbackIconUrl?: string

View File

@@ -68,7 +68,7 @@ async function submit() {
loading.value = true loading.value = true
try { try {
await api.login(user.value, pass.value) await api.login(user.value, pass.value)
await router.push(redirect.value || '/profile') await router.push(redirect.value || '/')
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Login failed' error.value = e instanceof Error ? e.message : 'Login failed'
} finally { } finally {