391 lines
11 KiB
Vue
391 lines
11 KiB
Vue
<template>
|
|
<div class="relative w-full rounded-box overflow-hidden" :style="{ height: computedHeight }">
|
|
<MapboxMap
|
|
:map-id="mapId"
|
|
style="width: 100%; height: 100%"
|
|
:options="mapOptions"
|
|
@mb-created="onMapCreated"
|
|
>
|
|
<MapboxNavigationControl position="top-right" />
|
|
</MapboxMap>
|
|
|
|
<!-- Location info popup -->
|
|
<Transition
|
|
enter-active-class="transition ease-out duration-200"
|
|
enter-from-class="opacity-0 translate-y-2"
|
|
enter-to-class="opacity-100 translate-y-0"
|
|
leave-active-class="transition ease-in duration-150"
|
|
leave-from-class="opacity-100 translate-y-0"
|
|
leave-to-class="opacity-0 translate-y-2"
|
|
>
|
|
<div
|
|
v-if="selectedLocation"
|
|
class="absolute bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-base-100 border border-base-300 rounded-box shadow-lg p-4 z-10"
|
|
>
|
|
<Stack gap="3">
|
|
<Stack direction="row" align="center" justify="between">
|
|
<Stack gap="1">
|
|
<Heading :level="4" weight="semibold">{{ selectedLocation.name }}</Heading>
|
|
<Text tone="muted" size="base">{{ selectedLocation.country }}</Text>
|
|
</Stack>
|
|
<button
|
|
class="p-1 text-base-content/60 hover:text-base-content transition-colors"
|
|
@click="selectedLocation = null"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</Stack>
|
|
<Button size="small" @click="flyToSelectedLocation">
|
|
Fly here
|
|
</Button>
|
|
</Stack>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Animation indicator -->
|
|
<Transition
|
|
enter-active-class="transition ease-out duration-200"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition ease-in duration-150"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div
|
|
v-if="isAnimating"
|
|
class="absolute top-4 left-1/2 -translate-x-1/2 bg-base-content/70 text-base-100 px-4 py-2 rounded-full text-sm flex items-center gap-2"
|
|
>
|
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
Flying...
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Map as MapboxMap } from 'mapbox-gl'
|
|
|
|
interface Location {
|
|
uuid?: string | null
|
|
name?: string | null
|
|
latitude?: number | null
|
|
longitude?: number | null
|
|
country?: string | null
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
locations: Location[]
|
|
height?: number | string
|
|
initialCenter?: [number, number]
|
|
initialZoom?: number
|
|
selectedLocationId?: string | null
|
|
mapId?: string
|
|
}>(), {
|
|
height: 500,
|
|
initialCenter: () => [37.64, 55.76], // Moscow
|
|
initialZoom: 2,
|
|
selectedLocationId: null,
|
|
mapId: 'globe-map'
|
|
})
|
|
|
|
// Compute height: if number add px, else use as provided
|
|
const computedHeight = computed(() => {
|
|
if (typeof props.height === 'number') {
|
|
return `${props.height}px`
|
|
}
|
|
return props.height
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'location-click', location: Location): void
|
|
}>()
|
|
|
|
// useMapboxRef gives reactive map ref when ready
|
|
const mapRef = useMapboxRef(props.mapId)
|
|
const selectedLocation = ref<Location | null>(null)
|
|
|
|
// Promise to wait for map readiness
|
|
let mapReadyResolve: (() => void) | null = null
|
|
const mapReadyPromise = new Promise<void>((resolve) => {
|
|
mapReadyResolve = resolve
|
|
})
|
|
const isMapReady = ref(false)
|
|
|
|
const { flyThroughSpace, setupGlobeAtmosphere, isAnimating } = useMapboxFlyAnimation()
|
|
|
|
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.locations
|
|
.filter(loc => loc.latitude != null && loc.longitude != null)
|
|
.map(location => ({
|
|
type: 'Feature' as const,
|
|
properties: {
|
|
uuid: location.uuid,
|
|
name: location.name,
|
|
country: location.country
|
|
},
|
|
geometry: {
|
|
type: 'Point' as const,
|
|
coordinates: [location.longitude!, location.latitude!]
|
|
}
|
|
}))
|
|
}))
|
|
|
|
const onMapCreated = (map: MapboxMap) => {
|
|
console.log('[MapboxGlobe] onMapCreated, map.loaded():', map.loaded())
|
|
|
|
const initMap = () => {
|
|
console.log('[MapboxGlobe] initMap called')
|
|
|
|
// Setup globe atmosphere with stars
|
|
setupGlobeAtmosphere(map)
|
|
|
|
console.log('[MapboxGlobe] Map loaded, locations:', props.locations.length)
|
|
console.log('[MapboxGlobe] GeoJSON features:', geoJsonData.value.features.length)
|
|
|
|
// Add clustered source
|
|
map.addSource('locations', {
|
|
type: 'geojson',
|
|
data: geoJsonData.value,
|
|
cluster: true,
|
|
clusterMaxZoom: 14,
|
|
clusterRadius: 50
|
|
})
|
|
|
|
// Cluster circles
|
|
map.addLayer({
|
|
id: 'clusters',
|
|
type: 'circle',
|
|
source: 'locations',
|
|
filter: ['has', 'point_count'],
|
|
paint: {
|
|
'circle-color': '#10b981',
|
|
'circle-radius': [
|
|
'step',
|
|
['get', 'point_count'],
|
|
20, 10,
|
|
30, 50,
|
|
40
|
|
],
|
|
'circle-opacity': 0.8,
|
|
'circle-stroke-width': 2,
|
|
'circle-stroke-color': '#ffffff'
|
|
}
|
|
})
|
|
|
|
// Cluster count labels
|
|
map.addLayer({
|
|
id: 'cluster-count',
|
|
type: 'symbol',
|
|
source: 'locations',
|
|
filter: ['has', 'point_count'],
|
|
layout: {
|
|
'text-field': ['get', 'point_count_abbreviated'],
|
|
'text-size': 14
|
|
},
|
|
paint: {
|
|
'text-color': '#ffffff'
|
|
}
|
|
})
|
|
|
|
// Individual points
|
|
map.addLayer({
|
|
id: 'unclustered-point',
|
|
type: 'circle',
|
|
source: 'locations',
|
|
filter: ['!', ['has', 'point_count']],
|
|
paint: {
|
|
'circle-radius': [
|
|
'case',
|
|
['==', ['get', 'uuid'], props.selectedLocationId || '__NONE__'],
|
|
14,
|
|
10
|
|
],
|
|
'circle-color': [
|
|
'case',
|
|
['==', ['get', 'uuid'], props.selectedLocationId || '__NONE__'],
|
|
'#f59e0b',
|
|
'#10b981'
|
|
],
|
|
'circle-stroke-width': 2,
|
|
'circle-stroke-color': '#ffffff'
|
|
}
|
|
})
|
|
|
|
// Click on cluster to zoom in
|
|
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('locations') 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
|
|
})
|
|
})
|
|
})
|
|
|
|
// Click on individual point
|
|
map.on('click', 'unclustered-point', (e) => {
|
|
const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point'] })
|
|
if (!features.length) return
|
|
|
|
const featureProps = features[0].properties
|
|
const geometry = features[0].geometry as GeoJSON.Point
|
|
|
|
const location: Location = {
|
|
uuid: featureProps?.uuid,
|
|
name: featureProps?.name,
|
|
country: featureProps?.country,
|
|
longitude: geometry.coordinates[0],
|
|
latitude: geometry.coordinates[1]
|
|
}
|
|
|
|
selectedLocation.value = location
|
|
emit('location-click', location)
|
|
})
|
|
|
|
// Cursor changes
|
|
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 already loaded - init immediately, otherwise wait for load
|
|
if (map.loaded()) {
|
|
initMap()
|
|
isMapReady.value = true
|
|
mapReadyResolve?.()
|
|
} else {
|
|
map.on('load', () => {
|
|
initMap()
|
|
isMapReady.value = true
|
|
mapReadyResolve?.()
|
|
})
|
|
}
|
|
}
|
|
|
|
const flyToSelectedLocation = async () => {
|
|
if (!mapRef.value || !selectedLocation.value) return
|
|
|
|
const { longitude, latitude } = selectedLocation.value
|
|
if (longitude == null || latitude == null) return
|
|
|
|
await flyThroughSpace(mapRef.value, {
|
|
targetCenter: [longitude, latitude],
|
|
targetZoom: 8,
|
|
totalDuration: 6000
|
|
})
|
|
|
|
selectedLocation.value = null
|
|
}
|
|
|
|
// Update source data when locations change
|
|
watch(geoJsonData, (newData) => {
|
|
if (!mapRef.value) return
|
|
|
|
const source = mapRef.value.getSource('locations') as mapboxgl.GeoJSONSource | undefined
|
|
if (source) {
|
|
source.setData(newData)
|
|
}
|
|
}, { deep: true })
|
|
|
|
// Update marker styles when selectedLocationId changes
|
|
watch(() => props.selectedLocationId, (newId) => {
|
|
if (!mapRef.value || !isMapReady.value) return
|
|
|
|
const map = mapRef.value
|
|
|
|
// Check if layer exists before updating
|
|
if (!map.getLayer('unclustered-point')) return
|
|
|
|
// Update circle radius
|
|
map.setPaintProperty('unclustered-point', 'circle-radius', [
|
|
'case',
|
|
['==', ['get', 'uuid'], newId || '__NONE__'],
|
|
14,
|
|
10
|
|
])
|
|
|
|
// Update circle color
|
|
map.setPaintProperty('unclustered-point', 'circle-color', [
|
|
'case',
|
|
['==', ['get', 'uuid'], newId || '__NONE__'],
|
|
'#f59e0b',
|
|
'#10b981'
|
|
])
|
|
})
|
|
|
|
// Fly to a specific location (exposed for parent component)
|
|
const flyToLocation = async (location: Location) => {
|
|
console.log('[MapboxGlobe] flyToLocation called:', location.name, location.longitude, location.latitude)
|
|
console.log('[MapboxGlobe] isMapReady:', isMapReady.value, 'mapRef.value:', !!mapRef.value)
|
|
|
|
const { longitude, latitude } = location
|
|
if (longitude == null || latitude == null) {
|
|
console.log('[MapboxGlobe] ERROR: coordinates are null')
|
|
return
|
|
}
|
|
|
|
// Wait for map readiness if not ready
|
|
if (!isMapReady.value) {
|
|
console.log('[MapboxGlobe] waiting for map to be ready...')
|
|
await mapReadyPromise
|
|
console.log('[MapboxGlobe] map is now ready!')
|
|
}
|
|
|
|
if (!mapRef.value) {
|
|
console.log('[MapboxGlobe] ERROR: mapRef is still null after waiting')
|
|
return
|
|
}
|
|
|
|
selectedLocation.value = location
|
|
|
|
console.log('[MapboxGlobe] calling flyThroughSpace:', [longitude, latitude])
|
|
// Space fly animation settings: 5000ms, minZoom 3, targetZoom 12
|
|
await flyThroughSpace(mapRef.value, {
|
|
targetCenter: [longitude, latitude],
|
|
targetZoom: 12,
|
|
totalDuration: 5000,
|
|
minZoom: 3
|
|
})
|
|
console.log('[MapboxGlobe] flyThroughSpace completed')
|
|
}
|
|
|
|
// Expose methods for parent component
|
|
defineExpose({
|
|
flyToLocation,
|
|
resize: () => mapRef.value?.resize(),
|
|
isMapReady,
|
|
waitForReady: () => mapReadyPromise
|
|
})
|
|
</script>
|