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
}
interface HoveredItem {
latitude: number
longitude: number
}
const props = withDefaults(defineProps<{
mapId: string
items?: MapItem[]
clusteredPoints?: ClusterPointType[]
useServerClustering?: boolean
hoveredItemId?: string | null
hoveredItem?: HoveredItem | null
pointColor?: string
initialCenter?: [number, 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 hoveredSourceId = computed(() => `${props.mapId}-hovered`)
const emitBoundsChange = (map: MapboxMapType) => {
const bounds = map.getBounds()
@@ -344,6 +364,23 @@ const initServerClusteringLayers = (map: MapboxMapType) => {
map.on('mouseleave', 'server-clusters', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'server-points', () => { map.getCanvas().style.cursor = 'pointer' })
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
@@ -355,22 +392,31 @@ watch(() => props.useServerClustering ? serverClusteredGeoJson.value : geoJsonDa
}
}, { deep: true })
// Update hover paint when hoveredItemId changes
watch(() => props.hoveredItemId, (newId) => {
// Update hovered point layer when hoveredItem changes
watch(() => props.hoveredItem, () => {
if (!mapRef.value || !mapInitialized.value) return
const layerId = props.useServerClustering ? 'server-points' : 'unclustered-point'
const propName = props.useServerClustering ? 'id' : 'uuid'
if (mapRef.value.getLayer(layerId)) {
mapRef.value.setPaintProperty(layerId, 'circle-color', [
'case',
['==', ['get', propName], newId || ''],
'#facc15',
props.pointColor
])
const source = mapRef.value.getSource(hoveredSourceId.value) as mapboxgl.GeoJSONSource | undefined
if (source) {
source.setData(hoveredPointGeoJson.value)
}
})
}, { 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)
const flyTo = async (lat: number, lng: number, zoom = 8) => {

View File

@@ -24,7 +24,7 @@
<Stack gap="3">
<div
v-for="item in items"
v-for="item in displayItems"
:key="item.uuid"
:class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedId }"
@click="onItemClick(item)"
@@ -37,7 +37,7 @@
<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">
<Text tone="muted">{{ $t('common.values.not_available') }}</Text>
</slot>
@@ -48,6 +48,11 @@
<!-- Right: Map (fixed position) -->
<div class="w-3/5 relative">
<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>
<CatalogMap
ref="mapRef"
@@ -57,6 +62,7 @@
:use-server-clustering="useServerClustering"
:point-color="pointColor"
:hovered-item-id="hoveredId"
:hovered-item="hoveredItem"
@select-item="onMapSelect"
@bounds-change="onBoundsChange"
/>
@@ -74,7 +80,7 @@
<Stack gap="3">
<div
v-for="item in items"
v-for="item in displayItems"
:key="item.uuid"
:class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedId }"
@click="onItemClick(item)"
@@ -87,7 +93,7 @@
<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">
<Text tone="muted">{{ $t('common.values.not_available') }}</Text>
</slot>
@@ -95,7 +101,12 @@
</Stack>
</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>
<CatalogMap
ref="mobileMapRef"
@@ -105,6 +116,7 @@
:use-server-clustering="useServerClustering"
:point-color="pointColor"
:hovered-item-id="hoveredId"
:hovered-item="hoveredItem"
@select-item="onMapSelect"
@bounds-change="onBoundsChange"
/>
@@ -207,12 +219,37 @@ const emit = defineEmits<{
// Server-side clustering
const { clusteredNodes, fetchClusters } = useClusteredNodes()
// Search with map checkbox
const searchWithMap = ref(false)
const currentBounds = ref<MapBounds | null>(null)
const onBoundsChange = (bounds: MapBounds) => {
currentBounds.value = bounds
if (props.useServerClustering) {
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
const itemsForMap = computed(() => props.mapItems || props.items)