feat(map): add server-side h3 clustering and hover highlight
All checks were successful
Build Docker Image / build (push) Successful in 4m55s
All checks were successful
Build Docker Image / build (push) Successful in 4m55s
- 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
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
<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
|
||||
@@ -26,25 +27,41 @@ interface MapItem {
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface MapBounds {
|
||||
west: number
|
||||
south: number
|
||||
east: number
|
||||
north: number
|
||||
zoom: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
mapId: string
|
||||
items: MapItem[]
|
||||
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
|
||||
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',
|
||||
@@ -54,6 +71,7 @@ const mapOptions = computed(() => ({
|
||||
pitch: 20
|
||||
}))
|
||||
|
||||
// Client-side clustering GeoJSON (when not using server clustering)
|
||||
const geoJsonData = computed(() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: props.items.map(item => ({
|
||||
@@ -63,8 +81,39 @@ const geoJsonData = computed(() => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// 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({
|
||||
@@ -75,103 +124,19 @@ const onMapCreated = (map: MapboxMapType) => {
|
||||
'star-intensity': 0.6
|
||||
})
|
||||
|
||||
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': 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
|
||||
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()) {
|
||||
@@ -181,15 +146,232 @@ const onMapCreated = (map: MapboxMapType) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Update map data when items change
|
||||
watch(geoJsonData, (newData) => {
|
||||
if (!mapRef.value) return
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user