Enhance map components and improve build processes

- Updated Makefile to include `--build` flag for `docker-compose.dev.yml` and `--no-cache` for `docker-compose.prod.yml` to ensure fresh builds.
- Added new CSS styles for Leaflet tooltips and popups to utilize theme colors, enhancing visual consistency.
- Enhanced MapView component with new props for markers and current zoom level, improving marker management and zoom functionality.
- Introduced new icons for copy and info actions to improve user interface clarity.
- Updated MapBookmarks and MapControls components to support new features and improve user experience with bookmarks and zoom controls.
- Refactored MapSearch to display coordinates and improve marker search functionality.
This commit is contained in:
2026-03-04 12:49:31 +03:00
parent dda35baeca
commit fc42d86ca0
13 changed files with 267 additions and 46 deletions

View File

@@ -3,10 +3,10 @@
TOOLS_COMPOSE = docker compose -f docker-compose.tools.yml TOOLS_COMPOSE = docker compose -f docker-compose.tools.yml
dev: dev:
docker compose -f docker-compose.dev.yml up docker compose -f docker-compose.dev.yml up --build
build: build:
docker compose -f docker-compose.prod.yml build docker compose -f docker-compose.prod.yml build --no-cache
test: test-backend test-frontend test: test-backend test-frontend

View File

@@ -21,3 +21,39 @@
.leaflet-tile.tile-fresh { .leaflet-tile.tile-fresh {
animation: tile-fresh-glow 0.4s ease-out; animation: tile-fresh-glow 0.4s ease-out;
} }
/* Leaflet tooltip: use theme colors (dark/light) */
.leaflet-tooltip {
background-color: var(--color-base-100);
color: var(--color-base-content);
border-color: var(--color-base-300);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.leaflet-tooltip-top:before {
border-top-color: var(--color-base-100);
}
.leaflet-tooltip-bottom:before {
border-bottom-color: var(--color-base-100);
}
.leaflet-tooltip-left:before {
border-left-color: var(--color-base-100);
}
.leaflet-tooltip-right:before {
border-right-color: var(--color-base-100);
}
/* Leaflet popup: use theme colors (dark/light) */
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: var(--color-base-100);
color: var(--color-base-content);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.35);
}
.leaflet-container a.leaflet-popup-close-button {
color: var(--color-base-content);
opacity: 0.7;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
opacity: 1;
}

View File

