Add map improvements: hover highlight, fitBounds, search checkbox
All checks were successful
Build Docker Image / build (push) Successful in 5m45s
All checks were successful
Build Docker Image / build (push) Successful in 5m45s
This commit is contained in:
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"catalogMap": {
|
"catalogMap": {
|
||||||
|
"searchWithMap": "Искать на карте",
|
||||||
"states": {
|
"states": {
|
||||||
"loading": "Загружаем локации...",
|
"loading": "Загружаем локации...",
|
||||||
"no_hubs": "Нет доступных хабов",
|
"no_hubs": "Нет доступных хабов",
|
||||||
|
|||||||
Reference in New Issue
Block a user