Interactive 3D Data Globe (Airports and Routes) using Nuxt 4 + TypeScript + Three.js

Let’s build a performant, interactive 3D globe that renders airports as instanced points and draws beautiful great‑circle route arcs — all in a fresh Nuxt 4 + TypeScript app using Three.js.
Tags: Nuxt 4, TypeScript, Three.js, Data Viz, WebGL
Time to read: 14 min
What’s new we’ll lean on:
- Nuxt 4 improvements in dev/build pipeline and SSR hydration make client-only WebGL components smoother.
- Three.js modern ESM modules and stable features like InstancedMesh, slerp-based great-circle paths, and OrbitControls with damping.
Prerequisites
- Comfortable with the terminal
- Node.js 18+ installed
- Basic understanding of Vue 3 and TypeScript
1) Create a Nuxt 4 app
npx nuxi init nuxt4-globe
cd nuxt4-globe
npm install
Run the dev server:
npm run dev
Visit http://localhost:3000 to confirm it works.
2) Install Three.js
npm install three
We’ll use the official ESM builds and examples from three/examples.
3) Add sample data
Create two data files in the public directory.
public/data/airports.json
[
{ "id": "JFK", "name": "New York JFK", "lat": 40.6413, "lng": -73.7781 },
{ "id": "LHR", "name": "London Heathrow", "lat": 51.47, "lng": -0.4543 },
{ "id": "CDG", "name": "Paris CDG", "lat": 49.0097, "lng": 2.5479 },
{ "id": "DXB", "name": "Dubai", "lat": 25.2532, "lng": 55.3657 },
{ "id": "HND", "name": "Tokyo Haneda", "lat": 35.5494, "lng": 139.7798 },
{ "id": "SYD", "name": "Sydney", "lat": -33.9399, "lng": 151.1753 },
{ "id": "SFO", "name": "San Francisco", "lat": 37.6213, "lng": -122.379 },
{ "id": "LAX", "name": "Los Angeles", "lat": 33.9416, "lng": -118.4085 },
{ "id": "SIN", "name": "Singapore Changi", "lat": 1.3644, "lng": 103.9915 },
{ "id": "GRU", "name": "São Paulo GRU", "lat": -23.4356, "lng": -46.4731 },
{ "id": "FRA", "name": "Frankfurt", "lat": 50.0379, "lng": 8.5622 },
{ "id": "JNB", "name": "Johannesburg", "lat": -26.1337, "lng": 28.242 }
]
public/data/routes.json
[
{ "from": "JFK", "to": "LHR" },
{ "from": "LHR", "to": "CDG" },
{ "from": "LHR", "to": "DXB" },
{ "from": "DXB", "to": "HND" },
{ "from": "HND", "to": "SYD" },
{ "from": "SFO", "to": "LAX" },
{ "from": "SFO", "to": "JFK" },
{ "from": "SIN", "to": "DXB" },
{ "from": "SIN", "to": "SYD" },
{ "from": "GRU", "to": "JFK" },
{ "from": "FRA", "to": "LHR" },
{ "from": "FRA", "to": "SIN" },
{ "from": "JNB", "to": "DXB" },
{ "from": "JNB", "to": "LHR" }
]
4) Create the Globe component
We’ll render WebGL on the client only to avoid SSR mismatches.
components/Globe.vue
<template>
<div ref="container" class="globe-wrap">
<div ref="tooltip" class="tooltip" v-show="tooltipVisible">{{ tooltipText }}</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watchEffect } from 'vue'
import type { PropType } from 'vue'
import {
Scene,
PerspectiveCamera,
WebGLRenderer,
AmbientLight,
DirectionalLight,
Group,
SphereGeometry,
MeshStandardMaterial,
MeshBasicMaterial,
Mesh,
Color,
Vector3,
Quaternion,
Matrix4,
InstancedMesh,
LineBasicMaterial,
BufferGeometry,
Line,
Float32BufferAttribute,
AdditiveBlending,
BackSide
} from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
type Airport = {
id: string
name: string
lat: number
lng: number
}
type Route = {
from: string
to: string
}
const props = defineProps({
airports: {
type: Array as PropType<Airport[]>,
default: () => []
},
routes: {
type: Array as PropType<Route[]>,
default: () => []
}
})
// Refs
const container = ref<HTMLDivElement | null>(null)
const tooltip = ref<HTMLDivElement | null>(null)
// UI state
const tooltipVisible = ref(false)
const tooltipText = ref('')
// Three.js entities
let scene: Scene
let camera: PerspectiveCamera
let renderer: WebGLRenderer
let controls: OrbitControls
let globeGroup: Group
let routesGroup: Group
let airportsMesh: InstancedMesh | null = null
let rafId = 0
// Config
const RADIUS = 1.0
const DPR_MAX = 2
const BG_COLOR = new Color('#0b1020')
const ROUTE_COLOR = new Color('#5ad1ff')
const EARTH_COLOR = new Color('#112244')
const ATMOSPHERE_COLOR = new Color('#3bf1ff')
const AIRPORT_COLOR = new Color('#ffd166')
let airportPositions: Vector3[] = []
let hoveredIndex: number | null = null
function lngLatToVector3(lng: number, lat: number, radius = RADIUS): Vector3 {
const phi = (90 - lat) * (Math.PI / 180)
const theta = (lng + 180) * (Math.PI / 180)
const x = -radius * Math.sin(phi) * Math.cos(theta)
const y = radius * Math.cos(phi)
const z = radius * Math.sin(phi) * Math.sin(theta)
return new Vector3(x, y, z)
}
function buildGlobe() {
globeGroup = new Group()
scene.add(globeGroup)
// Earth
const earthGeo = new SphereGeometry(RADIUS, 64, 64)
const earthMat = new MeshStandardMaterial({
color: EARTH_COLOR,
roughness: 1,
metalness: 0
})
const earth = new Mesh(earthGeo, earthMat)
globeGroup.add(earth)
// Atmosphere glow (backside, additive)
const atmoGeo = new SphereGeometry(RADIUS * 1.05, 64, 64)
const atmoMat = new MeshBasicMaterial({
color: ATMOSPHERE_COLOR,
blending: AdditiveBlending,
transparent: true,
opacity: 0.2,
side: BackSide
})
const atmo = new Mesh(atmoGeo, atmoMat)
globeGroup.add(atmo)
}
function buildAirports(airports: Airport[]) {
if (airportsMesh) {
airportsMesh.geometry.dispose()
;(airportsMesh.material as any).dispose?.()
globeGroup.remove(airportsMesh)
airportsMesh = null
}
const count = airports.length
airportPositions = new Array(count)
const geo = new SphereGeometry(0.01, 8, 8)
const mat = new MeshBasicMaterial({ color: AIRPORT_COLOR })
const inst = new InstancedMesh(geo, mat, count)
inst.instanceMatrix.setUsage(35048) // DynamicDrawUsage
const m = new Matrix4()
const q = new Quaternion()
const s = new Vector3(1, 1, 1)
airports.forEach((a, i) => {
const p = lngLatToVector3(a.lng, a.lat)
airportPositions[i] = p
m.compose(p, q, s)
inst.setMatrixAt(i, m)
})
inst.instanceMatrix.needsUpdate = true
airportsMesh = inst
globeGroup.add(inst)
}
function greatCircleArcPoints(a: Vector3, b: Vector3, segments = 64, altitude = 0.15): Vector3[] {
const start = a.clone().normalize()
const end = b.clone().normalize()
const points: Vector3[] = []
for (let i = 0; i <= segments; i++) {
const t = i / segments
const p = new Vector3().slerpVectors(start, end, t).normalize()
const h = Math.sin(Math.PI * t) * altitude
points.push(p.multiplyScalar(RADIUS * (1 + h)))
}
return points
}
function buildRoutes(airports: Airport[], routes: Route[]) {
if (routesGroup) {
routesGroup.traverse(obj => {
if ((obj as any).geometry) (obj as any).geometry.dispose?.()
if ((obj as any).material) (obj as any).material.dispose?.()
})
scene.remove(routesGroup)
}
const indexById = new Map(airports.map((a, i) => [a.id, i]))
routesGroup = new Group()
const material = new LineBasicMaterial({
color: ROUTE_COLOR,
transparent: true,
opacity: 0.9
})
routes.forEach(route => {
const iFrom = indexById.get(route.from)
const iTo = indexById.get(route.to)
if (iFrom == null || iTo == null) return
const a = airportPositions[iFrom]
const b = airportPositions[iTo]
if (!a || !b) return
const points = greatCircleArcPoints(a, b, 64, 0.18)
const positions = new Float32Array(points.length * 3)
points.forEach((p, i) => {
positions[i * 3 + 0] = p.x
positions[i * 3 + 1] = p.y
positions[i * 3 + 2] = p.z
})
const geo = new BufferGeometry()
geo.setAttribute('position', new Float32BufferAttribute(positions, 3))
const line = new Line(geo, material)
routesGroup.add(line)
})
scene.add(routesGroup)
}
function initThree() {
scene = new Scene()
scene.background = BG_COLOR
const width = container.value!.clientWidth
const height = container.value!.clientHeight
camera = new PerspectiveCamera(50, width / height, 0.1, 100)
camera.position.set(0, 0, 3)
renderer = new WebGLRenderer({ antialias: true, alpha: false })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, DPR_MAX))
renderer.setSize(width, height)
container.value!.appendChild(renderer.domElement)
// Lights
scene.add(new AmbientLight(0xffffff, 0.35))
const dir = new DirectionalLight(0xffffff, 1.0)
dir.position.set(5, 3, 5)
scene.add(dir)
// Controls
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.08
controls.rotateSpeed = 0.6
controls.minDistance = 1.6
controls.maxDistance = 6
// Group
globeGroup = new Group()
scene.add(globeGroup)
// Build base globe
buildGlobe()
// Render loop
const animate = () => {
rafId = renderer.setAnimationLoop(() => {
globeGroup.rotation.y += 0.0008
controls.update()
renderer.render(scene, camera)
updateTooltipPosition()
}) as unknown as number
}
animate()
window.addEventListener('resize', onResize, { passive: true })
renderer.domElement.addEventListener('mousemove', onPointerMove, { passive: true })
renderer.domElement.addEventListener('mouseleave', onPointerLeave, { passive: true })
}
function onResize() {
if (!container.value) return
const w = container.value.clientWidth
const h = container.value.clientHeight
camera.aspect = w / h
camera.updateProjectionMatrix()
renderer.setSize(w, h)
}
const mouseNdc = new Vector3()
function onPointerMove(e: MouseEvent) {
if (!airportsMesh) return
const rect = (e.target as HTMLCanvasElement).getBoundingClientRect()
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1
mouseNdc.set(x, y, 0)
// Raycast
const { Raycaster } = await import('three')
const raycaster = new Raycaster()
raycaster.setFromCamera(mouseNdc, camera)
const intersects = raycaster.intersectObject(airportsMesh, false)
if (intersects.length) {
const hit = intersects[0] as any
const idx: number | undefined = hit.instanceId
if (typeof idx === 'number') {
handleHover(idx)
tooltipVisible.value = true
tooltipText.value = props.airports[idx]?.name || ''
updateTooltipPosition(e.clientX, e.clientY)
return
}
}
clearHover()
}
function onPointerLeave() {
tooltipVisible.value = false
clearHover()
}
function handleHover(idx: number) {
if (!airportsMesh) return
if (hoveredIndex === idx) return
// Restore previous
if (hoveredIndex != null) {
const p = airportPositions[hoveredIndex]
const m = new Matrix4()
m.compose(p, new Quaternion(), new Vector3(1, 1, 1))
airportsMesh.setMatrixAt(hoveredIndex, m)
}
// Highlight current
const p = airportPositions[idx]
const m = new Matrix4()
const s = new Vector3(1.8, 1.8, 1.8)
m.compose(p, new Quaternion(), s)
airportsMesh.setMatrixAt(idx, m)
airportsMesh.instanceMatrix.needsUpdate = true
hoveredIndex = idx
}
function clearHover() {
if (!airportsMesh || hoveredIndex == null) return
const p = airportPositions[hoveredIndex]
const m = new Matrix4()
m.compose(p, new Quaternion(), new Vector3(1, 1, 1))
airportsMesh.setMatrixAt(hoveredIndex, m)
airportsMesh.instanceMatrix.needsUpdate = true
hoveredIndex = null
}
function updateTooltipPosition(clientX?: number, clientY?: number) {
if (!tooltip.value || !tooltipVisible.value) return
const t = tooltip.value
if (clientX != null && clientY != null) {
t.style.transform = `translate(${clientX + 12}px, ${clientY + 12}px)`
}
}
onMounted(() => {
initThree()
// Build airports + routes after init
watchEffect(() => {
if (!props.airports?.length) return
buildAirports(props.airports)
if (props.routes?.length) {
buildRoutes(props.airports, props.routes)
}
})
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
renderer?.domElement?.removeEventListener('mousemove', onPointerMove as any)
renderer?.domElement?.removeEventListener('mouseleave', onPointerLeave as any)
if (renderer) {
renderer.setAnimationLoop(null)
cancelAnimationFrame(rafId)
renderer.dispose()
}
// Dispose geometries/materials
scene?.traverse(obj => {
const anyObj = obj as any
if (anyObj.geometry) anyObj.geometry.dispose?.()
if (anyObj.material) {
if (Array.isArray(anyObj.material)) anyObj.material.forEach((m: any) => m.dispose?.())
else anyObj.material.dispose?.()
}
})
})
</script>
<style scoped>
.globe-wrap {
position: relative;
width: 100%;
height: min(80vh, 800px);
background: radial-gradient(1200px 500px at 50% 0%, #0f1835 0%, #070b17 55%, #04060c 100%);
border-radius: 14px;
overflow: hidden;
}
.tooltip {
position: fixed;
pointer-events: none;
background: rgba(15, 15, 30, 0.9);
color: #e8f1ff;
font-size: 12px;
padding: 6px 8px;
border: 1px solid rgba(90, 209, 255, 0.3);
border-radius: 6px;
white-space: nowrap;
transform: translate(-9999px, -9999px);
z-index: 10;
backdrop-filter: blur(4px);
}
</style>
5) Use the Globe on the home page
We’ll fetch the data at the page level (SSR-safe) and hand it down to the client-only component.
pages/index.vue
<template>
<section class="page">
<header class="hero">
<h1>Interactive 3D Data Globe</h1>
<p>Airports and great-circle routes with Nuxt 4 + TypeScript + Three.js</p>
</header>
<ClientOnly>
<Globe :airports="airports || []" :routes="routes || []" />
</ClientOnly>
<footer class="notes">
Hover the glowing points to see airport names. Drag to orbit. Scroll to zoom.
</footer>
</section>
</template>
<script setup lang="ts">
import Globe from '~/components/Globe.vue'
type Airport = { id: string; name: string; lat: number; lng: number }
type Route = { from: string; to: string }
const { data: airports } = await useFetch<Airport[]>('/data/airports.json')
const { data: routes } = await useFetch<Route[]>('/data/routes.json')
</script>
<style scoped>
.page {
display: grid;
gap: 20px;
padding: 24px;
}
.hero h1 {
font-size: 28px;
margin: 0 0 8px;
}
.hero p {
margin: 0;
opacity: 0.8;
}
.notes {
opacity: 0.7;
font-size: 14px;
}
</style>
Nuxt will only render the Three.js canvas on the client, avoiding SSR issues.
6) Run it
npm run dev
Open http://localhost:3000 and you should see:
- A rotating globe with a soft atmosphere
- Airports as glowing points (hover for tooltip)
- Great‑circle route arcs between selected airports
7) How it works
- InstancedMesh for airports: CPU-light, GPU-friendly rendering of many identical meshes.
- Great-circle arcs: slerp two normalized vectors on the sphere and raise points with a sin-based altitude profile for pleasing curvature.
- OrbitControls with damping for smooth interaction.
- Raycasting against InstancedMesh to find the hovered instanceId and update its transform.
8) Optional enhancements
- Add per-route colors and widths (Line2 from examples) for thicker, screen-space lines.
- Animate route drawing by revealing line segments over time.
- Use real datasets (OpenFlights) and filter by airline/country with a small UI.
- Add a starfield background or HDRI for richer scene lighting.
- Persist camera position in query params for sharable viewpoints.
9) Production build
npm run build
npm run preview
Verify performance in Lighthouse/Performance panel. For large datasets, prefer:
- Instanced lines or batched line geometries
- Frustum culling by group
- Reduced segment counts on low DPR devices
Conclusion
You’ve built a modern, interactive 3D data globe in Nuxt 4 using TypeScript and Three.js. The setup demonstrates clean client-only WebGL integration with SSR-friendly data fetching, instanced rendering for performance, and polished interactivity. Have fun layering in richer datasets and visual encodings!