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:
4
Makefile
4
Makefile
@@ -3,10 +3,10 @@
|
||||
TOOLS_COMPOSE = docker compose -f docker-compose.tools.yml
|
||||
|
||||
dev:
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
docker compose -f docker-compose.dev.yml up --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
|
||||
|
||||
|
||||
@@ -21,3 +21,39 @@
|
||||
.leaflet-tile.tile-fresh {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -100,12 +100,15 @@
|
||||
:maps="maps"
|
||||
:quest-givers="questGivers"
|
||||
:players="players"
|
||||
:markers="allMarkers"
|
||||
:current-zoom="currentZoom"
|
||||
:current-map-id="mapLogic.state.mapid.value"
|
||||
:current-coords="mapLogic.state.displayCoords.value"
|
||||
:selected-marker-for-bookmark="selectedMarkerForBookmark"
|
||||
@zoom-in="mapLogic.zoomIn(leafletMap)"
|
||||
@zoom-out="mapLogic.zoomOutControl(leafletMap)"
|
||||
@reset-view="mapLogic.resetView(leafletMap)"
|
||||
@set-zoom="onSetZoom"
|
||||
@jump-to-marker="mapLogic.state.selectedMarkerId.value = $event"
|
||||
/>
|
||||
<MapContextMenu
|
||||
@@ -147,7 +150,7 @@ import { useMapNavigate } from '~/composables/useMapNavigate'
|
||||
import { useFullscreen } from '~/composables/useFullscreen'
|
||||
import { startMapUpdates, type UseMapUpdatesReturn, type SseConnectionState } from '~/composables/useMapUpdates'
|
||||
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'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -252,6 +255,10 @@ const maps = ref<MapInfo[]>([])
|
||||
const mapsLoaded = ref(false)
|
||||
const questGivers = 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 (1–6) for zoom slider. Updated on zoomend. */
|
||||
const currentZoom = ref(HnHDefaultZoom)
|
||||
/** Single source of truth: layout updates me, we derive auths for context menu. */
|
||||
const me = useState<MeResponse | null>('me', () => null)
|
||||
const auths = computed(() => me.value?.auths ?? [])
|
||||
@@ -347,6 +354,10 @@ function reloadPage() {
|
||||
if (import.meta.client) window.location.reload()
|
||||
}
|
||||
|
||||
function onSetZoom(z: number) {
|
||||
if (leafletMap) leafletMap.setZoom(z)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')
|
||||
@@ -462,6 +473,16 @@ onMounted(async () => {
|
||||
getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
|
||||
setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
|
||||
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),
|
||||
fallbackIconUrl: FALLBACK_MARKER_ICON,
|
||||
})
|
||||
@@ -479,7 +500,9 @@ onMounted(async () => {
|
||||
layersManager!.changeMap(mapTo)
|
||||
api.getMarkers().then((body) => {
|
||||
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()
|
||||
})
|
||||
leafletMap!.setView(latLng, leafletMap!.getZoom())
|
||||
@@ -530,7 +553,9 @@ onMounted(async () => {
|
||||
// Markers load asynchronously after map is visible.
|
||||
api.getMarkers().then((body) => {
|
||||
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()
|
||||
updateSelectedMarkerForBookmark()
|
||||
})
|
||||
@@ -650,6 +675,10 @@ onMounted(async () => {
|
||||
|
||||
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
|
||||
mapLogic.updateDisplayCoords(leafletMap)
|
||||
currentZoom.value = leafletMap.getZoom()
|
||||
leafletMap.on('zoomend', () => {
|
||||
if (leafletMap) currentZoom.value = leafletMap.getZoom()
|
||||
})
|
||||
leafletMap.on('drag', () => {
|
||||
mapLogic.state.trackingCharacterId.value = -1
|
||||
})
|
||||
|
||||
6
frontend-nuxt/components/icons/IconCopy.vue
Normal file
6
frontend-nuxt/components/icons/IconCopy.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">
|
||||
<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>
|
||||
7
frontend-nuxt/components/icons/IconInfo.vue
Normal file
7
frontend-nuxt/components/icons/IconInfo.vue
Normal 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>
|
||||
@@ -7,7 +7,7 @@
|
||||
<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>
|
||||
<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 v-else>
|
||||
<div
|
||||
@@ -49,7 +49,7 @@
|
||||
class="btn btn-primary btn-sm w-full"
|
||||
:class="touchFriendly ? 'min-h-11' : ''"
|
||||
: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"
|
||||
>
|
||||
<icons-icon-plus class="size-4" />
|
||||
|
||||
@@ -49,12 +49,15 @@
|
||||
:maps="maps"
|
||||
:quest-givers="questGivers"
|
||||
:players="players"
|
||||
:markers="markers"
|
||||
:current-zoom="currentZoom"
|
||||
:current-map-id="currentMapId ?? undefined"
|
||||
:current-coords="currentCoords"
|
||||
:selected-marker-for-bookmark="selectedMarkerForBookmark"
|
||||
@zoom-in="$emit('zoomIn')"
|
||||
@zoom-out="$emit('zoomOut')"
|
||||
@reset-view="$emit('resetView')"
|
||||
@set-zoom="$emit('setZoom', $event)"
|
||||
@jump-to-marker="$emit('jumpToMarker', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -140,6 +143,8 @@
|
||||
:maps="maps"
|
||||
:quest-givers="questGivers"
|
||||
:players="players"
|
||||
:markers="markers"
|
||||
:current-zoom="currentZoom"
|
||||
:current-map-id="currentMapId ?? undefined"
|
||||
:current-coords="currentCoords"
|
||||
:selected-marker-for-bookmark="selectedMarkerForBookmark"
|
||||
@@ -147,6 +152,7 @@
|
||||
@zoom-in="$emit('zoomIn')"
|
||||
@zoom-out="$emit('zoomOut')"
|
||||
@reset-view="$emit('resetView')"
|
||||
@set-zoom="$emit('setZoom', $event)"
|
||||
@jump-to-marker="$emit('jumpToMarker', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -169,7 +175,7 @@
|
||||
</template>
|
||||
|
||||
<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 MapControlsContent from '~/components/map/MapControlsContent.vue'
|
||||
|
||||
@@ -188,6 +194,8 @@ const props = withDefaults(
|
||||
maps: MapInfo[]
|
||||
questGivers: QuestGiver[]
|
||||
players: Player[]
|
||||
markers?: ApiMarker[]
|
||||
currentZoom?: number
|
||||
currentMapId?: number | null
|
||||
currentCoords?: { x: number; y: number; z: number } | null
|
||||
selectedMarkerForBookmark?: SelectedMarkerForBookmark
|
||||
@@ -196,6 +204,8 @@ const props = withDefaults(
|
||||
maps: () => [],
|
||||
questGivers: () => [],
|
||||
players: () => [],
|
||||
markers: () => [],
|
||||
currentZoom: 1,
|
||||
currentMapId: null,
|
||||
currentCoords: null,
|
||||
selectedMarkerForBookmark: null,
|
||||
@@ -206,6 +216,7 @@ defineEmits<{
|
||||
zoomIn: []
|
||||
zoomOut: []
|
||||
resetView: []
|
||||
setZoom: [level: number]
|
||||
jumpToMarker: [id: number]
|
||||
}>()
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
v-if="currentMapId != null && currentCoords != null"
|
||||
:maps="maps"
|
||||
:quest-givers="questGivers"
|
||||
:markers="markers"
|
||||
:overlay-map-id="props.overlayMapId"
|
||||
:current-map-id="currentMapId"
|
||||
:current-coords="currentCoords"
|
||||
:touch-friendly="touchFriendly"
|
||||
@@ -48,6 +50,19 @@
|
||||
<icons-icon-home />
|
||||
</button>
|
||||
</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>
|
||||
<!-- Display -->
|
||||
<section class="flex flex-col gap-2">
|
||||
@@ -78,7 +93,16 @@
|
||||
</select>
|
||||
</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
|
||||
v-model="overlayMapId"
|
||||
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
|
||||
@@ -89,22 +113,43 @@
|
||||
</select>
|
||||
</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
|
||||
v-if="jumpToTab === 'quest'"
|
||||
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' : ''"
|
||||
aria-label="Select quest giver"
|
||||
>
|
||||
<option value="">Select quest giver</option>
|
||||
<option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label py-0"><span>Jump to Player</span></label>
|
||||
<select
|
||||
v-else
|
||||
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' : ''"
|
||||
aria-label="Select player"
|
||||
>
|
||||
<option value="">Select player</option>
|
||||
<option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option>
|
||||
@@ -126,9 +171,10 @@
|
||||
</template>
|
||||
|
||||
<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 MapBookmarks from '~/components/map/MapBookmarks.vue'
|
||||
import { HnHMinZoom, HnHMaxZoom } from '~/lib/LeafletCustomTypes'
|
||||
|
||||
interface QuestGiver {
|
||||
id: number
|
||||
@@ -140,27 +186,33 @@ interface Player {
|
||||
name: string
|
||||
}
|
||||
|
||||
const zoomMin = HnHMinZoom
|
||||
const zoomMax = HnHMaxZoom
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
maps: MapInfo[]
|
||||
questGivers: QuestGiver[]
|
||||
players: Player[]
|
||||
markers?: ApiMarker[]
|
||||
touchFriendly?: boolean
|
||||
selectedMapIdSelect: string
|
||||
overlayMapId: number
|
||||
selectedMarkerIdSelect: string
|
||||
selectedPlayerIdSelect: string
|
||||
currentZoom?: number
|
||||
currentMapId?: number
|
||||
currentCoords?: { x: number; y: number; z: number } | null
|
||||
selectedMarkerForBookmark?: SelectedMarkerForBookmark
|
||||
}>(),
|
||||
{ touchFriendly: false, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null }
|
||||
{ touchFriendly: false, markers: () => [], currentZoom: 1, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
zoomIn: []
|
||||
zoomOut: []
|
||||
resetView: []
|
||||
setZoom: [level: number]
|
||||
jumpToMarker: [id: number]
|
||||
'update:hideMarkers': [v: boolean]
|
||||
'update:selectedMapIdSelect': [v: string]
|
||||
@@ -169,8 +221,16 @@ const emit = defineEmits<{
|
||||
'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 jumpToTab = ref<'quest' | 'player'>('quest')
|
||||
|
||||
const selectedMapIdSelect = computed({
|
||||
get: () => props.selectedMapIdSelect,
|
||||
set: (v) => emit('update:selectedMapIdSelect', v),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
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"
|
||||
:title="copied ? 'Copied!' : 'Click to copy share link'"
|
||||
role="button"
|
||||
@@ -19,6 +19,7 @@
|
||||
Copied!
|
||||
</span>
|
||||
</span>
|
||||
<icons-icon-copy class="size-4 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -74,11 +74,13 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 mt-0.5">Coords: x, y — or type a marker name.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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 { useRecentLocations } from '~/composables/useRecentLocations'
|
||||
|
||||
@@ -86,11 +88,13 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
maps: MapInfo[]
|
||||
questGivers: Array<{ id: number; name: string }>
|
||||
markers?: ApiMarker[]
|
||||
overlayMapId?: number
|
||||
currentMapId: number
|
||||
currentCoords: { x: number; y: number; z: number } | null
|
||||
touchFriendly?: boolean
|
||||
}>(),
|
||||
{ touchFriendly: false }
|
||||
{ touchFriendly: false, markers: () => [], overlayMapId: -1 }
|
||||
)
|
||||
|
||||
const { goToCoords } = useMapNavigate()
|
||||
@@ -147,22 +151,37 @@ const suggestions = computed<Suggestion[]>(() => {
|
||||
}
|
||||
|
||||
const list: Suggestion[] = []
|
||||
for (const qg of props.questGivers) {
|
||||
if (qg.name.toLowerCase().includes(q)) {
|
||||
const overlayId = props.overlayMapId ?? -1
|
||||
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({
|
||||
key: `qg-${qg.id}`,
|
||||
label: qg.name,
|
||||
mapId: props.currentMapId,
|
||||
x: 0,
|
||||
y: 0,
|
||||
key: `marker-${m.id}`,
|
||||
label: `${m.name} · ${gridX}, ${gridY}`,
|
||||
mapId: m.map,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
zoom: undefined,
|
||||
markerId: qg.id,
|
||||
markerId: m.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (list.length > 0) return list.slice(0, 8)
|
||||
|
||||
return []
|
||||
// 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))
|
||||
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() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character'
|
||||
import {
|
||||
@@ -14,6 +14,15 @@ import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api
|
||||
|
||||
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export interface MapLayersOptions {
|
||||
/** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */
|
||||
L: typeof import('leaflet')
|
||||
@@ -28,6 +37,8 @@ export interface MapLayersOptions {
|
||||
getTrackingCharacterId: () => number
|
||||
setTrackingCharacterId: (id: number) => 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. */
|
||||
resolveIconUrl?: (path: string) => string
|
||||
/** Fallback icon URL when a marker image fails to load. */
|
||||
@@ -62,6 +73,7 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
|
||||
getTrackingCharacterId,
|
||||
setTrackingCharacterId,
|
||||
onMarkerContextMenu,
|
||||
onAddMarkerToBookmark,
|
||||
resolveIconUrl,
|
||||
fallbackIconUrl,
|
||||
} = options
|
||||
@@ -112,7 +124,30 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
|
||||
(marker: MapMarker) => {
|
||||
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
|
||||
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) => {
|
||||
mev.originalEvent.preventDefault()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type L from 'leaflet'
|
||||
import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes'
|
||||
import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
|
||||
export interface MarkerData {
|
||||
id: number
|
||||
@@ -104,7 +104,15 @@ export function createMarker(
|
||||
}
|
||||
|
||||
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)
|
||||
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
|
||||
if (markerEl) markerEl.setAttribute('aria-label', marker.name)
|
||||
@@ -125,6 +133,9 @@ export function createMarker(
|
||||
if (leafletMarker) {
|
||||
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
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}`)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ vi.mock('leaflet', () => {
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
setLatLng: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn().mockReturnThis(),
|
||||
bindTooltip: vi.fn().mockReturnThis(),
|
||||
setTooltipContent: vi.fn().mockReturnThis(),
|
||||
openPopup: vi.fn().mockReturnThis(),
|
||||
closePopup: vi.fn().mockReturnThis(),
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
@@ -19,11 +23,13 @@ vi.mock('leaflet', () => {
|
||||
|
||||
vi.mock('~/lib/LeafletCustomTypes', () => ({
|
||||
HnHMaxZoom: 6,
|
||||
TileSize: 100,
|
||||
ImageIcon: class {
|
||||
constructor(_opts: Record<string, unknown>) {}
|
||||
},
|
||||
}))
|
||||
|
||||
import L from 'leaflet'
|
||||
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
|
||||
|
||||
function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData {
|
||||
@@ -57,7 +63,7 @@ describe('createMarker', () => {
|
||||
})
|
||||
|
||||
it('creates a marker with correct properties', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
expect(marker.id).toBe(1)
|
||||
expect(marker.name).toBe('Tower')
|
||||
expect(marker.position).toEqual({ x: 100, y: 200 })
|
||||
@@ -69,46 +75,46 @@ describe('createMarker', () => {
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('detects custom type', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'custom' }))
|
||||
const marker = createMarker(makeMarkerData({ image: 'custom' }), undefined, L)
|
||||
expect(marker.type).toBe('custom')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('starts with null leaflet marker', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
expect(marker.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('add creates a leaflet marker for non-hidden markers', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
const mapview = makeMapViewRef()
|
||||
marker.add(mapview)
|
||||
expect(mapview.map.unproject).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('add does nothing for hidden markers', () => {
|
||||
const marker = createMarker(makeMarkerData({ hidden: true }))
|
||||
const marker = createMarker(makeMarkerData({ hidden: true }), undefined, L)
|
||||
const mapview = makeMapViewRef()
|
||||
marker.add(mapview)
|
||||
expect(mapview.map.unproject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('update changes position and name', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
const mapview = makeMapViewRef()
|
||||
|
||||
marker.update(mapview, {
|
||||
@@ -122,7 +128,7 @@ describe('createMarker', () => {
|
||||
})
|
||||
|
||||
it('setClickCallback and setContextMenu work', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
const clickCb = vi.fn()
|
||||
const contextCb = vi.fn()
|
||||
|
||||
@@ -131,7 +137,7 @@ describe('createMarker', () => {
|
||||
})
|
||||
|
||||
it('remove on a marker without leaflet marker does nothing', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
const mapview = makeMapViewRef()
|
||||
marker.remove(mapview) // should not throw
|
||||
expect(marker.leafletMarker).toBeNull()
|
||||
|
||||
Reference in New Issue
Block a user