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:
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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
|
<div
|
||||||
v-if="!mapReady"
|
v-if="!mapReady"
|
||||||
class="absolute inset-0 z-[400] flex flex-col items-center justify-center gap-6 bg-base-200/95 p-8"
|
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"
|
:mapid="mapLogic.state.mapid.value"
|
||||||
:display-coords="mapLogic.state.displayCoords.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
|
<MapControls
|
||||||
:show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
|
:show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
|
||||||
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
|
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
|
||||||
@@ -48,9 +82,12 @@
|
|||||||
:maps="maps"
|
:maps="maps"
|
||||||
:quest-givers="questGivers"
|
:quest-givers="questGivers"
|
||||||
:players="players"
|
:players="players"
|
||||||
|
:current-map-id="mapLogic.state.mapid.value"
|
||||||
|
:current-coords="mapLogic.state.displayCoords.value"
|
||||||
@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)"
|
||||||
|
@jump-to-marker="mapLogic.state.selectedMarkerId.value = $event"
|
||||||
/>
|
/>
|
||||||
<MapContextMenu
|
<MapContextMenu
|
||||||
:context-menu="mapLogic.contextMenu"
|
:context-menu="mapLogic.contextMenu"
|
||||||
@@ -65,6 +102,7 @@
|
|||||||
@close="mapLogic.closeCoordSetModal()"
|
@close="mapLogic.closeCoordSetModal()"
|
||||||
@submit="onSubmitCoordSet"
|
@submit="onSubmitCoordSet"
|
||||||
/>
|
/>
|
||||||
|
<MapShortcutsOverlay :open="showShortcutsOverlay" @close="showShortcutsOverlay = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -73,8 +111,11 @@ 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 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'
|
||||||
|
import { useMapNavigate } from '~/composables/useMapNavigate'
|
||||||
|
import { useFullscreen } from '~/composables/useFullscreen'
|
||||||
import { startMapUpdates, type UseMapUpdatesReturn } from '~/composables/useMapUpdates'
|
import { startMapUpdates, type UseMapUpdatesReturn } from '~/composables/useMapUpdates'
|
||||||
import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
|
import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
|
||||||
import type { MapInfo, ConfigResponse, MeResponse } from '~/types/api'
|
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 }
|
{ characterId: -1, mapId: undefined, gridX: 0, gridY: 0, zoom: 1 }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 mapLogic = useMapLogic()
|
const mapLogic = useMapLogic()
|
||||||
const { resolvePath } = useAppPaths()
|
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. */
|
/** Fallback marker icon (simple pin) when the real icon image fails to load. */
|
||||||
const FALLBACK_MARKER_ICON =
|
const FALLBACK_MARKER_ICON =
|
||||||
@@ -155,7 +218,35 @@ function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
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 () => {
|
onMounted(async () => {
|
||||||
@@ -346,6 +437,15 @@ onMounted(async () => {
|
|||||||
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) => {
|
||||||
|
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))
|
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
|
||||||
mapLogic.updateDisplayCoords(leafletMap)
|
mapLogic.updateDisplayCoords(leafletMap)
|
||||||
leafletMap.on('zoomend', () => {
|
leafletMap.on('zoomend', () => {
|
||||||
@@ -361,12 +461,49 @@ onMounted(async () => {
|
|||||||
mapLogic.state.trackingCharacterId.value = -1
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
window.removeEventListener('keydown', onKeydown)
|
window.removeEventListener('keydown', onKeydown)
|
||||||
}
|
}
|
||||||
|
mapNavigate.setGoTo(null)
|
||||||
if (contextMenuHandler) {
|
if (contextMenuHandler) {
|
||||||
document.removeEventListener('contextmenu', contextMenuHandler, true)
|
document.removeEventListener('contextmenu', contextMenuHandler, true)
|
||||||
}
|
}
|
||||||
|
|||||||
5
frontend-nuxt/components/icons/IconBookmark.vue
Normal file
5
frontend-nuxt/components/icons/IconBookmark.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
8
frontend-nuxt/components/icons/IconFullscreen.vue
Normal file
8
frontend-nuxt/components/icons/IconFullscreen.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||||
|
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||||
|
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||||
|
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
8
frontend-nuxt/components/icons/IconFullscreenExit.vue
Normal file
8
frontend-nuxt/components/icons/IconFullscreenExit.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||||
|
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||||
|
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||||
|
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
13
frontend-nuxt/components/icons/IconKeyboard.vue
Normal file
13
frontend-nuxt/components/icons/IconKeyboard.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" ry="2" />
|
||||||
|
<path d="M6 8h.001" />
|
||||||
|
<path d="M10 8h.001" />
|
||||||
|
<path d="M14 8h.001" />
|
||||||
|
<path d="M18 8h.001" />
|
||||||
|
<path d="M8 12h.001" />
|
||||||
|
<path d="M12 12h.001" />
|
||||||
|
<path d="M16 12h.001" />
|
||||||
|
<path d="M7 16h10" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
10
frontend-nuxt/components/icons/IconMinimap.vue
Normal file
10
frontend-nuxt/components/icons/IconMinimap.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="2" />
|
||||||
|
<path d="M9 9h6v6H9z" />
|
||||||
|
<path d="M14 5v2" />
|
||||||
|
<path d="M14 17v2" />
|
||||||
|
<path d="M5 14h2" />
|
||||||
|
<path d="M17 14h2" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
9
frontend-nuxt/components/icons/IconRuler.vue
Normal file
9
frontend-nuxt/components/icons/IconRuler.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z" />
|
||||||
|
<path d="m14.5 12.5 2-2" />
|
||||||
|
<path d="m11.5 9.5 2-2" />
|
||||||
|
<path d="m8.5 6.5 2-2" />
|
||||||
|
<path d="m17.5 15.5 2-2" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
6
frontend-nuxt/components/icons/IconSearch.vue
Normal file
6
frontend-nuxt/components/icons/IconSearch.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
96
frontend-nuxt/components/map/MapBookmarks.vue
Normal file
96
frontend-nuxt/components/map/MapBookmarks.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
|
||||||
|
<icons-icon-bookmark class="size-3.5 opacity-80" aria-hidden="true" />
|
||||||
|
Saved locations
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||||
|
<template v-if="bookmarks.length === 0">
|
||||||
|
<p class="text-xs text-base-content/60 py-1">No saved locations.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="b in bookmarks"
|
||||||
|
:key="b.id"
|
||||||
|
class="flex items-center gap-2 group rounded-lg hover:bg-base-200/50 px-2 py-1.5 -mx-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs flex-1 min-w-0 justify-start text-left truncate font-normal"
|
||||||
|
:title="`Go to ${b.name}`"
|
||||||
|
@click="onGoTo(b)"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ b.name }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs btn-square shrink-0 opacity-70 hover:opacity-100 hover:text-error"
|
||||||
|
aria-label="Remove bookmark"
|
||||||
|
@click="onRemove(b.id)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MapInfo } from '~/types/api'
|
||||||
|
import { useMapBookmarks } from '~/composables/useMapBookmarks'
|
||||||
|
import { useMapNavigate } from '~/composables/useMapNavigate'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
maps: MapInfo[]
|
||||||
|
currentMapId: number | null
|
||||||
|
currentCoords: { x: number; y: number; z: number } | null
|
||||||
|
touchFriendly?: boolean
|
||||||
|
}>(),
|
||||||
|
{ touchFriendly: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { bookmarks, add, remove } = useMapBookmarks()
|
||||||
|
const { goToCoords } = useMapNavigate()
|
||||||
|
|
||||||
|
const canAddCurrent = computed(
|
||||||
|
() =>
|
||||||
|
props.currentMapId != null &&
|
||||||
|
props.currentCoords != null &&
|
||||||
|
props.currentMapId >= 0
|
||||||
|
)
|
||||||
|
|
||||||
|
function onGoTo(b: { mapId: number; x: number; y: number; zoom?: number }) {
|
||||||
|
goToCoords(b.mapId, b.x, b.y, b.zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemove(id: string) {
|
||||||
|
remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddCurrent() {
|
||||||
|
if (!canAddCurrent.value) return
|
||||||
|
const mapId = props.currentMapId!
|
||||||
|
const { x, y, z } = props.currentCoords!
|
||||||
|
const mapName = props.maps.find((m) => m.ID === mapId)?.Name ?? `Map ${mapId}`
|
||||||
|
add({
|
||||||
|
name: `${mapName} ${x}, ${y}`,
|
||||||
|
mapId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
zoom: z,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -50,9 +50,12 @@
|
|||||||
:maps="maps"
|
:maps="maps"
|
||||||
:quest-givers="questGivers"
|
:quest-givers="questGivers"
|
||||||
:players="players"
|
:players="players"
|
||||||
|
:current-map-id="currentMapId ?? undefined"
|
||||||
|
:current-coords="currentCoords"
|
||||||
@zoom-in="$emit('zoomIn')"
|
@zoom-in="$emit('zoomIn')"
|
||||||
@zoom-out="$emit('zoomOut')"
|
@zoom-out="$emit('zoomOut')"
|
||||||
@reset-view="$emit('resetView')"
|
@reset-view="$emit('resetView')"
|
||||||
|
@jump-to-marker="$emit('jumpToMarker', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -138,10 +141,13 @@
|
|||||||
:maps="maps"
|
:maps="maps"
|
||||||
:quest-givers="questGivers"
|
:quest-givers="questGivers"
|
||||||
:players="players"
|
:players="players"
|
||||||
|
:current-map-id="currentMapId ?? undefined"
|
||||||
|
:current-coords="currentCoords"
|
||||||
:touch-friendly="true"
|
:touch-friendly="true"
|
||||||
@zoom-in="$emit('zoomIn')"
|
@zoom-in="$emit('zoomIn')"
|
||||||
@zoom-out="$emit('zoomOut')"
|
@zoom-out="$emit('zoomOut')"
|
||||||
@reset-view="$emit('resetView')"
|
@reset-view="$emit('resetView')"
|
||||||
|
@jump-to-marker="$emit('jumpToMarker', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 border-t border-base-300 shrink-0 safe-area-pb">
|
<div class="p-3 border-t border-base-300 shrink-0 safe-area-pb">
|
||||||
@@ -178,14 +184,17 @@ const props = withDefaults(
|
|||||||
maps: MapInfo[]
|
maps: MapInfo[]
|
||||||
questGivers: QuestGiver[]
|
questGivers: QuestGiver[]
|
||||||
players: Player[]
|
players: Player[]
|
||||||
|
currentMapId?: number | null
|
||||||
|
currentCoords?: { x: number; y: number; z: number } | null
|
||||||
}>(),
|
}>(),
|
||||||
{ maps: () => [], questGivers: () => [], players: () => [] }
|
{ maps: () => [], questGivers: () => [], players: () => [], currentMapId: null, currentCoords: null }
|
||||||
)
|
)
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
zoomIn: []
|
zoomIn: []
|
||||||
zoomOut: []
|
zoomOut: []
|
||||||
resetView: []
|
resetView: []
|
||||||
|
jumpToMarker: [id: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })
|
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<MapSearch
|
||||||
|
v-if="currentMapId != null && currentCoords != null"
|
||||||
|
:maps="maps"
|
||||||
|
:quest-givers="questGivers"
|
||||||
|
:current-map-id="currentMapId"
|
||||||
|
:current-coords="currentCoords"
|
||||||
|
:touch-friendly="touchFriendly"
|
||||||
|
@jump-to-marker="$emit('jumpToMarker', $event)"
|
||||||
|
/>
|
||||||
<!-- Zoom -->
|
<!-- Zoom -->
|
||||||
<section class="flex flex-col gap-2">
|
<section class="flex flex-col gap-2">
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
|
||||||
@@ -105,11 +115,19 @@
|
|||||||
</select>
|
</select>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
|
<!-- Saved locations -->
|
||||||
|
<MapBookmarks
|
||||||
|
:maps="maps"
|
||||||
|
:current-map-id="currentMapId ?? null"
|
||||||
|
:current-coords="currentCoords ?? null"
|
||||||
|
:touch-friendly="touchFriendly"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MapInfo } from '~/types/api'
|
import type { MapInfo } from '~/types/api'
|
||||||
|
import MapBookmarks from '~/components/map/MapBookmarks.vue'
|
||||||
|
|
||||||
interface QuestGiver {
|
interface QuestGiver {
|
||||||
id: number
|
id: number
|
||||||
@@ -131,14 +149,17 @@ const props = withDefaults(
|
|||||||
overlayMapId: number
|
overlayMapId: number
|
||||||
selectedMarkerIdSelect: string
|
selectedMarkerIdSelect: string
|
||||||
selectedPlayerIdSelect: string
|
selectedPlayerIdSelect: string
|
||||||
|
currentMapId?: number
|
||||||
|
currentCoords?: { x: number; y: number; z: number } | null
|
||||||
}>(),
|
}>(),
|
||||||
{ touchFriendly: false }
|
{ touchFriendly: false, currentMapId: 0, currentCoords: null }
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
zoomIn: []
|
zoomIn: []
|
||||||
zoomOut: []
|
zoomOut: []
|
||||||
resetView: []
|
resetView: []
|
||||||
|
jumpToMarker: [id: number]
|
||||||
'update:showGridCoordinates': [v: boolean]
|
'update:showGridCoordinates': [v: boolean]
|
||||||
'update:hideMarkers': [v: boolean]
|
'update:hideMarkers': [v: boolean]
|
||||||
'update:selectedMapIdSelect': [v: string]
|
'update:selectedMapIdSelect': [v: string]
|
||||||
|
|||||||
191
frontend-nuxt/components/map/MapSearch.vue
Normal file
191
frontend-nuxt/components/map/MapSearch.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
|
||||||
|
<icons-icon-search class="size-3.5 opacity-80" aria-hidden="true" />
|
||||||
|
Search
|
||||||
|
</h3>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
class="input input-sm w-full pr-8 focus:ring-2 focus:ring-primary"
|
||||||
|
:class="touchFriendly ? 'min-h-11 text-base' : ''"
|
||||||
|
placeholder="Coords (x, y) or marker name"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="showDropdown = true"
|
||||||
|
@blur="scheduleCloseDropdown"
|
||||||
|
@keydown.enter="onEnter"
|
||||||
|
@keydown.down.prevent="moveHighlight(1)"
|
||||||
|
@keydown.up.prevent="moveHighlight(-1)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="query"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
|
||||||
|
aria-label="Clear"
|
||||||
|
@mousedown.prevent="query = ''; showDropdown = false"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showDropdown && (suggestions.length > 0 || recent.recent.length > 0)"
|
||||||
|
class="absolute left-0 right-0 top-full z-[600] mt-1 max-h-56 overflow-y-auto rounded-lg border border-base-300 bg-base-100 shadow-xl py-1"
|
||||||
|
>
|
||||||
|
<template v-if="suggestions.length > 0">
|
||||||
|
<button
|
||||||
|
v-for="(s, i) in suggestions"
|
||||||
|
:key="s.key"
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-3 py-2 text-sm hover:bg-base-200 flex items-center gap-2"
|
||||||
|
:class="{ 'bg-primary/10': i === highlightIndex }"
|
||||||
|
@mousedown.prevent="goToSuggestion(s)"
|
||||||
|
>
|
||||||
|
<icons-icon-map-pin class="size-4 shrink-0 opacity-70" />
|
||||||
|
<span class="truncate">{{ s.label }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="recent.recent.length > 0">
|
||||||
|
<p class="px-3 py-1 text-xs text-base-content/60 uppercase tracking-wider">Recent</p>
|
||||||
|
<button
|
||||||
|
v-for="(r, i) in recent.recent"
|
||||||
|
:key="r.at"
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-3 py-2 text-sm hover:bg-base-200 flex items-center gap-2"
|
||||||
|
:class="{ 'bg-primary/10': i === highlightIndex }"
|
||||||
|
@mousedown.prevent="goToRecent(r)"
|
||||||
|
>
|
||||||
|
<icons-icon-map-pin class="size-4 shrink-0 opacity-70" />
|
||||||
|
<span class="truncate">{{ r.label || `${r.mapId} · ${r.x}, ${r.y}` }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MapInfo } from '~/types/api'
|
||||||
|
import { useMapNavigate } from '~/composables/useMapNavigate'
|
||||||
|
import { useRecentLocations } from '~/composables/useRecentLocations'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
maps: MapInfo[]
|
||||||
|
questGivers: Array<{ id: number; name: string }>
|
||||||
|
currentMapId: number
|
||||||
|
currentCoords: { x: number; y: number; z: number } | null
|
||||||
|
touchFriendly?: boolean
|
||||||
|
}>(),
|
||||||
|
{ touchFriendly: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { goToCoords } = useMapNavigate()
|
||||||
|
const recent = useRecentLocations()
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const query = ref('')
|
||||||
|
const showDropdown = ref(false)
|
||||||
|
const highlightIndex = ref(0)
|
||||||
|
let closeDropdownTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
mapId: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
zoom?: number
|
||||||
|
markerId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = computed<Suggestion[]>(() => {
|
||||||
|
const q = query.value.trim().toLowerCase()
|
||||||
|
if (!q) return []
|
||||||
|
|
||||||
|
const coordMatch = q.match(/^\s*(-?\d+)\s*[,;\s]\s*(-?\d+)\s*$/)
|
||||||
|
if (coordMatch) {
|
||||||
|
const x = parseInt(coordMatch[1], 10)
|
||||||
|
const y = parseInt(coordMatch[2], 10)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: `coord-${x}-${y}`,
|
||||||
|
label: `Go to ${x}, ${y}`,
|
||||||
|
mapId: props.currentMapId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
zoom: props.currentCoords?.z,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const list: Suggestion[] = []
|
||||||
|
for (const qg of props.questGivers) {
|
||||||
|
if (qg.name.toLowerCase().includes(q)) {
|
||||||
|
list.push({
|
||||||
|
key: `qg-${qg.id}`,
|
||||||
|
label: qg.name,
|
||||||
|
mapId: props.currentMapId,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
zoom: undefined,
|
||||||
|
markerId: qg.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (list.length > 0) return list.slice(0, 8)
|
||||||
|
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
function scheduleCloseDropdown() {
|
||||||
|
closeDropdownTimer = setTimeout(() => {
|
||||||
|
showDropdown.value = false
|
||||||
|
closeDropdownTimer = null
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHighlight(delta: number) {
|
||||||
|
const total = suggestions.value.length || recent.recent.length
|
||||||
|
if (total === 0) return
|
||||||
|
highlightIndex.value = (highlightIndex.value + delta + total) % total
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToSuggestion(s: Suggestion) {
|
||||||
|
showDropdown.value = false
|
||||||
|
if (s.key.startsWith('coord-')) {
|
||||||
|
goToCoords(s.mapId, s.x, s.y, s.zoom)
|
||||||
|
recent.push({ mapId: s.mapId, x: s.x, y: s.y, zoom: s.zoom, label: `${s.x}, ${s.y}` })
|
||||||
|
} else if (s.markerId != null) {
|
||||||
|
emit('jump-to-marker', s.markerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToRecent(r: (typeof recent.recent.value)[0]) {
|
||||||
|
showDropdown.value = false
|
||||||
|
goToCoords(r.mapId, r.x, r.y, r.zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnter() {
|
||||||
|
if (suggestions.value.length > 0) {
|
||||||
|
const s = suggestions.value[highlightIndex.value] ?? suggestions.value[0]
|
||||||
|
goToSuggestion(s)
|
||||||
|
} else if (recent.recent.length > 0) {
|
||||||
|
const r = recent.recent[highlightIndex.value] ?? recent.recent[0]
|
||||||
|
goToRecent(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'jump-to-marker': [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
useMapNavigate().setFocusSearch(() => inputRef.value?.focus())
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (closeDropdownTimer) clearTimeout(closeDropdownTimer)
|
||||||
|
useMapNavigate().setFocusSearch(null)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
84
frontend-nuxt/components/map/MapShortcutsOverlay.vue
Normal file
84
frontend-nuxt/components/map/MapShortcutsOverlay.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="shortcuts-modal">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-[1200] flex items-center justify-center p-4 bg-black/50"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
@click.self="$emit('close')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-base-100 rounded-xl shadow-2xl border border-base-300 max-w-md w-full p-6"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<icons-icon-keyboard class="size-5" />
|
||||||
|
Keyboard shortcuts
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm btn-circle"
|
||||||
|
aria-label="Close"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
<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">?</kbd></dt>
|
||||||
|
<dd>Show this help</dd>
|
||||||
|
</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">+</kbd> / <kbd class="kbd kbd-sm">−</kbd></dt>
|
||||||
|
<dd>Zoom in / out</dd>
|
||||||
|
</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">H</kbd></dt>
|
||||||
|
<dd>Toggle markers visibility</dd>
|
||||||
|
</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">
|
||||||
|
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">F</kbd></dt>
|
||||||
|
<dd>Focus search</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4 py-1">
|
||||||
|
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">Esc</kbd></dt>
|
||||||
|
<dd>Close menus / overlay</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ open: boolean }>()
|
||||||
|
defineEmits<{ close: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shortcuts-modal-enter-active,
|
||||||
|
.shortcuts-modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.shortcuts-modal-enter-from,
|
||||||
|
.shortcuts-modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.shortcuts-modal-enter-active .bg-base-100,
|
||||||
|
.shortcuts-modal-leave-active .bg-base-100 {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.shortcuts-modal-enter-from .bg-base-100,
|
||||||
|
.shortcuts-modal-leave-to .bg-base-100 {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
frontend-nuxt/composables/useFullscreen.ts
Normal file
57
frontend-nuxt/composables/useFullscreen.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Fullscreen API for a target element. When active, optionally hide navbar via body class.
|
||||||
|
*/
|
||||||
|
export function useFullscreen(targetRef: Ref<HTMLElement | null>) {
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
|
||||||
|
function enter() {
|
||||||
|
const el = targetRef.value
|
||||||
|
if (!el || import.meta.server) return
|
||||||
|
const doc = document as Document & { fullscreenElement?: Element; exitFullscreen?: () => Promise<void> }
|
||||||
|
if (doc.fullscreenElement) return
|
||||||
|
el.requestFullscreen?.()?.then(() => {
|
||||||
|
isFullscreen.value = true
|
||||||
|
document.body.classList.add('map-fullscreen')
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function exit() {
|
||||||
|
if (import.meta.server) return
|
||||||
|
const doc = document as Document & { fullscreenElement?: Element; exitFullscreen?: () => Promise<void> }
|
||||||
|
if (!doc.fullscreenElement) {
|
||||||
|
isFullscreen.value = false
|
||||||
|
document.body.classList.remove('map-fullscreen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc.exitFullscreen?.()?.then(() => {
|
||||||
|
isFullscreen.value = false
|
||||||
|
document.body.classList.remove('map-fullscreen')
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (isFullscreen.value) exit()
|
||||||
|
else enter()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFullscreenChange() {
|
||||||
|
const doc = document as Document & { fullscreenElement?: Element }
|
||||||
|
isFullscreen.value = !!doc.fullscreenElement
|
||||||
|
if (!doc.fullscreenElement) document.body.classList.remove('map-fullscreen')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
if (isFullscreen.value) exit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { isFullscreen, enter, exit, toggle }
|
||||||
|
}
|
||||||
76
frontend-nuxt/composables/useMapBookmarks.ts
Normal file
76
frontend-nuxt/composables/useMapBookmarks.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
export interface MapBookmark {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
mapId: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
zoom?: number
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'hnh-map-bookmarks'
|
||||||
|
const MAX_BOOKMARKS = 50
|
||||||
|
|
||||||
|
const BOOKMARKS_STATE_KEY = 'hnh-map-bookmarks-state'
|
||||||
|
|
||||||
|
function loadBookmarks(): MapBookmark[] {
|
||||||
|
if (import.meta.server) return []
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw) as MapBookmark[]
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBookmarks(bookmarks: MapBookmark[]) {
|
||||||
|
if (import.meta.server) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks.slice(0, MAX_BOOKMARKS)))
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMapBookmarks() {
|
||||||
|
const bookmarks = useState<MapBookmark[]>(BOOKMARKS_STATE_KEY, () => [])
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
bookmarks.value = loadBookmarks()
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(bookmark: Omit<MapBookmark, 'id' | 'createdAt'>) {
|
||||||
|
const list = loadBookmarks()
|
||||||
|
const id = `b-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
const created: MapBookmark = {
|
||||||
|
...bookmark,
|
||||||
|
id,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
list.unshift(created)
|
||||||
|
saveBookmarks(list)
|
||||||
|
refresh()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string) {
|
||||||
|
const list = loadBookmarks().filter((b) => b.id !== id)
|
||||||
|
saveBookmarks(list)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
saveBookmarks([])
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh)
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookmarks: readonly(bookmarks),
|
||||||
|
refresh,
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend-nuxt/composables/useMapNavigate.ts
Normal file
28
frontend-nuxt/composables/useMapNavigate.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Composable for map navigation (go-to coordinates) and focus search.
|
||||||
|
* MapView registers the implementation on mount; MapSearch and shortcuts use it.
|
||||||
|
*/
|
||||||
|
type GoToCoordsFn = (mapId: number, x: number, y: number, zoom?: number) => void
|
||||||
|
|
||||||
|
const goToImpl = ref<GoToCoordsFn | null>(null)
|
||||||
|
const focusSearchImpl = ref<(() => void) | null>(null)
|
||||||
|
|
||||||
|
export function useMapNavigate() {
|
||||||
|
function setGoTo(fn: GoToCoordsFn | null) {
|
||||||
|
goToImpl.value = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCoords(mapId: number, x: number, y: number, zoom?: number) {
|
||||||
|
goToImpl.value?.(mapId, x, y, zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFocusSearch(fn: (() => void) | null) {
|
||||||
|
focusSearchImpl.value = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusSearch() {
|
||||||
|
focusSearchImpl.value?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { setGoTo, goToCoords, setFocusSearch, focusSearch }
|
||||||
|
}
|
||||||
63
frontend-nuxt/composables/useRecentLocations.ts
Normal file
63
frontend-nuxt/composables/useRecentLocations.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export interface RecentLocation {
|
||||||
|
mapId: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
zoom?: number
|
||||||
|
label?: string
|
||||||
|
at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'hnh-map-recent-locations'
|
||||||
|
const MAX_RECENT = 10
|
||||||
|
|
||||||
|
function loadRecent(): RecentLocation[] {
|
||||||
|
if (import.meta.server) return []
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw) as RecentLocation[]
|
||||||
|
return Array.isArray(parsed) ? parsed.slice(0, MAX_RECENT) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRecent(list: RecentLocation[]) {
|
||||||
|
if (import.meta.server) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(list.slice(0, MAX_RECENT)))
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecentLocations() {
|
||||||
|
const recent = ref<RecentLocation[]>([])
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
recent.value = loadRecent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(entry: Omit<RecentLocation, 'at'>) {
|
||||||
|
const list = loadRecent()
|
||||||
|
const at = Date.now()
|
||||||
|
const filtered = list.filter(
|
||||||
|
(r) => !(r.mapId === entry.mapId && r.x === entry.x && r.y === entry.y)
|
||||||
|
)
|
||||||
|
filtered.unshift({ ...entry, at })
|
||||||
|
saveRecent(filtered)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
saveRecent([])
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh)
|
||||||
|
|
||||||
|
return {
|
||||||
|
recent: readonly(recent),
|
||||||
|
refresh,
|
||||||
|
push,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user