Files
hnh-map/frontend-nuxt/components/map/MapSearch.vue
Nikolay Tatarinov fc42d86ca0 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.
2026-03-04 12:49:31 +03:00

246 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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"
aria-label="Search by coordinates or marker name"
aria-expanded="showDropdown && (suggestions.length > 0 || recent.recent.length > 0)"
aria-haspopup="listbox"
aria-controls="map-search-listbox"
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)"
id="map-search-listbox"
role="listbox"
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"
:aria-activedescendant="suggestions.length > 0 ? `map-search-opt-${highlightIndex}` : recent.recent.length > 0 ? `map-search-recent-${highlightIndex}` : undefined"
>
<template v-if="suggestions.length > 0">
<button
v-for="(s, i) in suggestions"
:id="`map-search-opt-${i}`"
:key="s.key"
type="button"
role="option"
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 }"
:aria-selected="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"
:id="`map-search-recent-${i}`"
:key="r.at"
type="button"
role="option"
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 }"
:aria-selected="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>
<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, Marker as ApiMarker } from '~/types/api'
import { TileSize } from '~/lib/LeafletCustomTypes'
import { useMapNavigate } from '~/composables/useMapNavigate'
import { useRecentLocations } from '~/composables/useRecentLocations'
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, markers: () => [], overlayMapId: -1 }
)
const { goToCoords } = useMapNavigate()
const recent = useRecentLocations()
const inputRef = ref<HTMLInputElement | null>(null)
const query = ref('')
const queryDebounced = ref('')
let queryDebounceId: ReturnType<typeof setTimeout> | null = null
const SEARCH_DEBOUNCE_MS = 180
watch(
query,
(v) => {
if (queryDebounceId) clearTimeout(queryDebounceId)
queryDebounceId = setTimeout(() => {
queryDebounced.value = v
queryDebounceId = null
}, SEARCH_DEBOUNCE_MS)
},
{ immediate: true }
)
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 = queryDebounced.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[] = []
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: `marker-${m.id}`,
label: `${m.name} · ${gridX}, ${gridY}`,
mapId: m.map,
x: gridX,
y: gridY,
zoom: undefined,
markerId: m.id,
})
}
}
// 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() {
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]
}>()
watch(suggestions, () => {
highlightIndex.value = 0
}, { flush: 'sync' })
onMounted(() => {
useMapNavigate().setFocusSearch(() => inputRef.value?.focus())
})
onBeforeUnmount(() => {
if (queryDebounceId) clearTimeout(queryDebounceId)
})
onBeforeUnmount(() => {
if (closeDropdownTimer) clearTimeout(closeDropdownTimer)
useMapNavigate().setFocusSearch(null)
})
</script>