← Back to the globe ← Volver al globo

CreatedCreado UpdatedActualizado

How the Flight Globe Was Built

The Flight Log is an interactive globe that traces the long-haul routes I’ve flown. It looks like a 3D earth — you can drag it, hover an airport to see its real coordinates, zoom into a cluster, and open the Settings panel to recolor the land and oceans or switch the whole page between light and dark. This page explains how it actually works — there’s more going on than a spinning picture.

The whole thing is Canvas 2D and arithmetic — no WebGL, no globe library, no 3D engine. That constraint is what makes it interesting.

It only looks 3D — it’s a flat projection

There is no 3D viewer here. No <canvas> WebGL context, no camera, no scene graph, no Three.js — none of the machinery people usually mean by “3D.” It’s a plain 2D drawing surface (canvas.getContext('2d')), and the roundness is an illusion produced by three steps of ordinary arithmetic, run every frame:

rotated unit sphere (3D math) 2D canvas (what you see) z > 0 drawn z ≤ 0 · culled drop z, scale by R → sx = cx + x·R, sy = cy − y·R
No 3D engine: each point is a 3D unit vector, rotated by the current drag, then flattened by simply ignoring its z and scaling x/y by the radius R. Anything with z ≤ 0 has rotated to the far side and is skipped — that one test is the whole "behind the globe" effect.

Because the projection just drops z (rather than dividing by it, the way a perspective camera would), this is an orthographic view — parallel rays, no foreshortening — which is exactly what a planet looks like from very far away. Everything below is built on these few lines.


A globe is just a sphere you keep rotating

Every point on Earth is a latitude/longitude pair. The first step is turning that into a point in 3D space — a unit vector on a sphere:

function vec(lat, lon) {
  const a = lat * Math.PI / 180, o = lon * Math.PI / 180;
  return {
    x: Math.cos(a) * Math.sin(o),
    y: Math.sin(a),
    z: Math.cos(a) * Math.cos(o),
  };
}

To “spin the globe,” every vector is rotated by the current yaw (drag left/right) and pitch (drag up/down) before it’s drawn. A point is on the near side of the planet — visible — only when its rotated z is positive. That single test (z > 0) is what lets arcs and coastlines disappear around the back of the globe instead of bleeding through it.

Projecting the rotated vector to the screen is then trivial — it’s just its x/y scaled by the globe’s radius R:

const sx = cx + r.x * R;
const sy = cy - r.y * R;   // minus: screen Y grows downward

That’s the entire camera. No projection matrix, no perspective — an orthographic view of a unit sphere, which is exactly what a globe seen from far away looks like.


The realistic earth: a lit, texture-mapped sphere

The default “Solid” mode doesn’t draw shapes onto the globe — it does it the other way around. It builds a flat equirectangular world map once (an offscreen 2048×1024 canvas with the continents filled in), then, for every pixel inside the globe’s disc, asks: “which point on Earth is here?”

For each on-screen pixel it reconstructs the sphere normal (X, Y, Z) with Z = √(1 − X² − Y²), applies the inverse rotation to get back to a world direction, converts that to latitude/longitude, and samples the corresponding pixel from the flat map. Then it shades it:

let f = 0.52 + 0.55 * Math.max(0, N · L); // a fixed light direction
f *= 0.68 + 0.32 * Z;                     // limb darkening toward the edge

The first line is ordinary diffuse lighting; the second darkens the rim so the sphere reads as round rather than as a flat disc. The per-pixel ray cache is rebuilt only on resize, and the texture only when you change the land color — so the expensive parts don’t run every frame.


Real coastlines, not hand-drawn blobs

The first version of the globe used coastlines I’d sketched by hand, and it showed — Central America didn’t reach South America, and Cuba was the size of Florida. The fix was to drop in Natural Earth ne_110m_land, a public-domain dataset of real coastlines at low (110m) resolution. It ships as GeoJSON; a small script flattens each polygon into a closed ring of [lon, lat] points:

export const LANDPOLYS = [
  [[-166, 66], [-162, 70], /* …127 rings, ~5,000 points… */],
  // North America, Eurasia, Africa, Antarctica, every major island…
];

Those same rings drive two things: the equirectangular texture (fill each ring as a polygon) and the dotted/contour modes (a point-in-polygon test decides which grid points are land). Swapping the data fixed the proportions everywhere at once, with no change to the renderer.


Putting each airport on the map

Every place on the globe is an airport with its official reference coordinates and field elevation, taken from aeronautical data — the same numbers you’d find in an airport’s AIP entry:

