diff --git a/frontend-nuxt/components/MapView.vue b/frontend-nuxt/components/MapView.vue index d3944f6..9cb891e 100644 --- a/frontend-nuxt/components/MapView.vue +++ b/frontend-nuxt/components/MapView.vue @@ -1,5 +1,5 @@ @@ -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(null) const mapRef = ref(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(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) } diff --git a/frontend-nuxt/components/icons/IconBookmark.vue b/frontend-nuxt/components/icons/IconBookmark.vue new file mode 100644 index 0000000..acea5bd --- /dev/null +++ b/frontend-nuxt/components/icons/IconBookmark.vue @@ -0,0 +1,5 @@ + diff --git a/frontend-nuxt/components/icons/IconFullscreen.vue b/frontend-nuxt/components/icons/IconFullscreen.vue new file mode 100644 index 0000000..a47b42c --- /dev/null +++ b/frontend-nuxt/components/icons/IconFullscreen.vue @@ -0,0 +1,8 @@ + diff --git a/frontend-nuxt/components/icons/IconFullscreenExit.vue b/frontend-nuxt/components/icons/IconFullscreenExit.vue new file mode 100644 index 0000000..1973d14 --- /dev/null +++ b/frontend-nuxt/components/icons/IconFullscreenExit.vue @@ -0,0 +1,8 @@ + diff --git a/frontend-nuxt/components/icons/IconKeyboard.vue b/frontend-nuxt/components/icons/IconKeyboard.vue new file mode 100644 index 0000000..296e1ce --- /dev/null +++ b/frontend-nuxt/components/icons/IconKeyboard.vue @@ -0,0 +1,13 @@ + diff --git a/frontend-nuxt/components/icons/IconMinimap.vue b/frontend-nuxt/components/icons/IconMinimap.vue new file mode 100644 index 0000000..a6d395b --- /dev/null +++ b/frontend-nuxt/components/icons/IconMinimap.vue @@ -0,0 +1,10 @@ + diff --git a/frontend-nuxt/components/icons/IconRuler.vue b/frontend-nuxt/components/icons/IconRuler.vue new file mode 100644 index 0000000..26b74ac --- /dev/null +++ b/frontend-nuxt/components/icons/IconRuler.vue @@ -0,0 +1,9 @@ + diff --git a/frontend-nuxt/components/icons/IconSearch.vue b/frontend-nuxt/components/icons/IconSearch.vue new file mode 100644 index 0000000..64d4154 --- /dev/null +++ b/frontend-nuxt/components/icons/IconSearch.vue @@ -0,0 +1,6 @@ + diff --git a/frontend-nuxt/components/map/MapBookmarks.vue b/frontend-nuxt/components/map/MapBookmarks.vue new file mode 100644 index 0000000..2896306 --- /dev/null +++ b/frontend-nuxt/components/map/MapBookmarks.vue @@ -0,0 +1,96 @@ + + + diff --git a/frontend-nuxt/components/map/MapControls.vue b/frontend-nuxt/components/map/MapControls.vue index 23665e2..85f84d2 100644 --- a/frontend-nuxt/components/map/MapControls.vue +++ b/frontend-nuxt/components/map/MapControls.vue @@ -50,9 +50,12 @@ :maps="maps" :quest-givers="questGivers" :players="players" + :current-map-id="currentMapId ?? undefined" + :current-coords="currentCoords" @zoom-in="$emit('zoomIn')" @zoom-out="$emit('zoomOut')" @reset-view="$emit('resetView')" + @jump-to-marker="$emit('jumpToMarker', $event)" /> @@ -138,10 +141,13 @@ :maps="maps" :quest-givers="questGivers" :players="players" + :current-map-id="currentMapId ?? undefined" + :current-coords="currentCoords" :touch-friendly="true" @zoom-in="$emit('zoomIn')" @zoom-out="$emit('zoomOut')" @reset-view="$emit('resetView')" + @jump-to-marker="$emit('jumpToMarker', $event)" />
@@ -178,14 +184,17 @@ const props = withDefaults( maps: MapInfo[] questGivers: QuestGiver[] players: Player[] + currentMapId?: number | null + currentCoords?: { x: number; y: number; z: number } | null }>(), - { maps: () => [], questGivers: () => [], players: () => [] } + { maps: () => [], questGivers: () => [], players: () => [], currentMapId: null, currentCoords: null } ) defineEmits<{ zoomIn: [] zoomOut: [] resetView: [] + jumpToMarker: [id: number] }>() const showGridCoordinates = defineModel('showGridCoordinates', { default: false }) diff --git a/frontend-nuxt/components/map/MapControlsContent.vue b/frontend-nuxt/components/map/MapControlsContent.vue index 923451c..3a22f78 100644 --- a/frontend-nuxt/components/map/MapControlsContent.vue +++ b/frontend-nuxt/components/map/MapControlsContent.vue @@ -1,5 +1,15 @@ diff --git a/frontend-nuxt/components/map/MapShortcutsOverlay.vue b/frontend-nuxt/components/map/MapShortcutsOverlay.vue new file mode 100644 index 0000000..f4e26d2 --- /dev/null +++ b/frontend-nuxt/components/map/MapShortcutsOverlay.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/frontend-nuxt/composables/useFullscreen.ts b/frontend-nuxt/composables/useFullscreen.ts new file mode 100644 index 0000000..2cabe28 --- /dev/null +++ b/frontend-nuxt/composables/useFullscreen.ts @@ -0,0 +1,57 @@ +/** + * Fullscreen API for a target element. When active, optionally hide navbar via body class. + */ +export function useFullscreen(targetRef: Ref) { + 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 } + 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 } + 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 } +} diff --git a/frontend-nuxt/composables/useMapBookmarks.ts b/frontend-nuxt/composables/useMapBookmarks.ts new file mode 100644 index 0000000..818fd57 --- /dev/null +++ b/frontend-nuxt/composables/useMapBookmarks.ts @@ -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(BOOKMARKS_STATE_KEY, () => []) + + function refresh() { + bookmarks.value = loadBookmarks() + } + + function add(bookmark: Omit) { + 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, + } +} diff --git a/frontend-nuxt/composables/useMapNavigate.ts b/frontend-nuxt/composables/useMapNavigate.ts new file mode 100644 index 0000000..e7cb165 --- /dev/null +++ b/frontend-nuxt/composables/useMapNavigate.ts @@ -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(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 } +} diff --git a/frontend-nuxt/composables/useRecentLocations.ts b/frontend-nuxt/composables/useRecentLocations.ts new file mode 100644 index 0000000..125894d --- /dev/null +++ b/frontend-nuxt/composables/useRecentLocations.ts @@ -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([]) + + function refresh() { + recent.value = loadRecent() + } + + function push(entry: Omit) { + 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, + } +}