Enhance MapView component with new features and icons

- Added fullscreen and measurement mode buttons for improved map interaction.
- Introduced a bookmarks section to save and navigate to locations easily.
- Implemented a search feature for quick access to coordinates and markers.
- Added keyboard shortcuts overlay for enhanced usability.
- Refactored MapControls and MapControlsContent to support new functionalities.
- Introduced new icon components for better visual representation in the UI.
This commit is contained in:
2026-03-01 16:00:25 +03:00
parent 945b803dba
commit 7f990c0c11
17 changed files with 825 additions and 4 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative h-full w-full" @click="(e: MouseEvent) => e.button === 0 && mapLogic.closeContextMenus()">
<div ref="mapContainerRef" class="relative h-full w-full" @click="(e: MouseEvent) => e.button === 0 && mapLogic.closeContextMenus()">
<div
v-if="!mapReady"
class="absolute inset-0 z-[400] flex flex-col items-center justify-center gap-6 bg-base-200/95 p-8"
@@ -32,6 +32,40 @@
:mapid="mapLogic.state.mapid.value"
:display-coords="mapLogic.state.displayCoords.value"
/>
<div class="absolute top-2 right-2 z-[501] flex flex-col gap-1">
<button
type="button"
class="btn btn-ghost btn-sm btn-square rounded-lg bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow hover:border-primary/50"
:title="fullscreen.isFullscreen.value ? 'Exit fullscreen' : 'Fullscreen'"
:aria-label="fullscreen.isFullscreen.value ? 'Exit fullscreen' : 'Fullscreen'"
@click="fullscreen.toggle()"
>
<icons-icon-fullscreen-exit v-if="fullscreen.isFullscreen.value" class="size-4" />
<icons-icon-fullscreen v-else class="size-4" />
</button>
<button
type="button"
class="btn btn-ghost btn-sm btn-square rounded-lg bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow hover:border-primary/50"
:class="{ 'border-primary bg-primary/10': measureMode }"
:title="measureMode ? 'Exit measure mode' : 'Measure distance'"
aria-label="Measure distance"
@click="measureMode = !measureMode; if (!measureMode) clearMeasure()"
>
<icons-icon-ruler class="size-4" />
</button>
</div>
<div
v-if="measureMode && mapReady"
class="absolute top-24 right-2 z-[500] rounded-lg px-3 py-2 text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow"
>
<template v-if="measurePointB == null">
<span class="text-base-content/80">{{ measurePointA == null ? 'Click first point on map' : 'Click second point' }}</span>
</template>
<template v-else>
<span class="font-mono">Distance: {{ measureDistance.toFixed(2) }} units</span>
<button type="button" class="btn btn-ghost btn-xs ml-2" @click="clearMeasure">Clear</button>
</template>
</div>
<MapControls
:show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
@@ -48,9 +82,12 @@
:maps="maps"
:quest-givers="questGivers"
:players="players"
:current-map-id="mapLogic.state.mapid.value"
:current-coords="mapLogic.state.displayCoords.value"
@zoom-in="mapLogic.zoomIn(leafletMap)"
@zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(leafletMap)"
@jump-to-marker="mapLogic.state.selectedMarkerId.value = $event"
/>
<MapContextMenu
:context-menu="mapLogic.contextMenu"
@@ -65,6 +102,7 @@
@close="mapLogic.closeCoordSetModal()"
@submit="onSubmitCoordSet"
/>
<MapShortcutsOverlay :open="showShortcutsOverlay" @close="showShortcutsOverlay = false" />
</div>
</template>
@@ -73,8 +111,11 @@ import MapControls from '~/components/map/MapControls.vue'
import MapCoordsDisplay from '~/components/map/MapCoordsDisplay.vue'
import MapContextMenu from '~/components/map/MapContextMenu.vue'
import MapCoordSetModal from '~/components/map/MapCoordSetModal.vue'
import MapShortcutsOverlay from '~/components/map/MapShortcutsOverlay.vue'
import { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
import { useMapNavigate } from '~/composables/useMapNavigate'
import { useFullscreen } from '~/composables/useFullscreen'
import { startMapUpdates, type UseMapUpdatesReturn } from '~/composables/useMapUpdates'
import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
import type { MapInfo, ConfigResponse, MeResponse } from '~/types/api'
@@ -91,10 +132,32 @@ const props = withDefaults(
{ characterId: -1, mapId: undefined, gridX: 0, gridY: 0, zoom: 1 }
)
const mapContainerRef = ref<HTMLElement | null>(null)
const mapRef = ref<HTMLElement | null>(null)
const api = useMapApi()
const mapLogic = useMapLogic()
const { resolvePath } = useAppPaths()
const mapNavigate = useMapNavigate()
const fullscreen = useFullscreen(mapContainerRef)
const showShortcutsOverlay = ref(false)
const measureMode = ref(false)
const measurePointA = ref<{ x: number; y: number } | null>(null)
const measurePointB = ref<{ x: number; y: number } | null>(null)
const measureLayer = ref<L.LayerGroup | null>(null)
const measureDistance = computed(() => {
const a = measurePointA.value
const b = measurePointB.value
if (!a || !b) return 0
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2)
})
function clearMeasure() {
measurePointA.value = null
measurePointB.value = null
if (measureLayer.value && leafletMap) {
measureLayer.value.clearLayers()
}
}
/** Fallback marker icon (simple pin) when the real icon image fails to load. */
const FALLBACK_MARKER_ICON =
@@ -155,7 +218,35 @@ function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: nu
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') mapLogic.closeContextMenus()
const target = e.target as HTMLElement
const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')
if (e.key === 'Escape') {
if (showShortcutsOverlay.value) showShortcutsOverlay.value = false
else mapLogic.closeContextMenus()
return
}
if (showShortcutsOverlay.value) return
if (e.key === '?') {
showShortcutsOverlay.value = true
e.preventDefault()
return
}
if (inInput) return
if (e.key === 'H') {
mapLogic.state.hideMarkers.value = !mapLogic.state.hideMarkers.value
e.preventDefault()
return
}
if (e.key === 'G') {
mapLogic.state.showGridCoordinates.value = !mapLogic.state.showGridCoordinates.value
e.preventDefault()
return
}
if (e.key === 'f' || e.key === 'F') {
mapNavigate.focusSearch()
e.preventDefault()
return
}
}
onMounted(async () => {
@@ -346,6 +437,15 @@ onMounted(async () => {
if (value != null) mapLogic.state.trackingCharacterId.value = value
})
mapNavigate.setGoTo((mapId: number, x: number, y: number, zoom?: number) => {
if (mapLogic.state.mapid.value !== mapId && layersManager) {
layersManager.changeMap(mapId)
mapLogic.state.selectedMapId.value = mapId
}
const latLng = toLatLng(x * TileSize, y * TileSize)
leafletMap!.setView(latLng, zoom ?? leafletMap!.getZoom(), { animate: true })
})
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
mapLogic.updateDisplayCoords(leafletMap)
leafletMap.on('zoomend', () => {
@@ -361,12 +461,49 @@ onMounted(async () => {
mapLogic.state.trackingCharacterId.value = -1
}
})
const measureLayerGroup = L.layerGroup().addTo(leafletMap)
measureLayerGroup.setZIndex(550)
measureLayer.value = measureLayerGroup
leafletMap.on('click', (e: L.LeafletMouseEvent) => {
if (!measureMode.value) return
e.originalEvent.stopPropagation()
const point = leafletMap!.project(e.latlng, HnHMaxZoom)
const x = Math.floor(point.x / TileSize)
const y = Math.floor(point.y / TileSize)
if (measurePointA.value == null) {
measurePointA.value = { x, y }
} else if (measurePointB.value == null) {
measurePointB.value = { x, y }
} else {
measurePointA.value = { x, y }
measurePointB.value = null
}
})
watch([measurePointA, measurePointB], () => {
const layer = measureLayer.value
if (!layer) return
layer.clearLayers()
const a = measurePointA.value
const b = measurePointB.value
if (!a) return
const latLngA = toLatLng(a.x * TileSize, a.y * TileSize)
L.circleMarker(latLngA, { radius: 6, color: '#6366f1', fillColor: '#6366f1', fillOpacity: 0.8, weight: 2 }).addTo(layer)
if (b) {
const latLngB = toLatLng(b.x * TileSize, b.y * TileSize)
L.circleMarker(latLngB, { radius: 6, color: '#6366f1', fillColor: '#6366f1', fillOpacity: 0.8, weight: 2 }).addTo(layer)
L.polyline([latLngA, latLngB], { color: '#6366f1', weight: 2, opacity: 0.9 }).addTo(layer)
}
})
})
onBeforeUnmount(() => {
if (import.meta.client) {
window.removeEventListener('keydown', onKeydown)
}
mapNavigate.setGoTo(null)
if (contextMenuHandler) {
document.removeEventListener('contextmenu', contextMenuHandler, true)
}