Files
webapp/app/components/catalog/CatalogMap.vue
Ruslan Bakiev 844878ce85
All checks were successful
Build Docker Image / build (push) Successful in 4m55s
feat(map): add server-side h3 clustering and hover highlight
- Add useClusteredNodes composable for fetching clustered nodes
- Update CatalogMap to support server-side clustering mode
- Add bounds-change event for fetching clusters on map move/zoom
- Add hover event to HubCard for marker highlighting
- Update hubs/map page to use new clustering system
2026-01-14 10:34:44 +07:00

388 lines
10 KiB
Vue

<template>
<div class="flex flex-col flex-1 min-h-0 w-full h-full">
<ClientOnly>
<MapboxMap
:map-id="mapId"
class="flex-1 min-h-0"
style="width: 100%; height: 100%"
:options="mapOptions"
@load="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds } from 'mapbox-gl'
import type { ClusterPointType } from '~/composables/graphql/public/geo-generated'
interface MapItem {
uuid: string
name: string
latitude: number
longitude: number
country?: string
}
export interface MapBounds {
west: number
south: number
east: number
north: number
zoom: number
}
const props = withDefaults(defineProps<{
mapId: string
items?: MapItem[]
clusteredPoints?: ClusterPointType[]
useServerClustering?: boolean
hoveredItemId?: string | null
pointColor?: string
initialCenter?: [number, number]
initialZoom?: number
}>(), {
pointColor: '#10b981',
initialCenter: () => [37.64, 55.76],
initialZoom: 2,
useServerClustering: false,
items: () => [],
clusteredPoints: () => []
})
const emit = defineEmits<{
'select-item': [uuid: string]
'bounds-change': [bounds: MapBounds]
}>()
const mapRef = useMapboxRef(props.mapId)
const { flyThroughSpace } = useMapboxFlyAnimation()
const didFitBounds = ref(false)
const mapInitialized = ref(false)
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/satellite-streets-v12',
center: props.initialCenter,
zoom: props.initialZoom,
projection: 'globe',
pitch: 20
}))
// Client-side clustering GeoJSON (when not using server clustering)
const geoJsonData = computed(() => ({
type: 'FeatureCollection' as const,
features: props.items.map(item => ({
type: 'Feature' as const,
properties: { uuid: item.uuid, name: item.name, country: item.country },
geometry: { type: 'Point' as const, coordinates: [item.longitude, item.latitude] }
}))
}))
// Server-side clustering GeoJSON
const serverClusteredGeoJson = computed(() => ({
type: 'FeatureCollection' as const,
features: (props.clusteredPoints || []).filter(Boolean).map(point => ({
type: 'Feature' as const,
properties: {
id: point!.id,
name: point!.name,
count: point!.count ?? 1,
expansionZoom: point!.expansionZoom,
isCluster: (point!.count ?? 1) > 1
},
geometry: {
type: 'Point' as const,
coordinates: [point!.longitude ?? 0, point!.latitude ?? 0]
}
}))
}))
const sourceId = computed(() => `${props.mapId}-points`)
const emitBoundsChange = (map: MapboxMapType) => {
const bounds = map.getBounds()
if (!bounds) return
emit('bounds-change', {
west: bounds.getWest(),
south: bounds.getSouth(),
east: bounds.getEast(),
north: bounds.getNorth(),
zoom: map.getZoom()
})
}
const onMapCreated = (map: MapboxMapType) => {
const initMap = () => {
map.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.02,
'space-color': 'rgb(11, 11, 25)',
'star-intensity': 0.6
})
if (props.useServerClustering) {
initServerClusteringLayers(map)
} else {
initClientClusteringLayers(map)
}
// Emit initial bounds
emitBoundsChange(map)
// Emit bounds on move/zoom end
map.on('moveend', () => emitBoundsChange(map))
mapInitialized.value = true
}
if (map.loaded()) {
initMap()
} else {
map.on('load', initMap)
}
}
const initClientClusteringLayers = (map: MapboxMapType) => {
map.addSource(sourceId.value, {
type: 'geojson',
data: geoJsonData.value,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
})
map.addLayer({
id: 'clusters',
type: 'circle',
source: sourceId.value,
filter: ['has', 'point_count'],
paint: {
'circle-color': props.pointColor,
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 50, 40],
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: sourceId.value,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 14
},
paint: { 'text-color': '#ffffff' }
})
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: sourceId.value,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 12,
'circle-color': [
'case',
['==', ['get', 'uuid'], props.hoveredItemId || ''],
'#facc15', // yellow when hovered
props.pointColor
],
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'point-labels',
type: 'symbol',
source: sourceId.value,
filter: ['!', ['has', 'point_count']],
layout: {
'text-field': ['get', 'name'],
'text-offset': [0, 1.5],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold']
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1.5
}
})
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
if (!features.length) return
const clusterId = features[0].properties?.cluster_id
const source = map.getSource(sourceId.value) as mapboxgl.GeoJSONSource
source.getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return
const geometry = features[0].geometry as GeoJSON.Point
map.easeTo({ center: geometry.coordinates as [number, number], zoom: zoom || 4 })
})
})
map.on('click', 'unclustered-point', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point'] })
if (!features.length) return
emit('select-item', features[0].properties?.uuid)
})
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'unclustered-point', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'unclustered-point', () => { map.getCanvas().style.cursor = '' })
// Auto-fit bounds to all items
if (!didFitBounds.value && props.items.length > 0) {
const bounds = new LngLatBounds()
props.items.forEach(item => {
bounds.extend([item.longitude, item.latitude])
})
map.fitBounds(bounds, { padding: 50, maxZoom: 10 })
didFitBounds.value = true
}
}
const initServerClusteringLayers = (map: MapboxMapType) => {
map.addSource(sourceId.value, {
type: 'geojson',
data: serverClusteredGeoJson.value
})
// Clusters (count > 1)
map.addLayer({
id: 'server-clusters',
type: 'circle',
source: sourceId.value,
filter: ['>', ['get', 'count'], 1],
paint: {
'circle-color': props.pointColor,
'circle-radius': ['step', ['get', 'count'], 20, 10, 30, 50, 40],
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'server-cluster-count',
type: 'symbol',
source: sourceId.value,
filter: ['>', ['get', 'count'], 1],
layout: {
'text-field': ['get', 'count'],
'text-size': 14
},
paint: { 'text-color': '#ffffff' }
})
// Individual points (count == 1)
map.addLayer({
id: 'server-points',
type: 'circle',
source: sourceId.value,
filter: ['==', ['get', 'count'], 1],
paint: {
'circle-radius': 12,
'circle-color': [
'case',
['==', ['get', 'id'], props.hoveredItemId || ''],
'#facc15', // yellow when hovered
props.pointColor
],
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'server-point-labels',
type: 'symbol',
source: sourceId.value,
filter: ['==', ['get', 'count'], 1],
layout: {
'text-field': ['get', 'name'],
'text-offset': [0, 1.5],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold']
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1.5
}
})
// Click on cluster to zoom in
map.on('click', 'server-clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['server-clusters'] })
if (!features.length) return
const expansionZoom = features[0].properties?.expansionZoom
const geometry = features[0].geometry as GeoJSON.Point
map.easeTo({
center: geometry.coordinates as [number, number],
zoom: expansionZoom || map.getZoom() + 2
})
})
// Click on individual point
map.on('click', 'server-points', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['server-points'] })
if (!features.length) return
emit('select-item', features[0].properties?.id)
})
map.on('mouseenter', 'server-clusters', () => { map.getCanvas().style.cursor = 'pointer' })
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 = '' })
}
// Update map data when items or clusteredPoints change
watch(() => props.useServerClustering ? serverClusteredGeoJson.value : geoJsonData.value, (newData) => {
if (!mapRef.value || !mapInitialized.value) return
const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined
if (source) {
source.setData(newData)
}
}, { deep: true })
// Update hover paint when hoveredItemId changes
watch(() => props.hoveredItemId, (newId) => {
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
])
}
})
// Expose flyTo method for external use (with space fly animation)
const flyTo = async (lat: number, lng: number, zoom = 8) => {
if (!mapRef.value) return
await flyThroughSpace(mapRef.value, {
targetCenter: [lng, lat],
targetZoom: zoom,
totalDuration: 5000,
minZoom: 3
})
}
defineExpose({ flyTo })
</script>