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
|
||||
}
|
||||
|
||||
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 source = mapRef.value.getSource(hoveredSourceId.value) as mapboxgl.GeoJSONSource | undefined
|
||||
if (source) {
|
||||
source.setData(hoveredPointGeoJson.value)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
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
|
||||
])
|
||||
// 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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"catalogMap": {
|
||||
"searchWithMap": "Search as map moves",
|
||||
"states": {
|
||||
"loading": "Loading locations...",
|
||||
"no_hubs": "No hubs available",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"catalogMap": {
|
||||
"searchWithMap": "Искать на карте",
|
||||
"states": {
|
||||
"loading": "Загружаем локации...",
|
||||
"no_hubs": "Нет доступных хабов",
|
||||
|
||||
Reference in New Issue
Block a user