export const CITIES = {
  IAH: { name: 'George Bush Intercontinental', lat: 29.9844, lon: -95.3414, elevFt: 97,  elevM: 30  },
  LHR: { name: 'London Heathrow',              lat: 51.4700, lon: -0.4543,  elevFt: 83,  elevM: 25  },
  // …LAX, PVG, NRT, FRA, DXB
};

Hover any marker and the globe projects that exact lat/lon onto the sphere and shows it back to you, hemispheres and all (29.9844°N · 95.3414°W). The distances in the side panel aren’t typed in — they’re computed from these coordinates with the haversine formula (great-circle distance on a sphere), and “air time” is just total distance ÷ a cruise speed of 870 km/h.

Each route between two airports is drawn as a great-circle arc — the shortest path over a sphere, which on a flat map looks like a curve. The arc is a spherical interpolation (slerp) between the two city vectors, lifted slightly off the surface so longer routes arch higher, then clipped wherever it passes behind the globe.


Why nautical miles?

Distances are shown in nautical miles (nm) — the standard unit in aviation and at sea. One nautical mile is exactly 1,852 metres, originally defined as one minute of arc of latitude, which is what makes it natural for navigation: one degree of latitude is 60 nm. Aviation pairs it with two more non-metric units you’ll find in any cockpit — speed in knots (nautical miles per hour) and altitude in feet. That’s why each airport here carries its field elevation in feet, and the cruise figure behind “air time” is about 470 knots.

These conventions are set internationally by ICAO — Annex 5, Units of Measurement to be Used in Air and Ground Operations — and in the US by the FAA. The exact 1,852 m value is the international standard nautical mile (Wikipedia: Nautical mile). The small 1 nm = 1.852 km = 1.151 mi legend under the route list converts back to the units most people picture.


The render modes

The Settings panel exposes a few ways to draw the land:

  • Solid — the lit, texture-mapped earth described above.
  • Dots — the dark sphere with coastlines stroked as a solid contour and the interiors stippled with depth-scaled dots, so the continents stay readable.
  • Wireframe — an independent toggle that overlays a latitude/longitude graticule, with the Equator and Prime Meridian picked out, on either solid or dots.

Plus land color, a new ocean color (it rebuilds the equirectangular texture with your chosen water hue, darkened toward the poles for a subtle vignette), the atmosphere glow, and spin speed. Every choice is saved to localStorage, so the globe comes back the way you left it.


Zooming in

Zoom is almost free here, because the entire camera is the single radius R. Magnifying the globe is just R = baseR × zoom — every arc, coastline, marker, and hit-test already scales off R, so nothing else has to change. To zoom toward the cursor (or the midpoint of a two-finger pinch) rather than the center, the globe’s on-screen center is panned so the point under the pointer stays fixed:

const f = nextZoom / prevZoom;
cx = focusX + (cx - focusX) * f;   // keep the point under the cursor put
cy = focusY + (cy - focusY) * f;
R  = baseR * nextZoom;

Three inputs drive it — the mouse wheel, a pinch gesture (tracked through pointer events), and the on-screen + / − buttons — all funnelling into that one function. The one wrinkle is cost: the lit “Solid” earth is rasterised pixel-by-pixel, so re-rasterising every frame mid-zoom would stutter. Instead the existing low-res raster is just scaled up during the gesture (cheap and smooth) and re-rendered at full resolution ~160 ms after the zoom settles.


Light and dark, one set of tokens

The globe page used to be pinned dark. Now it follows the site-wide light/dark toggle (the sun/moon button next to the info and gear icons), driven by a single html[data-theme] attribute and a handful of CSS custom properties — the same mechanism every other page uses. Nothing is forked into “a light page” and “a dark page”: the panel text, stats, route list, and settings all read from var(--text), var(--muted), var(--accent), var(--border) and a few flight-log-local tokens (--fg-space for the backdrop, --fg-glass for the settings panel), so switching themes just swaps those values.

The globe sphere itself stays a rendered earth in both themes — its oceans and land are the colors you pick in Settings, lit the same way. What changes around it is the scene: a deep-space gradient in dark, a soft daytime sky in light. The effect is that the same earth hangs in space at night or in a bright sky by day, while the surrounding chrome stays as readable as the rest of the site.


The Equator and the Prime Meridian

