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> <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)
} }

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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 })

View File

@@ -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]

View 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>

View 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>

View 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 }
}

View 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,
}
}

View 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 }
}

View 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,
}
}