Files
webapp/app/components/MapboxGlobe.client.vue
2026-01-07 09:10:35 +07:00

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>