Turn on Wireframe and two of the grid lines are drawn a little brighter than the rest:

  • The Equator (amber) is the circle of 0° latitude, exactly halfway between the poles. It divides Earth into the Northern and Southern hemispheres, is the planet’s widest circle — about 21,639 nm (40,075 km) around — and is the line the whole latitude system is measured outward from. (National Geographic, NOAA SciJinks)
  • The Prime Meridian (cyan) is the line of 0° longitude, running through the Royal Observatory at Greenwich, London. It was adopted as the world’s reference meridian at the 1884 International Meridian Conference; east and west longitude — and historically Greenwich Mean Time and the world’s time zones — are all measured from it. (Royal Museums Greenwich)

Together, 0° latitude and 0° longitude are the origin of the coordinate grid — every lat, lon in this globe’s airport table is ultimately measured from those two lines.


Why there’s no React here

A reasonable question for an interactive component like this is “why not build it in a framework?” Because the part that’s hard — the globe — is an imperative requestAnimationFrame loop mutating yaw, pitch, and hover state sixty times a second. Those values are deliberately kept out of any reactive state; they live on a plain object the draw loop reads directly. React’s strength is diffing a declarative DOM tree, and there’s almost no DOM here — just a <canvas> and a handful of settings controls wired with ordinary event listeners.

So the whole page is static Astro: HTML and one self-contained script, no framework runtime shipped to the browser. The numbers in the side panel are even computed at build time, because the route list never changes. It’s a good reminder that “interactive” and “needs a framework” aren’t the same thing.


Built with Astro and Claude Code. The coastline data is Natural Earth, public domain.

Cómo se construyó el Globo de Vuelos

La Bitácora de Vuelos es un globo interactivo que traza las rutas de larga distancia que he volado. Parece una Tierra 3D — puedes arrastrarla, pasar el cursor sobre un aeropuerto para ver sus coordenadas reales, hacer zoom sobre un grupo de rutas y abrir el panel de Ajustes para recolorear el terreno y los océanos o cambiar toda la página entre modo claro y oscuro. Esta página explica cómo funciona de verdad — hay más cosas pasando que una simple imagen girando.

Todo está hecho con Canvas 2D y aritmética — sin WebGL, sin librería de globos, sin motor 3D. Esa restricción es justo lo que lo hace interesante.

Solo parece 3D — es una proyección plana

Aquí no hay ningún visor 3D. Sin contexto WebGL en el <canvas>, sin cámara, sin grafo de escena, sin Three.js — nada de la maquinaria que la gente suele llamar “3D”. Es una superficie de dibujo 2D normal (canvas.getContext('2d')), y la redondez es una ilusión producida por tres pasos de aritmética común, ejecutados en cada cuadro:

esfera unitaria rotada (3D) canvas 2D (lo que ves) z > 0 se dibuja z ≤ 0 · se omite descarta z, escala por R → sx = cx + x·R, sy = cy − y·R
Sin motor 3D: cada punto es un vector unitario 3D, rotado según el arrastre actual y luego aplanado simplemente ignorando su z y escalando x/y por el radio R. Todo lo que tenga z ≤ 0 rotó hacia la cara lejana y se omite — esa única prueba es todo el efecto de "detrás del globo".

Como la proyección solo descarta z (en vez de dividir por él, como haría una cámara en perspectiva), esta es una vista ortográfica — rayos paralelos, sin escorzo — que es justo como se ve un planeta desde muy lejos. Todo lo de abajo se construye sobre estas pocas líneas.


Un globo es solo una esfera que sigues rotando

Cada punto de la Tierra es un par latitud/longitud. El primer paso es convertir eso en un punto en el espacio 3D — un vector unitario sobre una esfera:

function vec(lat, lon) {
  const a = lat * Math.PI / 180, o = lon * Math.PI / 180;
  return {
    x: Math.cos(a) * Math.sin(o),
    y: Math.sin(a),
    z: Math.cos(a) * Math.cos(o),
  };
}

Para “girar el globo”, cada vector se rota según el yaw actual (arrastrar izquierda/derecha) y el pitch (arrastrar arriba/abajo) antes de dibujarlo. Un punto está en la cara cercana del planeta — visible — solo cuando su z rotado es positivo. Esa única prueba (z > 0) es la que hace que los arcos y las costas desaparezcan por detrás del globo en vez de atravesarlo.

Proyectar el vector rotado a la pantalla es trivial — son su x/y escalados por el radio R del globo:

const sx = cx + r.x * R;
const sy = cy - r.y * R;   // menos: la Y de pantalla crece hacia abajo

Esa es toda la cámara. Sin matriz de proyección, sin perspectiva — una vista ortográfica de una esfera unitaria, que es exactamente como se ve un globo desde lejos.


