feat: enhance globe visualization with traffic arcs

Adds real-time visualization of traffic flow between user locations and data centers on the 3D globe:

- Implements arc animations for traffic visualization
- Adjusts globe colors and atmosphere for better visibility
- Adds data center (colo) location mapping
- Centers initial view on user's current location
- Improves mobile responsiveness and layout
- Adds cleanup handling for globe resources

Performance and UX improvements:
- Optimizes globe rotation speed and controls
- Updates time picker layout with logical grouping
- Enhances responsive design for dashboard components
This commit is contained in:
ccbikai 2025-05-18 20:50:56 +08:00
parent 70d6632fd0
commit 852755ee1d
12 changed files with 108 additions and 16 deletions

View file

@ -65,9 +65,6 @@ onBeforeMount(() => {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">
{{ $t('dashboard.time_picker.today') }}
</SelectItem>
<SelectItem value="last-5m">
{{ $t('dashboard.time_picker.last_5m') }}
</SelectItem>
@ -89,6 +86,10 @@ onBeforeMount(() => {
<SelectItem value="last-24h">
{{ $t('dashboard.time_picker.last_24h') }}
</SelectItem>
<SelectSeparator />
<SelectItem value="today">
{{ $t('dashboard.time_picker.today') }}
</SelectItem>
</SelectContent>
</Select>
</template>

View file

@ -30,7 +30,7 @@ onMounted(async () => {
</script>
<template>
<Card class="md:w-80 h-72 flex flex-col p-4 m-2">
<Card class="md:w-80 h-72 flex flex-col p-4 md:m-2">
<div class="h-24">
<CardHeader class="flex flex-row justify-between items-center pb-2 space-y-0 px-0 pt-2">
<CardTitle class="text-sm font-medium flex items-center gap-2">

View file

@ -18,6 +18,8 @@ const filters = inject('filters')
const countries = ref({})
const locations = ref([])
const colos = ref({})
const currentLocation = ref({})
const el = useTemplateRef('globeEl')
const { width } = useElementSize(el)
@ -39,6 +41,16 @@ async function getGlobeJSON() {
countries.value = data
}
async function getColosJSON() {
const data = await $fetch('/colos.json')
colos.value = data
}
async function getCurrentLocation() {
const data = await useAPI('/api/location')
currentLocation.value = data
}
async function getLiveLocations() {
const { data } = await useAPI('/api/logs/locations', {
query: {
@ -54,15 +66,32 @@ async function getLiveLocations() {
}))
}
function initGlobe() {
const normalized = 5 / props.minutes
const weightColor = scaleSequentialSqrt(interpolateYlOrRd).domain([0, highest.value * normalized * 15])
function trafficEvent({ props }) {
const arc = {
startLat: props.item.latitude,
startLng: props.item.longitude,
endLat: colos.value[props.item.COLO]?.lat,
endLng: colos.value[props.item.COLO]?.lon,
color: 'red',
arcAltitude: 0.5,
}
console.info(`from ${props.item.city}(${props.item.latitude}, ${props.item.longitude}) to ${props.item.COLO}(${colos.value[props.item.COLO]?.lat}, ${colos.value[props.item.COLO]?.lon})`)
const random = Math.random()
globe.arcsData([arc])
.arcColor('color')
.arcDashLength(() => random + 0.2)
.arcDashGap(() => random - 0.2)
.arcDashAnimateTime(2000)
}
const normalized = 5 / props.minutes
const weightColor = scaleSequentialSqrt(interpolateYlOrRd).domain([0, highest.value * normalized * 15])
function initGlobe() {
globe = new Globe(globeEl.value)
.width(size.value.width)
.height(size.value.height)
// .globeOffset([width.value > 768 ? -100 : 0, width.value > 768 ? 0 : 100])
.atmosphereColor('rgba(170, 170, 200, 1)')
.atmosphereColor('rgba(170, 170, 200, 0.8)')
.globeMaterial(new MeshPhongMaterial({
color: 'rgb(228, 228, 231)',
transparent: false,
@ -77,13 +106,11 @@ function initGlobe() {
.hexPolygonAltitude(() => hexAltitude.value)
.hexBinMerge(true)
.hexBinPointWeight('count')
.hexPolygonColor(() => `rgba(120, 140, 110, ${Math.random() / 2 + 0.5})`)
.hexTopColor(d => weightColor(d.sumWeight))
.hexSideColor(d => weightColor(d.sumWeight))
.hexPolygonColor(() => `rgba(54, 211, 153, ${Math.random() / 1.5 + 0.5})`)
.onGlobeReady(() => {
globe.pointOfView({ lat: 20, lng: -36, altitude: width.value > 768 ? 2 : 3 })
globe.pointOfView({ lat: currentLocation.value.latitude, lng: currentLocation.value.longitude, altitude: width.value > 768 ? 2 : 3 })
globe.controls().autoRotate = true
globe.controls().autoRotateSpeed = 0.2
globe.controls().autoRotateSpeed = 0.3
})
globe.controls().addEventListener('end', debounce(() => {
@ -96,6 +123,8 @@ function initGlobe() {
if (nextAlt !== hexAltitude.value)
hexAltitude.value = nextAlt
}, 200))
globalTrafficEvent.on(trafficEvent)
}
function stopRotation() {
@ -118,13 +147,28 @@ watch(width, () => {
watch(locations, () => {
if (globe) {
globe.hexBinPointsData(locations.value)
globe.hexTopColor(d => weightColor(d.sumWeight))
globe.hexSideColor(d => weightColor(d.sumWeight))
}
})
onMounted(async () => {
await Promise.all([getGlobeJSON(), getLiveLocations()])
await Promise.all([
getLiveLocations(),
getCurrentLocation(),
getGlobeJSON(),
getColosJSON(),
])
initGlobe()
})
onBeforeUnmount(() => {
globalTrafficEvent.off(trafficEvent)
if (globe) {
globe._destructor?.()
globe = null
}
})
</script>
<template>

View file

@ -61,7 +61,7 @@ onBeforeMount(() => {
</DashboardNav>
<DashboardFilters @change="changeFilter" />
</div>
<div class="relative">
<div class="relative space-y-4">
<DashboardRealtimeChart class="md:absolute top-0 left-0 z-10" />
<DashboardRealtimeGlobe />
<DashboardRealtimeLogs class="md:absolute top-0 right-0 h-full z-10" />

View file

@ -26,10 +26,14 @@ watch([time, filters], getEvents, {
onMounted(async () => {
getEvents()
})
function onUpdateItems(items) {
globalTrafficEvent.emit(items)
}
</script>
<template>
<AnimatedList v-if="logs.length" :key="logskey" class="md:w-72">
<AnimatedList v-if="logs.length" :key="logskey" class="md:w-72" @update:items="onUpdateItems">
<template #default>
<Notification
v-for="item in logs"
@ -38,6 +42,7 @@ onMounted(async () => {
:description="[item.os, item.browser].filter(Boolean).join(' ')"
:icon="getFlag(item.country)"
:time="item.timestamp"
:item="item"
class="w-full"
/>
</template>

View file

@ -9,6 +9,8 @@ const props = withDefaults(defineProps<{
delay: 1000,
})
const emit = defineEmits(['update:items'])
const slots = useSlots()
const index = ref(0)
const slotsArray = ref<any>([])
@ -19,6 +21,10 @@ const itemsToShow = computed(() => {
return slotsArray.value.slice(start, index.value)
})
watch([itemsToShow], () => {
emit('update:items', itemsToShow.value.at(-1))
})
async function loadComponents() {
slotsArray.value = slots.default ? slots.default()?.[0]?.children : []

3
app/utils/events.ts Normal file
View file

@ -0,0 +1,3 @@
import { useEventBus } from '@vueuse/core'
export const globalTrafficEvent = useEventBus(Symbol('traffic'))

View file

@ -11,6 +11,7 @@
"dev": "nuxt dev",
"build": "nuxt build",
"build:map": "node scripts/build-map.js",
"build:colo": "node scripts/build-colo.js",
"preview": "wrangler pages dev dist",
"deploy": "wrangler pages deploy dist",
"postinstall": "npm run build:map && nuxt prepare",

1
public/colos.json Normal file

File diff suppressed because one or more lines are too long

19
scripts/build-colo.js Normal file
View file

@ -0,0 +1,19 @@
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
async function main() {
const locations = await fetch('https://raw.githubusercontent.com/Netrvin/cloudflare-colo-list/refs/heads/main/locations.json')
if (!locations.ok) {
throw new Error('Failed to fetch locations')
}
const colos = await locations.json()
writeFileSync(join(import.meta.dirname, '../public/colos.json'), JSON.stringify(colos.reduce((acc, c) => {
acc[c.iata] = {
lat: c.lat,
lon: c.lon,
}
return acc
}, {}), 'utf8'))
}
main().catch(console.error)

7
server/api/location.ts Normal file
View file

@ -0,0 +1,7 @@
export default eventHandler((event) => {
const { request: { cf } } = event.context.cloudflare
return {
latitude: cf.latitude,
longitude: cf.longitude,
}
})

View file

@ -22,8 +22,13 @@ function events2logs(events: WAEEvents[]) {
_.push(event[`blob${i + 1}`])
return _
}, [])
const doubles = Array.from({ length: Object.keys(doublesMap).length }).fill(0).reduce<number[]>((_, _c, i) => {
_.push(+event[`double${i + 1}`])
return _
}, [])
return {
...blobs2logs(blobs),
...doubles2logs(doubles),
id: crypto.randomUUID(),
timestamp: date2unix(new Date(`${event.timestamp}Z`)),
}