November 17, 202514 min

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

Build a smooth, interactive 3D globe that visualizes airports and routes with Nuxt 4, TypeScript, and Three.js — complete with instancing, orbit controls, and raycasting tooltips.

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

Interactive 3D Data Globe

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!