La Tierra realista: una esfera iluminada con textura

El modo “Sólido” por defecto no dibuja formas sobre el globo — lo hace al revés. Construye una vez un mapa plano equirectangular del mundo (un canvas fuera de pantalla de 2048×1024 con los continentes rellenos) y luego, por cada píxel dentro del disco del globo, pregunta: “¿qué punto de la Tierra está aquí?”

Por cada píxel en pantalla reconstruye la normal de la esfera (X, Y, Z) con Z = √(1 − X² − Y²), aplica la rotación inversa para volver a una dirección del mundo, la convierte a latitud/longitud y toma el píxel correspondiente del mapa plano. Después lo sombrea:

let f = 0.52 + 0.55 * Math.max(0, N · L); // una dirección de luz fija
f *= 0.68 + 0.32 * Z;                     // oscurecer el borde del disco

La primera línea es iluminación difusa normal; la segunda oscurece el borde para que la esfera se vea redonda y no como un disco plano. El caché de rayos por píxel se reconstruye solo al cambiar el tamaño, y la textura solo cuando cambias el color del terreno — así las partes costosas no corren en cada cuadro.


Costas reales, no manchas dibujadas a mano

La primera versión del globo usaba costas que yo había esbozado a mano, y se notaba — Centroamérica no llegaba a Suramérica y Cuba era del tamaño de Florida. La solución fue meter Natural Earth ne_110m_land, un conjunto de datos de dominio público con costas reales en baja resolución (110m). Viene como GeoJSON; un script pequeño aplana cada polígono en un anillo cerrado de puntos [lon, lat]:

export const LANDPOLYS = [
  [[-166, 66], [-162, 70], /* …127 anillos, ~5.000 puntos… */],
  // Norteamérica, Eurasia, África, Antártida, cada isla importante…
];

Esos mismos anillos alimentan dos cosas: la textura equirectangular (rellenar cada anillo como polígono) y los modos de puntos/contorno (una prueba de punto en polígono decide qué puntos de la grilla son tierra). Cambiar los datos arregló las proporciones en todos lados de golpe, sin tocar el renderizador.


Poner cada aeropuerto en el mapa

Cada lugar del globo es un aeropuerto con sus coordenadas oficiales de referencia y elevación de campo, tomadas de datos aeronáuticos — los mismos números que encontrarías en la ficha AIP de un aeropuerto:

export const CITIES = {
  IAH: { name: 'George Bush Intercontinental', lat: 29.9844, lon: -95.3414, elevFt: 97,  elevM: 30  },
  LHR: { name: 'London Heathrow',              lat: 51.4700, lon: -0.4543,  elevFt: 83,  elevM: 25  },
  // …LAX, PVG, NRT, FRA, DXB
};

Pasa el cursor sobre cualquier marcador y el globo proyecta esa lat/lon exacta sobre la esfera y te la muestra, con hemisferios y todo (29.9844°N · 95.3414°W). Las distancias del panel lateral no están escritas a mano — se calculan a partir de estas coordenadas con la fórmula de haversine (distancia de círculo máximo sobre una esfera), y el “tiempo en aire” es simplemente la distancia total ÷ una velocidad de crucero de 870 km/h.

Cada ruta entre dos aeropuertos se dibuja como un arco de círculo máximo — el camino más corto sobre una esfera, que en un mapa plano se ve como una curva. El arco es una interpolación esférica (slerp) entre los dos vectores de ciudad, levantado un poco de la superficie para que las rutas largas se arqueen más alto, y recortado donde pasa por detrás del globo.


¿Por qué millas náuticas?

Las distancias se muestran en millas náuticas (nm) — la unidad estándar en aviación y en el mar. Una milla náutica equivale exactamente a 1.852 metros, definida originalmente como un minuto de arco de latitud, lo que la hace natural para la navegación: un grado de latitud son 60 nm. La aviación la acompaña de otras dos unidades no métricas que verás en cualquier cabina: la velocidad en nudos (millas náuticas por hora) y la altitud en pies. Por eso cada aeropuerto aquí trae su elevación de campo en pies, y la velocidad de crucero detrás del “tiempo en aire” es de unos 470 nudos.

Estas convenciones las fija internacionalmente la OACI — Anexo 5, Unidades de medida que se emplearán en las operaciones aéreas y terrestres — y en EE. UU. la FAA. El valor exacto de 1.852 m es la milla náutica internacional estándar (Wikipedia: Milla náutica). La pequeña leyenda 1 nm = 1.852 km = 1.151 mi bajo la lista de rutas reconvierte a las unidades que la mayoría imagina.


