193 lines
5.1 KiB
Vue
193 lines
5.1 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'
|
|
|
|
interface MapItem {
|
|
uuid: string
|
|
name: string
|
|
latitude: number
|
|
longitude: number
|
|
country?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
mapId: string
|
|
items: MapItem[]
|
|
pointColor?: string
|
|
initialCenter?: [number, number]
|
|
initialZoom?: number
|
|
}>(), {
|
|
pointColor: '#10b981',
|
|
initialCenter: () => [37.64, 55.76],
|
|
initialZoom: 2
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'select-item': [uuid: string]
|
|
}>()
|
|
|
|
const mapRef = useMapboxRef(props.mapId)
|
|
|
|
const mapOptions = computed(() => ({
|
|
style: 'mapbox://styles/mapbox/satellite-streets-v12',
|
|
center: props.initialCenter,
|
|
zoom: props.initialZoom,
|
|
projection: 'globe',
|
|
pitch: 20
|
|
}))
|
|
|
|
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] }
|
|
}))
|
|
}))
|
|
|
|
const sourceId = computed(() => `${props.mapId}-points`)
|
|
|
|
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
|
|
})
|
|
|
|
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 = '' })
|
|
}
|
|
|
|
if (map.loaded()) {
|
|
initMap()
|
|
} else {
|
|
map.on('load', initMap)
|
|
}
|
|
}
|
|
|
|
// Update map data when items change
|
|
watch(geoJsonData, (newData) => {
|
|
if (!mapRef.value) return
|
|
const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined
|
|
if (source) {
|
|
source.setData(newData)
|
|
}
|
|
}, { deep: true })
|
|
|
|
// Expose flyTo method for external use
|
|
const flyTo = (lat: number, lng: number, zoom = 8) => {
|
|
if (!mapRef.value) return
|
|
mapRef.value.easeTo({
|
|
center: [lng, lat],
|
|
zoom,
|
|
duration: 2000,
|
|
easing: (t) => t * (2 - t)
|
|
})
|
|
}
|
|
|
|
defineExpose({ flyTo })
|
|
</script>
|