@@ -100,12 +100,15 @@
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
:markers="allMarkers"
:current-zoom="currentZoom"
:current-map-id="mapLogic.state.mapid.value" :current-map-id="mapLogic.state.mapid.value"
:current-coords="mapLogic.state.displayCoords.value" :current-coords="mapLogic.state.displayCoords.value"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
@zoom-in="mapLogic.zoomIn(leafletMap)" @zoom-in="mapLogic.zoomIn(leafletMap)"
@zoom-out="mapLogic.zoomOutControl(leafletMap)" @zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(leafletMap)" @reset-view="mapLogic.resetView(leafletMap)"
@set-zoom="onSetZoom"
@jump-to-marker="mapLogic.state.selectedMarkerId.value = $event" @jump-to-marker="mapLogic.state.selectedMarkerId.value = $event"
/> />
<MapContextMenu <MapContextMenu
@@ -147,7 +150,7 @@ import { useMapNavigate } from '~/composables/useMapNavigate'
import { useFullscreen } from '~/composables/useFullscreen' import { useFullscreen } from '~/composables/useFullscreen'
import { startMapUpdates, type UseMapUpdatesReturn, type SseConnectionState } from '~/composables/useMapUpdates' import { startMapUpdates, type UseMapUpdatesReturn, type SseConnectionState } 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, Marker as ApiMarker } from '~/types/api'
import type L from 'leaflet' import type L from 'leaflet'
const props = withDefaults( const props = withDefaults(
@@ -252,6 +255,10 @@ const maps = ref<MapInfo[]>([])
const mapsLoaded = ref(false) const mapsLoaded = ref(false)
const questGivers = ref<Array<{ id: number; name: string }>>([]) const questGivers = ref<Array<{ id: number; name: string }>>([])
const players = ref<Array<{ id: number; name: string }>>([]) const players = ref<Array<{ id: number; name: string }>>([])
/** All markers from API for search suggestions (updated when markers load or on merge). */
const allMarkers = ref<ApiMarker[]>([])
/** Current map zoom level (16) for zoom slider. Updated on zoomend. */
const currentZoom = ref(HnHDefaultZoom)
/** Single source of truth: layout updates me, we derive auths for context menu. */ /** Single source of truth: layout updates me, we derive auths for context menu. */
const me = useState<MeResponse | null>('me', () => null) const me = useState<MeResponse | null>('me', () => null)
const auths = computed(() => me.value?.auths ?? []) const auths = computed(() => me.value?.auths ?? [])
@@ -347,6 +354,10 @@ function reloadPage() {
if (import.meta.client) window.location.reload() if (import.meta.client) window.location.reload()
} }
function onSetZoom(z: number) {
if (leafletMap) leafletMap.setZoom(z)
}
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement const target = e.target as HTMLElement
const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '') const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')
@@ -462,6 +473,16 @@ onMounted(async () => {
getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value, getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id }, setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
onMarkerContextMenu: mapLogic.openMarkerContextMenu, onMarkerContextMenu: mapLogic.openMarkerContextMenu,
onAddMarkerToBookmark: (markerId, getMarkerById) => {
const m = getMarkerById(markerId)
if (!m) return
openBookmarkModal(m.name, 'Add bookmark', {
kind: 'add',
mapId: m.map,
x: Math.floor(m.position.x / TileSize),
y: Math.floor(m.position.y / TileSize),
})
},
resolveIconUrl: (path) => resolvePath(path), resolveIconUrl: (path) => resolvePath(path),
fallbackIconUrl: FALLBACK_MARKER_ICON, fallbackIconUrl: FALLBACK_MARKER_ICON,
}) })
@@ -479,7 +500,9 @@ onMounted(async () => {
layersManager!.changeMap(mapTo) layersManager!.changeMap(mapTo)
api.getMarkers().then((body) => { api.getMarkers().then((body) => {
if (!mounted) return if (!mounted) return
layersManager!.updateMarkers(Array.isArray(body) ? body : []) const list = Array.isArray(body) ? body : []
allMarkers.value = list
layersManager!.updateMarkers(list)
questGivers.value = layersManager!.getQuestGivers() questGivers.value = layersManager!.getQuestGivers()
}) })
leafletMap!.setView(latLng, leafletMap!.getZoom()) leafletMap!.setView(latLng, leafletMap!.getZoom())
@@ -530,7 +553,9 @@ onMounted(async () => {
// Markers load asynchronously after map is visible. // Markers load asynchronously after map is visible.
api.getMarkers().then((body) => { api.getMarkers().then((body) => {
if (!mounted) return if (!mounted) return
layersManager!.updateMarkers(Array.isArray(body) ? body : []) const list = Array.isArray(body) ? body : []
allMarkers.value = list
layersManager!.updateMarkers(list)
questGivers.value = layersManager!.getQuestGivers() questGivers.value = layersManager!.getQuestGivers()
updateSelectedMarkerForBookmark() updateSelectedMarkerForBookmark()
}) })
@@ -650,6 +675,10 @@ onMounted(async () => {
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap)) leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
mapLogic.updateDisplayCoords(leafletMap) mapLogic.updateDisplayCoords(leafletMap)
currentZoom.value = leafletMap.getZoom()
leafletMap.on('zoomend', () => {
if (leafletMap) currentZoom.value = leafletMap.getZoom()
})
leafletMap.on('drag', () => { leafletMap.on('drag', () => {
mapLogic.state.trackingCharacterId.value = -1 mapLogic.state.trackingCharacterId.value = -1
}) })

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">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<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="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
</template>

View File

@@ -7,7 +7,7 @@
<div class="flex flex-col gap-1 max-h-40 overflow-y-auto"> <div class="flex flex-col gap-1 max-h-40 overflow-y-auto">
<template v-if="bookmarks.length === 0"> <template v-if="bookmarks.length === 0">
<p class="text-xs text-base-content/60 py-1">No saved locations.</p> <p class="text-xs text-base-content/60 py-1">No saved locations.</p>
<p class="text-xs text-base-content/50 py-0">Add current location or a selected quest giver below.</p> <p class="text-xs text-base-content/50 py-0">Add your first location below.</p>
</template> </template>
<template v-else> <template v-else>
<div <div
@@ -49,7 +49,7 @@
class="btn btn-primary btn-sm w-full" class="btn btn-primary btn-sm w-full"
:class="touchFriendly ? 'min-h-11' : ''" :class="touchFriendly ? 'min-h-11' : ''"
:disabled="!selectedMarkerForBookmark" :disabled="!selectedMarkerForBookmark"
title="Add selected quest giver as bookmark" :title="selectedMarkerForBookmark ? 'Add selected quest giver as bookmark' : 'Select a quest giver from the list above to add it as a bookmark.'"
@click="onAddSelectedMarker" @click="onAddSelectedMarker"
> >
<icons-icon-plus class="size-4" /> <icons-icon-plus class="size-4" />

View File

@@ -49,12 +49,15 @@
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
:markers="markers"
:current-zoom="currentZoom"
:current-map-id="currentMapId ?? undefined" :current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords" :current-coords="currentCoords"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
@zoom-in="$emit('zoomIn')" @zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')" @zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')" @reset-view="$emit('resetView')"
@set-zoom="$emit('setZoom', $event)"
@jump-to-marker="$emit('jumpToMarker', $event)" @jump-to-marker="$emit('jumpToMarker', $event)"
/> />
</div> </div>
@@ -140,6 +143,8 @@
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
:markers="markers"
:current-zoom="currentZoom"
:current-map-id="currentMapId ?? undefined" :current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords" :current-coords="currentCoords"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
@@ -147,6 +152,7 @@
@zoom-in="$emit('zoomIn')" @zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')" @zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')" @reset-view="$emit('resetView')"
@set-zoom="$emit('setZoom', $event)"
@jump-to-marker="$emit('jumpToMarker', $event)" @jump-to-marker="$emit('jumpToMarker', $event)"
/> />
</div> </div>
@@ -169,7 +175,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo, Marker as ApiMarker } from '~/types/api'
import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue' import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue'
import MapControlsContent from '~/components/map/MapControlsContent.vue' import MapControlsContent from '~/components/map/MapControlsContent.vue'
@@ -188,6 +194,8 @@ const props = withDefaults(
maps: MapInfo[] maps: MapInfo[]
questGivers: QuestGiver[] questGivers: QuestGiver[]
players: Player[] players: Player[]
markers?: ApiMarker[]
currentZoom?: number
currentMapId?: number | null currentMapId?: number | null
currentCoords?: { x: number; y: number; z: number } | null currentCoords?: { x: number; y: number; z: number } | null
selectedMarkerForBookmark?: SelectedMarkerForBookmark selectedMarkerForBookmark?: SelectedMarkerForBookmark
@@ -196,6 +204,8 @@ const props = withDefaults(
maps: () => [], maps: () => [],
questGivers: () => [], questGivers: () => [],
players: () => [], players: () => [],
markers: () => [],
currentZoom: 1,
currentMapId: null, currentMapId: null,
currentCoords: null, currentCoords: null,
selectedMarkerForBookmark: null, selectedMarkerForBookmark: null,
@@ -206,6 +216,7 @@ defineEmits<{
zoomIn: [] zoomIn: []
zoomOut: [] zoomOut: []
resetView: [] resetView: []
setZoom: [level: number]
jumpToMarker: [id: number] jumpToMarker: [id: number]
}>() }>()

View File

@@ -5,6 +5,8 @@
v-if="currentMapId != null && currentCoords != null" v-if="currentMapId != null && currentCoords != null"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:markers="markers"
:overlay-map-id="props.overlayMapId"
:current-map-id="currentMapId" :current-map-id="currentMapId"
:current-coords="currentCoords" :current-coords="currentCoords"
:touch-friendly="touchFriendly" :touch-friendly="touchFriendly"
@@ -48,6 +50,19 @@
<icons-icon-home /> <icons-icon-home />
</button> </button>
</div> </div>
<div class="flex items-center gap-2">
<input
type="range"
:min="zoomMin"
:max="zoomMax"
:value="currentZoom"
class="range range-primary range-sm flex-1"
:class="touchFriendly ? 'range-lg' : ''"
aria-label="Zoom level"
@input="onZoomSliderInput($event)"
>
<span class="text-xs font-mono w-6 text-right" aria-hidden="true">{{ currentZoom }}</span>
</div>
</section> </section>
<!-- Display --> <!-- Display -->
<section class="flex flex-col gap-2"> <section class="flex flex-col gap-2">
@@ -78,7 +93,16 @@
</select> </select>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label py-0"><span>Overlay Map</span></label> <label class="label py-0 flex items-center gap-1.5">
<span>Overlay Map</span>
<span
class="inline-flex text-base-content/60 cursor-help"
title="Overlay shows markers from another map on top of the current one."
aria-label="Overlay shows markers from another map on top of the current one."
>
<icons-icon-info class="size-3.5" />
</span>
</label>
<select <select
v-model="overlayMapId" v-model="overlayMapId"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation" class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
@@ -89,22 +113,43 @@
</select> </select>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label py-0"><span>Jump to Quest Giver</span></label> <label class="label py-0"><span>Jump to</span></label>
<div class="join w-full flex">
<button
type="button"
class="btn btn-sm join-item flex-1 touch-manipulation"
:class="[jumpToTab === 'quest' ? 'btn-active' : 'btn-ghost', touchFriendly ? 'min-h-11 text-base' : '']"
aria-pressed="jumpToTab === 'quest'"
@click="jumpToTab = 'quest'"
>
Quest giver
</button>
<button
type="button"
class="btn btn-sm join-item flex-1 touch-manipulation"
:class="[jumpToTab === 'player' ? 'btn-active' : 'btn-ghost', touchFriendly ? 'min-h-11 text-base' : '']"
aria-pressed="jumpToTab === 'player'"
@click="jumpToTab = 'player'"
>
Player
</button>
</div>
<select <select
v-if="jumpToTab === 'quest'"
v-model="selectedMarkerIdSelect" v-model="selectedMarkerIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation" class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation mt-1"
:class="touchFriendly ? 'min-h-11 text-base' : ''" :class="touchFriendly ? 'min-h-11 text-base' : ''"
aria-label="Select quest giver"
> >
<option value="">Select quest giver</option> <option value="">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option> <option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option>
</select> </select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Player</span></label>
<select <select
v-else
v-model="selectedPlayerIdSelect" v-model="selectedPlayerIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation" class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation mt-1"
:class="touchFriendly ? 'min-h-11 text-base' : ''" :class="touchFriendly ? 'min-h-11 text-base' : ''"
aria-label="Select player"
> >
<option value="">Select player</option> <option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option> <option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option>
@@ -126,9 +171,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo, Marker as ApiMarker } from '~/types/api'
import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue' import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue'
import MapBookmarks from '~/components/map/MapBookmarks.vue' import MapBookmarks from '~/components/map/MapBookmarks.vue'
import { HnHMinZoom, HnHMaxZoom } from '~/lib/LeafletCustomTypes'
interface QuestGiver { interface QuestGiver {
id: number id: number
@@ -140,27 +186,33 @@ interface Player {
name: string name: string
} }
const zoomMin = HnHMinZoom
const zoomMax = HnHMaxZoom
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
maps: MapInfo[] maps: MapInfo[]
questGivers: QuestGiver[] questGivers: QuestGiver[]
players: Player[] players: Player[]
markers?: ApiMarker[]
touchFriendly?: boolean touchFriendly?: boolean
selectedMapIdSelect: string selectedMapIdSelect: string
overlayMapId: number overlayMapId: number
selectedMarkerIdSelect: string selectedMarkerIdSelect: string
selectedPlayerIdSelect: string selectedPlayerIdSelect: string
currentZoom?: number
currentMapId?: number currentMapId?: number
currentCoords?: { x: number; y: number; z: number } | null currentCoords?: { x: number; y: number; z: number } | null
selectedMarkerForBookmark?: SelectedMarkerForBookmark selectedMarkerForBookmark?: SelectedMarkerForBookmark
}>(), }>(),
{ touchFriendly: false, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null } { touchFriendly: false, markers: () => [], currentZoom: 1, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null }
) )
const emit = defineEmits<{ const emit = defineEmits<{
zoomIn: [] zoomIn: []
zoomOut: [] zoomOut: []
resetView: [] resetView: []
setZoom: [level: number]
jumpToMarker: [id: number] jumpToMarker: [id: number]
'update:hideMarkers': [v: boolean] 'update:hideMarkers': [v: boolean]
'update:selectedMapIdSelect': [v: string] 'update:selectedMapIdSelect': [v: string]
@@ -169,8 +221,16 @@ const emit = defineEmits<{
'update:selectedPlayerIdSelect': [v: string] 'update:selectedPlayerIdSelect': [v: string]
}>() }>()
function onZoomSliderInput(event: Event) {
const value = (event.target as HTMLInputElement).value
const level = Number(value)
if (!Number.isNaN(level)) emit('setZoom', level)
}
const hideMarkers = defineModel<boolean>('hideMarkers', { required: true }) const hideMarkers = defineModel<boolean>('hideMarkers', { required: true })
const jumpToTab = ref<'quest' | 'player'>('quest')
const selectedMapIdSelect = computed({ const selectedMapIdSelect = computed({
get: () => props.selectedMapIdSelect, get: () => props.selectedMapIdSelect,
set: (v) => emit('update:selectedMapIdSelect', v), set: (v) => emit('update:selectedMapIdSelect', v),

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="displayCoords" v-if="displayCoords"
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow cursor-pointer select-none transition-all hover:border-primary/50 hover:bg-base-100" class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-base bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow cursor-pointer select-none transition-all hover:border-primary/50 hover:bg-base-100 flex items-center gap-2"
aria-label="Current grid position and zoom click to copy share link" aria-label="Current grid position and zoom click to copy share link"
:title="copied ? 'Copied!' : 'Click to copy share link'" :title="copied ? 'Copied!' : 'Click to copy share link'"
role="button" role="button"
@@ -19,6 +19,7 @@
Copied! Copied!
</span> </span>
</span> </span>
<icons-icon-copy class="size-4 shrink-0 opacity-70" aria-hidden="true" />
</div> </div>
</template> </template>

View File

@@ -74,11 +74,13 @@
</template> </template>
</div> </div>
</div> </div>
<p class="text-xs text-base-content/50 mt-0.5">Coords: x, y — or type a marker name.</p>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo, Marker as ApiMarker } from '~/types/api'
import { TileSize } from '~/lib/LeafletCustomTypes'
import { useMapNavigate } from '~/composables/useMapNavigate' import { useMapNavigate } from '~/composables/useMapNavigate'
import { useRecentLocations } from '~/composables/useRecentLocations' import { useRecentLocations } from '~/composables/useRecentLocations'
@@ -86,11 +88,13 @@ const props = withDefaults(
defineProps<{ defineProps<{
maps: MapInfo[] maps: MapInfo[]
questGivers: Array<{ id: number; name: string }> questGivers: Array<{ id: number; name: string }>
markers?: ApiMarker[]
overlayMapId?: number
currentMapId: number currentMapId: number
currentCoords: { x: number; y: number; z: number } | null currentCoords: { x: number; y: number; z: number } | null
touchFriendly?: boolean touchFriendly?: boolean
}>(), }>(),
{ touchFriendly: false } { touchFriendly: false, markers: () => [], overlayMapId: -1 }
) )
const { goToCoords } = useMapNavigate() const { goToCoords } = useMapNavigate()
@@ -147,22 +151,37 @@ const suggestions = computed<Suggestion[]>(() => {
} }
const list: Suggestion[] = [] const list: Suggestion[] = []
for (const qg of props.questGivers) { const overlayId = props.overlayMapId ?? -1
if (qg.name.toLowerCase().includes(q)) { const visibleMarkers = (props.markers ?? []).filter(
(m) =>
!m.hidden &&
(m.map === props.currentMapId || (overlayId >= 0 && m.map === overlayId))
)
const qLower = q.toLowerCase()
for (const m of visibleMarkers) {
if (m.name.toLowerCase().includes(qLower)) {
const gridX = Math.floor(m.position.x / TileSize)
const gridY = Math.floor(m.position.y / TileSize)
list.push({ list.push({
key: `qg-${qg.id}`, key: `marker-${m.id}`,
label: qg.name, label: `${m.name} · ${gridX}, ${gridY}`,
mapId: props.currentMapId, mapId: m.map,
x: 0, x: gridX,
y: 0, y: gridY,
zoom: undefined, zoom: undefined,
markerId: qg.id, markerId: m.id,
}) })
} }
} }
if (list.length > 0) return list.slice(0, 8) // Prefer quest givers (match by id in questGivers) so they appear first when query matches both
const qgIds = new Set(props.questGivers.map((qg) => qg.id))
return [] list.sort((a, b) => {
const aQg = a.markerId != null && qgIds.has(a.markerId) ? 1 : 0
const bQg = b.markerId != null && qgIds.has(b.markerId) ? 1 : 0
if (bQg !== aQg) return bQg - aQg
return 0
})
return list.slice(0, 8)
}) })
function scheduleCloseDropdown() { function scheduleCloseDropdown() {

View File

@@ -1,5 +1,5 @@
import type L from 'leaflet' import type L from 'leaflet'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker' import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker'
import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character' import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character'
import { import {
@@ -14,6 +14,15 @@ import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer> type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export interface MapLayersOptions { export interface MapLayersOptions {
/** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */ /** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */
L: typeof import('leaflet') L: typeof import('leaflet')
@@ -28,6 +37,8 @@ export interface MapLayersOptions {
getTrackingCharacterId: () => number getTrackingCharacterId: () => number
setTrackingCharacterId: (id: number) => void setTrackingCharacterId: (id: number) => void
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
/** Called when user clicks "Add to saved locations" in marker popup. Receives marker id and getter to resolve marker. */
onAddMarkerToBookmark?: (markerId: number, getMarkerById: (id: number) => MapMarker | undefined) => void
/** Resolves relative marker icon path to absolute URL. If omitted, relative paths are used. */ /** Resolves relative marker icon path to absolute URL. If omitted, relative paths are used. */
resolveIconUrl?: (path: string) => string resolveIconUrl?: (path: string) => string
/** Fallback icon URL when a marker image fails to load. */ /** Fallback icon URL when a marker image fails to load. */
@@ -62,6 +73,7 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
getTrackingCharacterId, getTrackingCharacterId,
setTrackingCharacterId, setTrackingCharacterId,
onMarkerContextMenu, onMarkerContextMenu,
onAddMarkerToBookmark,
resolveIconUrl, resolveIconUrl,
fallbackIconUrl, fallbackIconUrl,
} = options } = options
@@ -112,7 +124,30 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
(marker: MapMarker) => { (marker: MapMarker) => {
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx) if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
marker.setClickCallback(() => { marker.setClickCallback(() => {
if (marker.leafletMarker) map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom) if (marker.leafletMarker) {
if (onAddMarkerToBookmark) {
const gridX = Math.floor(marker.position.x / TileSize)
const gridY = Math.floor(marker.position.y / TileSize)
const div = document.createElement('div')
div.className = 'map-marker-popup text-sm'
div.innerHTML = `
<p class="font-medium mb-1">${escapeHtml(marker.name)}</p>
<p class="text-base-content/70 text-xs mb-2 font-mono">${gridX}, ${gridY}</p>
<button type="button" class="btn btn-primary btn-xs w-full">Add to saved locations</button>
`
const btn = div.querySelector('button')
if (btn) {
btn.addEventListener('click', () => {
onAddMarkerToBookmark(marker.id, findMarkerById)
marker.leafletMarker?.closePopup()
})
}
marker.leafletMarker.unbindPopup()
marker.leafletMarker.bindPopup(div, { minWidth: 140, autoPan: true }).openPopup()
} else {
map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom)
}
}
}) })
marker.setContextMenu((mev: L.LeafletMouseEvent) => { marker.setContextMenu((mev: L.LeafletMouseEvent) => {
mev.originalEvent.preventDefault() mev.originalEvent.preventDefault()

View File

@@ -1,5 +1,5 @@
import type L from 'leaflet' import type L from 'leaflet'
import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes'
export interface MarkerData { export interface MarkerData {
id: number id: number
@@ -104,7 +104,15 @@ export function createMarker(
} }
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom) const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
leafletMarker = L.marker(position, { icon, title: marker.name }) leafletMarker = L.marker(position, { icon })
const gridX = Math.floor(marker.position.x / TileSize)
const gridY = Math.floor(marker.position.y / TileSize)
const tooltipContent = `${marker.name} · ${gridX}, ${gridY}`
leafletMarker.bindTooltip(tooltipContent, {
direction: 'top',
permanent: false,
offset: L.point(0, -14),
})
leafletMarker.addTo(mapview.markerLayer) leafletMarker.addTo(mapview.markerLayer)
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.() const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
if (markerEl) markerEl.setAttribute('aria-label', marker.name) if (markerEl) markerEl.setAttribute('aria-label', marker.name)
@@ -125,6 +133,9 @@ export function createMarker(
if (leafletMarker) { if (leafletMarker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
leafletMarker.setLatLng(position) leafletMarker.setLatLng(position)
const gridX = Math.floor(updated.position.x / TileSize)
const gridY = Math.floor(updated.position.y / TileSize)
leafletMarker.setTooltipContent(`${marker.name} · ${gridX}, ${gridY}`)
} }
}, },

View File

@@ -6,6 +6,10 @@ vi.mock('leaflet', () => {
addTo: vi.fn().mockReturnThis(), addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(), setLatLng: vi.fn().mockReturnThis(),
remove: vi.fn().mockReturnThis(), remove: vi.fn().mockReturnThis(),
bindTooltip: vi.fn().mockReturnThis(),
setTooltipContent: vi.fn().mockReturnThis(),
openPopup: vi.fn().mockReturnThis(),
closePopup: vi.fn().mockReturnThis(),
} }
return { return {
default: { default: {
@@ -19,11 +23,13 @@ vi.mock('leaflet', () => {
vi.mock('~/lib/LeafletCustomTypes', () => ({ vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6, HnHMaxZoom: 6,
TileSize: 100,
ImageIcon: class { ImageIcon: class {
constructor(_opts: Record<string, unknown>) {} constructor(_opts: Record<string, unknown>) {}
}, },
})) }))
import L from 'leaflet'
import { createMarker, type MarkerData, type MapViewRef } from '../Marker' import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData { function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData {
@@ -57,7 +63,7 @@ describe('createMarker', () => {
}) })
it('creates a marker with correct properties', () => { it('creates a marker with correct properties', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
expect(marker.id).toBe(1) expect(marker.id).toBe(1)
expect(marker.name).toBe('Tower') expect(marker.name).toBe('Tower')
expect(marker.position).toEqual({ x: 100, y: 200 }) expect(marker.position).toEqual({ x: 100, y: 200 })
@@ -69,46 +75,46 @@ describe('createMarker', () => {
}) })
it('detects quest type', () => { it('detects quest type', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' })) const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' }), undefined, L)
expect(marker.type).toBe('quest') expect(marker.type).toBe('quest')
}) })
it('detects quest type for bumling', () => { it('detects quest type for bumling', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' })) const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' }), undefined, L)
expect(marker.type).toBe('quest') expect(marker.type).toBe('quest')
}) })
it('detects custom type', () => { it('detects custom type', () => {
const marker = createMarker(makeMarkerData({ image: 'custom' })) const marker = createMarker(makeMarkerData({ image: 'custom' }), undefined, L)
expect(marker.type).toBe('custom') expect(marker.type).toBe('custom')
}) })
it('extracts type from gfx path', () => { it('extracts type from gfx path', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' })) const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' }), undefined, L)
expect(marker.type).toBe('village') expect(marker.type).toBe('village')
}) })
it('starts with null leaflet marker', () => { it('starts with null leaflet marker', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
expect(marker.leafletMarker).toBeNull() expect(marker.leafletMarker).toBeNull()
}) })
it('add creates a leaflet marker for non-hidden markers', () => { it('add creates a leaflet marker for non-hidden markers', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.add(mapview) marker.add(mapview)
expect(mapview.map.unproject).toHaveBeenCalled() expect(mapview.map.unproject).toHaveBeenCalled()
}) })
it('add does nothing for hidden markers', () => { it('add does nothing for hidden markers', () => {
const marker = createMarker(makeMarkerData({ hidden: true })) const marker = createMarker(makeMarkerData({ hidden: true }), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.add(mapview) marker.add(mapview)
expect(mapview.map.unproject).not.toHaveBeenCalled() expect(mapview.map.unproject).not.toHaveBeenCalled()
}) })
it('update changes position and name', () => { it('update changes position and name', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.update(mapview, { marker.update(mapview, {
@@ -122,7 +128,7 @@ describe('createMarker', () => {
}) })
it('setClickCallback and setContextMenu work', () => { it('setClickCallback and setContextMenu work', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const clickCb = vi.fn() const clickCb = vi.fn()
const contextCb = vi.fn() const contextCb = vi.fn()
@@ -131,7 +137,7 @@ describe('createMarker', () => {
}) })
it('remove on a marker without leaflet marker does nothing', () => { it('remove on a marker without leaflet marker does nothing', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.remove(mapview) // should not throw marker.remove(mapview) // should not throw
expect(marker.leafletMarker).toBeNull() expect(marker.leafletMarker).toBeNull()