Add map improvements: hover highlight, fitBounds, search checkbox
All checks were successful
Build Docker Image / build (push) Successful in 5m45s

This commit is contained in:
Ruslan Bakiev
2026-01-14 12:09:15 +07:00
parent c458f851dc
commit a493d2cf01
4 changed files with 104 additions and 19 deletions

View File

@@ -35,12 +35,18 @@ export interface MapBounds {
zoom: number zoom: number
} }
interface HoveredItem {
latitude: number
longitude: number
}
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
mapId: string mapId: string
items?: MapItem[] items?: MapItem[]
clusteredPoints?: ClusterPointType[] clusteredPoints?: ClusterPointType[]
useServerClustering?: boolean useServerClustering?: boolean
hoveredItemId?: string | null hoveredItemId?: string | null
hoveredItem?: HoveredItem | null
pointColor?: string pointColor?: string
initialCenter?: [number, number] initialCenter?: [number, number]
initialZoom?: number initialZoom?: number
@@ -100,7 +106,21 @@ const serverClusteredGeoJson = computed(() => ({
})) }))
})) }))
// Hovered point GeoJSON (separate layer on top)
const hoveredPointGeoJson = computed(() => ({
type: 'FeatureCollection' as const,
features: props.hoveredItem ? [{
type: 'Feature' as const,
properties: {},
geometry: {
type: 'Point' as const,
coordinates: [props.hoveredItem.longitude, props.hoveredItem.latitude]
}
}] : []
}))
const sourceId = computed(() => `${props.mapId}-points`) const sourceId = computed(() => `${props.mapId}-points`)
const hoveredSourceId = computed(() => `${props.mapId}-hovered`)
const emitBoundsChange = (map: MapboxMapType) => { const emitBoundsChange = (map: MapboxMapType) => {
const bounds = map.getBounds() const bounds = map.getBounds()
@@ -344,6 +364,23 @@ const initServerClusteringLayers = (map: MapboxMapType) => {
map.on('mouseleave', 'server-clusters', () => { map.getCanvas().style.cursor = '' }) map.on('mouseleave', 'server-clusters', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'server-points', () => { map.getCanvas().style.cursor = 'pointer' }) map.on('mouseenter', 'server-points', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'server-points', () => { map.getCanvas().style.cursor = '' }) map.on('mouseleave', 'server-points', () => { map.getCanvas().style.cursor = '' })
// Hovered point layer (on top of everything)
map.addSource(hoveredSourceId.value, {
type: 'geojson',
data: hoveredPointGeoJson.value
})
map.addLayer({
id: 'hovered-point-layer',
type: 'circle',
source: hoveredSourceId.value,
paint: {
'circle-radius': 14,
'circle-color': '#3b82f6',
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
}
})
} }
// Update map data when items or clusteredPoints change // Update map data when items or clusteredPoints change
@@ -355,22 +392,31 @@ watch(() => props.useServerClustering ? serverClusteredGeoJson.value : geoJsonDa
} }
}, { deep: true }) }, { deep: true })
// Update hover paint when hoveredItemId changes // Update hovered point layer when hoveredItem changes
watch(() => props.hoveredItemId, (newId) => { watch(() => props.hoveredItem, () => {
if (!mapRef.value || !mapInitialized.value) return if (!mapRef.value || !mapInitialized.value) return
const source = mapRef.value.getSource(hoveredSourceId.value) as mapboxgl.GeoJSONSource | undefined
const layerId = props.useServerClustering ? 'server-points' : 'unclustered-point' if (source) {
const propName = props.useServerClustering ? 'id' : 'uuid' source.setData(hoveredPointGeoJson.value)
if (mapRef.value.getLayer(layerId)) {
mapRef.value.setPaintProperty(layerId, 'circle-color', [
'case',
['==', ['get', propName], newId || ''],
'#facc15',
props.pointColor
])
} }
}) }, { deep: true })
// fitBounds for server clustering when first data arrives
watch(() => props.clusteredPoints, (points) => {
if (!mapRef.value || !mapInitialized.value) return
if (!didFitBounds.value && points && points.length > 0) {
const bounds = new LngLatBounds()
points.forEach(p => {
if (p?.longitude && p?.latitude) {
bounds.extend([p.longitude, p.latitude])
}
})
if (!bounds.isEmpty()) {
mapRef.value.fitBounds(bounds, { padding: 50, maxZoom: 6 })
didFitBounds.value = true
}
}
}, { immediate: true })
// Expose flyTo method for external use (with space fly animation) // Expose flyTo method for external use (with space fly animation)
const flyTo = async (lat: number, lng: number, zoom = 8) => { const flyTo = async (lat: number, lng: number, zoom = 8) => {

View File

@@ -24,7 +24,7 @@
<Stack gap="3"> <Stack gap="3">
<div <div
v-for="item in items" v-for="item in displayItems"
:key="item.uuid" :key="item.uuid"
:class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedId }" :class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedId }"
@click="onItemClick(item)" @click="onItemClick(item)"
@@ -37,7 +37,7 @@
<slot name="pagination" /> <slot name="pagination" />
<Stack v-if="items.length === 0" align="center" gap="2"> <Stack v-if="displayItems.length === 0" align="center" gap="2">
<slot name="empty"> <slot name="empty">
<Text tone="muted">{{ $t('common.values.not_available') }}</Text> <Text tone="muted">{{ $t('common.values.not_available') }}</Text>
</slot> </slot>
@@ -48,6 +48,11 @@
<!-- Right: Map (fixed position) --> <!-- Right: Map (fixed position) -->
<div class="w-3/5 relative"> <div class="w-3/5 relative">
<div class="fixed right-6 w-[calc(60%-3rem)] rounded-lg overflow-hidden" :class="[mapTopClass, mapHeightClass]"> <div class="fixed right-6 w-[calc(60%-3rem)] rounded-lg overflow-hidden" :class="[mapTopClass, mapHeightClass]">
<!-- Search with map checkbox -->
<label class="absolute top-4 left-4 z-10 bg-white/90 backdrop-blur px-3 py-2 rounded-lg shadow flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="searchWithMap" class="checkbox checkbox-sm" />
<span class="text-sm">{{ $t('catalogMap.searchWithMap') }}</span>
</label>
<ClientOnly> <ClientOnly>
<CatalogMap <CatalogMap
ref="mapRef" ref="mapRef"
@@ -57,6 +62,7 @@
:use-server-clustering="useServerClustering" :use-server-clustering="useServerClustering"
:point-color="pointColor" :point-color="pointColor"
:hovered-item-id="hoveredId" :hovered-item-id="hoveredId"
:hovered-item="hoveredItem"
@select-item="onMapSelect" @select-item="onMapSelect"
@bounds-change="onBoundsChange" @bounds-change="onBoundsChange"
/> />
@@ -74,7 +80,7 @@
<Stack gap="3"> <Stack gap="3">
<div <div
v-for="item in items" v-for="item in displayItems"
:key="item.uuid" :key="item.uuid"
:class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedId }" :class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedId }"
@click="onItemClick(item)" @click="onItemClick(item)"
@@ -87,7 +93,7 @@
<slot name="pagination" /> <slot name="pagination" />
<Stack v-if="items.length === 0" align="center" gap="2"> <Stack v-if="displayItems.length === 0" align="center" gap="2">
<slot name="empty"> <slot name="empty">
<Text tone="muted">{{ $t('common.values.not_available') }}</Text> <Text tone="muted">{{ $t('common.values.not_available') }}</Text>
</slot> </slot>
@@ -95,7 +101,12 @@
</Stack> </Stack>
</div> </div>
<div class="flex-1" v-show="mobileView === 'map'"> <div class="flex-1 relative" v-show="mobileView === 'map'">
<!-- Search with map checkbox (mobile) -->
<label class="absolute top-4 left-4 z-10 bg-white/90 backdrop-blur px-3 py-2 rounded-lg shadow flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="searchWithMap" class="checkbox checkbox-sm" />
<span class="text-sm">{{ $t('catalogMap.searchWithMap') }}</span>
</label>
<ClientOnly> <ClientOnly>
<CatalogMap <CatalogMap
ref="mobileMapRef" ref="mobileMapRef"
@@ -105,6 +116,7 @@
:use-server-clustering="useServerClustering" :use-server-clustering="useServerClustering"
:point-color="pointColor" :point-color="pointColor"
:hovered-item-id="hoveredId" :hovered-item-id="hoveredId"
:hovered-item="hoveredItem"
@select-item="onMapSelect" @select-item="onMapSelect"
@bounds-change="onBoundsChange" @bounds-change="onBoundsChange"
/> />
@@ -207,12 +219,37 @@ const emit = defineEmits<{
// Server-side clustering // Server-side clustering
const { clusteredNodes, fetchClusters } = useClusteredNodes() const { clusteredNodes, fetchClusters } = useClusteredNodes()
// Search with map checkbox
const searchWithMap = ref(false)
const currentBounds = ref<MapBounds | null>(null)
const onBoundsChange = (bounds: MapBounds) => { const onBoundsChange = (bounds: MapBounds) => {
currentBounds.value = bounds
if (props.useServerClustering) { if (props.useServerClustering) {
fetchClusters(bounds) fetchClusters(bounds)
} }
} }
// Filtered items when searchWithMap is enabled
const displayItems = computed(() => {
if (!searchWithMap.value || !currentBounds.value) return props.items
return props.items.filter(item => {
if (item.latitude == null || item.longitude == null) return false
const { west, east, north, south } = currentBounds.value!
const lng = Number(item.longitude)
const lat = Number(item.latitude)
return lng >= west && lng <= east && lat >= south && lat <= north
})
})
// Hovered item with coordinates for map highlight
const hoveredItem = computed(() => {
if (!props.hoveredId) return null
const item = props.items.find(i => i.uuid === props.hoveredId)
if (!item?.latitude || !item?.longitude) return null
return { latitude: Number(item.latitude), longitude: Number(item.longitude) }
})
// Use mapItems if provided, otherwise fall back to items // Use mapItems if provided, otherwise fall back to items
const itemsForMap = computed(() => props.mapItems || props.items) const itemsForMap = computed(() => props.mapItems || props.items)

View File

@@ -1,5 +1,6 @@
{ {
"catalogMap": { "catalogMap": {
"searchWithMap": "Search as map moves",
"states": { "states": {
"loading": "Loading locations...", "loading": "Loading locations...",
"no_hubs": "No hubs available", "no_hubs": "No hubs available",

View File

@@ -1,5 +1,6 @@
{ {
"catalogMap": { "catalogMap": {
"searchWithMap": "Искать на карте",
"states": { "states": {
"loading": "Загружаем локации...", "loading": "Загружаем локации...",
"no_hubs": "Нет доступных хабов", "no_hubs": "Нет доступных хабов",