Los modos de dibujo

El panel de Ajustes ofrece varias formas de dibujar el terreno:

  • Sólido — la Tierra iluminada con textura que describí arriba.
  • Puntos — la esfera oscura con las costas trazadas como contorno sólido y los interiores punteados con escala de profundidad, para que los continentes se sigan leyendo.
  • Malla — un interruptor independiente que superpone una grilla de latitud/longitud, con el Ecuador y el Meridiano de Greenwich resaltados, sobre sólido o puntos.

Además del color del terreno, un nuevo color del océano (reconstruye la textura equirectangular con el tono de agua que elijas, oscurecido hacia los polos para un sutil viñeteado), el brillo de la atmósfera y la velocidad de giro. Cada elección se guarda en localStorage, así que el globo vuelve tal como lo dejaste.


Hacer zoom

El zoom es casi gratis aquí, porque toda la cámara es el único radio R. Ampliar el globo es solo R = baseR × zoom — cada arco, costa, marcador y prueba de impacto ya se escala a partir de R, así que nada más tiene que cambiar. Para hacer zoom hacia el cursor (o el punto medio de un gesto de pellizco con dos dedos) en vez de hacia el centro, se desplaza el centro en pantalla del globo para que el punto bajo el puntero quede fijo:

const f = nextZoom / prevZoom;
cx = focusX + (cx - focusX) * f;   // mantén fijo el punto bajo el cursor
cy = focusY + (cy - focusY) * f;
R  = baseR * nextZoom;

Tres entradas lo controlan — la rueda del ratón, un gesto de pellizco (rastreado con pointer events) y los botones en pantalla + / − — todas confluyendo en esa única función. La única complicación es el costo: la Tierra “Sólida” iluminada se rasteriza píxel a píxel, así que re-rasterizar en cada cuadro durante el zoom daría tirones. En su lugar, el ráster de baja resolución existente solo se escala durante el gesto (barato y fluido) y se vuelve a renderizar a resolución completa ~160 ms después de que el zoom se detiene.


Claro y oscuro, un solo juego de tokens

La página del globo antes estaba fijada en oscuro. Ahora sigue el interruptor claro/oscuro de todo el sitio (el botón de sol/luna junto a los iconos de info y engranaje), manejado por un único atributo html[data-theme] y un puñado de propiedades CSS personalizadas — el mismo mecanismo que usan todas las demás páginas. Nada se bifurca en “una página clara” y “una página oscura”: el texto del panel, las estadísticas, la lista de rutas y los ajustes leen de var(--text), var(--muted), var(--accent), var(--border) y unos pocos tokens locales del globo (--fg-space para el fondo, --fg-glass para el panel de ajustes), así que cambiar de tema solo intercambia esos valores.

La esfera del globo en sí sigue siendo una Tierra renderizada en ambos temas — sus océanos y su terreno son los colores que elijas en Ajustes, iluminados igual. Lo que cambia a su alrededor es la escena: un degradado de espacio profundo en oscuro, un cielo diurno suave en claro. El efecto es que la misma Tierra cuelga en el espacio de noche o en un cielo brillante de día, mientras el resto de la interfaz se mantiene tan legible como el resto del sitio.


El Ecuador y el Meridiano de Greenwich

Activa Malla y dos de las líneas de la grilla se dibujan un poco más brillantes que el resto:

Juntos, 0° de latitud y 0° de longitud son el origen de la cuadrícula de coordenadas — cada lat, lon de la tabla de aeropuertos de este globo se mide, en última instancia, desde esas dos líneas.


Por qué aquí no hay React

Una pregunta razonable para un componente interactivo como este es “¿por qué no construirlo en un framework?” Porque la parte difícil — el globo — es un bucle imperativo de requestAnimationFrame que muta yaw, pitch y el estado de hover sesenta veces por segundo. Esos valores se mantienen a propósito fuera de cualquier estado reactivo; viven en un objeto plano que el bucle de dibujo lee directamente. La fortaleza de React es comparar un árbol de DOM declarativo, y aquí casi no hay DOM — solo un <canvas> y un puñado de controles conectados con escuchas de eventos normales.

Así que toda la página es Astro estático: HTML y un script autocontenido, sin runtime de framework enviado al navegador. Los números del panel lateral hasta se calculan al compilar, porque la lista de rutas nunca cambia. Es un buen recordatorio de que “interactivo” y “necesita un framework” no son lo mismo.


Construido con Astro y Claude Code. Los datos de costas son de Natural Earth, de dominio público.