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:
parent
70d6632fd0
commit
852755ee1d
12 changed files with 108 additions and 16 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
3
app/utils/events.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { useEventBus } from '@vueuse/core'
|
||||
|
||||
export const globalTrafficEvent = useEventBus(Symbol('traffic'))
|
||||
|
|
@ -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
1
public/colos.json
Normal file
File diff suppressed because one or more lines are too long
19
scripts/build-colo.js
Normal file
19
scripts/build-colo.js
Normal 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
7
server/api/location.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export default eventHandler((event) => {
|
||||
const { request: { cf } } = event.context.cloudflare
|
||||
return {
|
||||
latitude: cf.latitude,
|
||||
longitude: cf.longitude,
|
||||
}
|
||||
})
|
||||
|
|
@ -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`)),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue