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 />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="today">
|
|
||||||
{{ $t('dashboard.time_picker.today') }}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="last-5m">
|
<SelectItem value="last-5m">
|
||||||
{{ $t('dashboard.time_picker.last_5m') }}
|
{{ $t('dashboard.time_picker.last_5m') }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -89,6 +86,10 @@ onBeforeMount(() => {
|
||||||
<SelectItem value="last-24h">
|
<SelectItem value="last-24h">
|
||||||
{{ $t('dashboard.time_picker.last_24h') }}
|
{{ $t('dashboard.time_picker.last_24h') }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectSeparator />
|
||||||
|
<SelectItem value="today">
|
||||||
|
{{ $t('dashboard.time_picker.today') }}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ onMounted(async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="h-24">
|
||||||
<CardHeader class="flex flex-row justify-between items-center pb-2 space-y-0 px-0 pt-2">
|
<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">
|
<CardTitle class="text-sm font-medium flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ const filters = inject('filters')
|
||||||
|
|
||||||
const countries = ref({})
|
const countries = ref({})
|
||||||
const locations = ref([])
|
const locations = ref([])
|
||||||
|
const colos = ref({})
|
||||||
|
const currentLocation = ref({})
|
||||||
|
|
||||||
const el = useTemplateRef('globeEl')
|
const el = useTemplateRef('globeEl')
|
||||||
const { width } = useElementSize(el)
|
const { width } = useElementSize(el)
|
||||||
|
|
@ -39,6 +41,16 @@ async function getGlobeJSON() {
|
||||||
countries.value = data
|
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() {
|
async function getLiveLocations() {
|
||||||
const { data } = await useAPI('/api/logs/locations', {
|
const { data } = await useAPI('/api/logs/locations', {
|
||||||
query: {
|
query: {
|
||||||
|
|
@ -54,15 +66,32 @@ async function getLiveLocations() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function initGlobe() {
|
function trafficEvent({ props }) {
|
||||||
const normalized = 5 / props.minutes
|
const arc = {
|
||||||
const weightColor = scaleSequentialSqrt(interpolateYlOrRd).domain([0, highest.value * normalized * 15])
|
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)
|
globe = new Globe(globeEl.value)
|
||||||
.width(size.value.width)
|
.width(size.value.width)
|
||||||
.height(size.value.height)
|
.height(size.value.height)
|
||||||
// .globeOffset([width.value > 768 ? -100 : 0, width.value > 768 ? 0 : 100])
|
// .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({
|
.globeMaterial(new MeshPhongMaterial({
|
||||||
color: 'rgb(228, 228, 231)',
|
color: 'rgb(228, 228, 231)',
|
||||||
transparent: false,
|
transparent: false,
|
||||||
|
|
@ -77,13 +106,11 @@ function initGlobe() {
|
||||||
.hexPolygonAltitude(() => hexAltitude.value)
|
.hexPolygonAltitude(() => hexAltitude.value)
|
||||||
.hexBinMerge(true)
|
.hexBinMerge(true)
|
||||||
.hexBinPointWeight('count')
|
.hexBinPointWeight('count')
|
||||||
.hexPolygonColor(() => `rgba(120, 140, 110, ${Math.random() / 2 + 0.5})`)
|
.hexPolygonColor(() => `rgba(54, 211, 153, ${Math.random() / 1.5 + 0.5})`)
|
||||||
.hexTopColor(d => weightColor(d.sumWeight))
|
|
||||||
.hexSideColor(d => weightColor(d.sumWeight))
|
|
||||||
.onGlobeReady(() => {
|
.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().autoRotate = true
|
||||||
globe.controls().autoRotateSpeed = 0.2
|
globe.controls().autoRotateSpeed = 0.3
|
||||||
})
|
})
|
||||||
|
|
||||||
globe.controls().addEventListener('end', debounce(() => {
|
globe.controls().addEventListener('end', debounce(() => {
|
||||||
|
|
@ -96,6 +123,8 @@ function initGlobe() {
|
||||||
if (nextAlt !== hexAltitude.value)
|
if (nextAlt !== hexAltitude.value)
|
||||||
hexAltitude.value = nextAlt
|
hexAltitude.value = nextAlt
|
||||||
}, 200))
|
}, 200))
|
||||||
|
|
||||||
|
globalTrafficEvent.on(trafficEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRotation() {
|
function stopRotation() {
|
||||||
|
|
@ -118,13 +147,28 @@ watch(width, () => {
|
||||||
watch(locations, () => {
|
watch(locations, () => {
|
||||||
if (globe) {
|
if (globe) {
|
||||||
globe.hexBinPointsData(locations.value)
|
globe.hexBinPointsData(locations.value)
|
||||||
|
globe.hexTopColor(d => weightColor(d.sumWeight))
|
||||||
|
globe.hexSideColor(d => weightColor(d.sumWeight))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([getGlobeJSON(), getLiveLocations()])
|
await Promise.all([
|
||||||
|
getLiveLocations(),
|
||||||
|
getCurrentLocation(),
|
||||||
|
getGlobeJSON(),
|
||||||
|
getColosJSON(),
|
||||||
|
])
|
||||||
initGlobe()
|
initGlobe()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
globalTrafficEvent.off(trafficEvent)
|
||||||
|
if (globe) {
|
||||||
|
globe._destructor?.()
|
||||||
|
globe = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ onBeforeMount(() => {
|
||||||
</DashboardNav>
|
</DashboardNav>
|
||||||
<DashboardFilters @change="changeFilter" />
|
<DashboardFilters @change="changeFilter" />
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
<div class="relative space-y-4">
|
||||||
<DashboardRealtimeChart class="md:absolute top-0 left-0 z-10" />
|
<DashboardRealtimeChart class="md:absolute top-0 left-0 z-10" />
|
||||||
<DashboardRealtimeGlobe />
|
<DashboardRealtimeGlobe />
|
||||||
<DashboardRealtimeLogs class="md:absolute top-0 right-0 h-full z-10" />
|
<DashboardRealtimeLogs class="md:absolute top-0 right-0 h-full z-10" />
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,14 @@ watch([time, filters], getEvents, {
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
getEvents()
|
getEvents()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function onUpdateItems(items) {
|
||||||
|
globalTrafficEvent.emit(items)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<template #default>
|
||||||
<Notification
|
<Notification
|
||||||
v-for="item in logs"
|
v-for="item in logs"
|
||||||
|
|
@ -38,6 +42,7 @@ onMounted(async () => {
|
||||||
:description="[item.os, item.browser].filter(Boolean).join(' ')"
|
:description="[item.os, item.browser].filter(Boolean).join(' ')"
|
||||||
:icon="getFlag(item.country)"
|
:icon="getFlag(item.country)"
|
||||||
:time="item.timestamp"
|
:time="item.timestamp"
|
||||||
|
:item="item"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ const props = withDefaults(defineProps<{
|
||||||
delay: 1000,
|
delay: 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:items'])
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
const index = ref(0)
|
const index = ref(0)
|
||||||
const slotsArray = ref<any>([])
|
const slotsArray = ref<any>([])
|
||||||
|
|
@ -19,6 +21,10 @@ const itemsToShow = computed(() => {
|
||||||
return slotsArray.value.slice(start, index.value)
|
return slotsArray.value.slice(start, index.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch([itemsToShow], () => {
|
||||||
|
emit('update:items', itemsToShow.value.at(-1))
|
||||||
|
})
|
||||||
|
|
||||||
async function loadComponents() {
|
async function loadComponents() {
|
||||||
slotsArray.value = slots.default ? slots.default()?.[0]?.children : []
|
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",
|
"dev": "nuxt dev",
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"build:map": "node scripts/build-map.js",
|
"build:map": "node scripts/build-map.js",
|
||||||
|
"build:colo": "node scripts/build-colo.js",
|
||||||
"preview": "wrangler pages dev dist",
|
"preview": "wrangler pages dev dist",
|
||||||
"deploy": "wrangler pages deploy dist",
|
"deploy": "wrangler pages deploy dist",
|
||||||
"postinstall": "npm run build:map && nuxt prepare",
|
"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}`])
|
_.push(event[`blob${i + 1}`])
|
||||||
return _
|
return _
|
||||||
}, [])
|
}, [])
|
||||||
|
const doubles = Array.from({ length: Object.keys(doublesMap).length }).fill(0).reduce<number[]>((_, _c, i) => {
|
||||||
|
_.push(+event[`double${i + 1}`])
|
||||||
|
return _
|
||||||
|
}, [])
|
||||||
return {
|
return {
|
||||||
...blobs2logs(blobs),
|
...blobs2logs(blobs),
|
||||||
|
...doubles2logs(doubles),
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
timestamp: date2unix(new Date(`${event.timestamp}Z`)),
|
timestamp: date2unix(new Date(`${event.timestamp}Z`)),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue