Initial commit from monorepo
This commit is contained in:
390
app/components/MapboxGlobe.client.vue
Normal file
390
app/components/MapboxGlobe.client.vue
Normal file
@@ -0,0 +1,390 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user