Arch Line Visualization

Learn how to visualize air traffic volume data between airports using arch lines (great circle routes lifted into arcs). This tutorial combines data-driven color mapping, animation, and glow effects to create a beautiful visualization.
What you will learn in this tutorial:
- Setting up a dark-themed globe view
- Configuring starfield, ambient light, and tone mapping
- Loading and processing GeoJSON data
- Data-driven color mapping using ColorMap
- Efficiently rendering multiple arch lines
- Expressing flow with dash animation
- Making the globe look beautiful with a glow effect
Basic Implementation
Section titled “Basic Implementation”First, create the base view. Set a dark background color and add a starfield and satellite imagery tiles.
import ThreeView, { Color } from "@navaramap/three";import { ToneMappingMode } from "@navaramap/three_default_descs";import { DefaultPlugin, type DefaultDescriptions } from "@navaramap/three_default_plugin";
const plugin = new DefaultPlugin();const view = new ThreeView<DefaultDescriptions>({ backgroundColor: new Color().setStyle("#0b0a0d"),});view.addPlugin(plugin);
await view.init();
// Adjust tone mapping exposureview.toneMappingExposure = 10;
// Add ambient lightview.addLight({ ambient: {},});
// Add starfieldview.addMesh({ stars: { intensity: 100, pointSize: 1.5, },});
// Add tone mapping effectview.addEffect({ toneMapping: { mode: ToneMappingMode.REINHARD2, },});
// Add anti-aliasing (SMAA)view.addEffect({ smaa: { quality: "ultra", },});
// Base satellite imagery tilesconst satelliteSource = view.addSource({ type: "raster-tile", // Credit: // - Geospatial Information Authority of Japan Tiles - Latest Nationwide Photo (Seamless) // https://maps.gsi.go.jp/development/ichiran.html url: "https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg", maxZoom: 6, minZoom: 2,});view.addLayer({ type: "raster", source: satelliteSource,});
// Set camera to a position where the entire globe is visibleview.setCamera({ lng: 140, lat: 20, height: 12_600_000, heading: 0, pitch: -90, roll: 0 });Adding Night Tiles
Section titled “Adding Night Tiles”To create a more beautiful nighttime Earth, overlay night tiles (NASA Earth at Night).
// Add night tiles (overlaid with transparency)const nightSource = view.addSource({ type: "raster-tile", // Credit: // - NASA Earth at Night imagery (Converted as raster tiles) url: "/data/blue-marble-night/{z}/{x}/{y}.webp", maxZoom: 6, minZoom: 1,});view.addLayer({ type: "raster", source: nightSource, raster: { opacity: 0.8, // Overlay with transparency },});Adding a Glow Effect
Section titled “Adding a Glow Effect”Using GlowGlobeMeshDesc, you can add a beautiful glow effect around the globe.
import type { GlowGlobeMeshDesc } from "@navaramap/three_default_descs";
// Add globe glow effectview.addMesh<GlowGlobeMeshDesc>({ glowGlobe: { radiusScale: 1.2, // Glow radius (multiplier relative to Earth's radius) coefficient: 0.43, // Glow intensity coefficient exponent: 40.0, // Glow falloff rate glowColor: new Color().setStyle("#938cff"), opacity: 0.5, // Opacity },});
Loading GeoJSON Data
Section titled “Loading GeoJSON Data”Load air traffic volume data between airports in GeoJSON format. Here we use the inter-airport flow volume data from the National Land Numerical Information service.
import type { FeatureCollection, MultiLineString } from "geojson";
// Type definition for air traffic volume datatype AirportTrafficData = FeatureCollection< MultiLineString, { S10b_001: string; // Departure airport S10b_004: string; // Arrival airport S10b_005: number; // Distance S10b_006: number; // Number of flights S10b_007: number; // Number of passengers S10b_008: number; // Total transport volume S10b_009: number; // Cargo volume }>;
// Fetch dataconst response = await fetch("/data/airport-traffic-volume.geojson");const data: AirportTrafficData = await response.json();Data-Driven Color Mapping with ColorMap
Section titled “Data-Driven Color Mapping with ColorMap”Using the ColorMap class, you can map numerical data to colors. Here we assign colors based on the number of flights.
import { Color, ColorMap, geodeticToVector3, degreeToRadian } from "@navaramap/three";
// ref: https://matplotlib.org/stable/users/explain/colors/colormaps.htmlconst PLASMA_COLORMAP = new ColorMap("sequential", "Plasma", [ [0.050383, 0.029803, 0.527975], [0.494877, 0.011990, 0.657865], [0.798216, 0.280197, 0.469538], [0.994324, 0.716681, 0.177208],]);
// Get the maximum number of flights (normalized on a logarithmic scale)const maxTrafficLog = Math.max( ...data.features.map((f) => Math.log(f.properties.S10b_006 + 1)));
// Convert each GeoJSON feature to an arch line definitionconst arcLines = data.features.map((feature) => { const coords = feature.geometry.coordinates[0]; const source = { lng: coords[0][0], lat: coords[0][1] }; const destination = { lng: coords[1][0], lat: coords[1][1] };
// Calculate distance between two points (used for animation speed adjustment) const srcVec = geodeticToVector3({ lat: degreeToRadian(source.lat), lng: degreeToRadian(source.lng), height: 0, }); const destVec = geodeticToVector3({ lat: degreeToRadian(destination.lat), lng: degreeToRadian(destination.lng), height: 0, }); const distance = srcVec.distanceTo(destVec);
// Normalize flight count on a logarithmic scale (0 to 1) const trafficVolume = feature.properties.S10b_006; const trafficVolumeLog = Math.log(trafficVolume + 1); const normalizedTraffic = trafficVolumeLog / maxTrafficLog;
// Get color from ColorMap const [r, g, b] = PLASMA_COLORMAP.linear(normalizedTraffic); const color = new Color().setRGB(r, g, b);
return { thickness: 1.2, transparent: true, opacity: 0.3, segments: 64, height: 0, arcHeightScale: 0.3, srcColor: color, tgtColor: color, dashed: true, dashSize: 500000, dashOffset: Math.random() * 1000000, // Random initial offset gapSize: 800000, geometry: [source, destination], distance, // Used for animation speed calculation };});Adding the Arch Line Object
Section titled “Adding the Arch Line Object”Add the arch line definitions as an object.
import type { ArclineMeshDesc } from "@navaramap/three_default_descs";
const arcLineHandle = view.addMesh<ArclineMeshDesc>({ arcLines,});
Adding Dash Animation
Section titled “Adding Dash Animation”Use requestAnimationFrame to update the dash offset and express flow directionality. Adjusting the animation speed based on distance creates a more natural appearance.
// Dash animation - flows from origin to destinationconst dashAnimFunc = () => { arcLines.forEach((arcLineDef) => { // Calculate animation speed based on distance const baseSpeed = 5000; const distance = arcLineDef.distance || 1; const speedMultiplier = Math.sqrt(distance / 2000000); const speed = baseSpeed * speedMultiplier;
arcLineDef.dashOffset = (arcLineDef.dashOffset ?? 0) + speed; });
arcLineHandle.update({ arcLines }); requestAnimationFrame(dashAnimFunc);};
// Start animationdashAnimFunc();Complete Example
Section titled “Complete Example”Below is a complete example that visualizes air traffic volume between airports.
import ThreeView, { Color, ColorMap, geodeticToVector3, degreeToRadian,} from "@navaramap/three";import { ToneMappingMode, type ArclineMeshDesc, type GlowGlobeMeshDesc,} from "@navaramap/three_default_descs";import { DefaultPlugin, type DefaultDescriptions } from "@navaramap/three_default_plugin";import type { FeatureCollection, MultiLineString } from "geojson";
// Type definition for air traffic volume datatype AirportTrafficData = FeatureCollection< MultiLineString, { S10b_001: string; S10b_004: string; S10b_005: number; S10b_006: number; S10b_007: number; S10b_008: number; S10b_009: number; }>;
// ref: https://matplotlib.org/stable/users/explain/colors/colormaps.htmlconst PLASMA_COLORMAP = new ColorMap("sequential", "Plasma", [ [0.050383, 0.029803, 0.527975], [0.494877, 0.011990, 0.657865], [0.798216, 0.280197, 0.469538], [0.994324, 0.716681, 0.177208],]);
// Construct dataconst constructData = async () => { const response = await fetch("/data/airport-traffic-volume.geojson"); const data: AirportTrafficData = await response.json();
const maxTrafficLog = Math.max( ...data.features.map((f) => Math.log(f.properties.S10b_006 + 1)) );
const arcLines = data.features.map((feature) => { const coords = feature.geometry.coordinates[0]; const source = { lng: coords[0][0], lat: coords[0][1] }; const destination = { lng: coords[1][0], lat: coords[1][1] };
const srcVec = geodeticToVector3({ lat: degreeToRadian(source.lat), lng: degreeToRadian(source.lng), height: 0, }); const destVec = geodeticToVector3({ lat: degreeToRadian(destination.lat), lng: degreeToRadian(destination.lng), height: 0, }); const distance = srcVec.distanceTo(destVec);
const trafficVolume = feature.properties.S10b_006; const trafficVolumeLog = Math.log(trafficVolume + 1); const normalizedTraffic = trafficVolumeLog / maxTrafficLog;
const [r, g, b] = PLASMA_COLORMAP.linear(normalizedTraffic); const color = new Color().setRGB(r, g, b);
return { thickness: 1.2, transparent: true, opacity: 0.3, segments: 64, height: 0, arcHeightScale: 0.3, srcColor: color, tgtColor: color, dashed: true, dashSize: 500000, dashOffset: Math.random() * 1000000, gapSize: 800000, geometry: [source, destination], distance, }; });
return { arcLines };};
// Main functionasync function run() { const plugin = new DefaultPlugin(); const view = new ThreeView<DefaultDescriptions>({ backgroundColor: new Color().setStyle("#0b0a0d"), }); view.addPlugin(plugin);
await view.init();
view.atmosphere.date.setHours(8); view.toneMappingExposure = 10;
// Ambient light view.addLight({ ambient: {}, });
// Starfield view.addMesh({ stars: { intensity: 100, pointSize: 1.5, }, });
// Tone mapping view.addEffect({ toneMapping: { mode: ToneMappingMode.REINHARD2, }, });
// Anti-aliasing view.addEffect({ smaa: { quality: "ultra", }, });
// Satellite imagery tiles const satelliteSource = view.addSource({ type: "raster-tile", // Credit: // - Geospatial Information Authority of Japan Tiles - Latest Nationwide Photo (Seamless) // https://maps.gsi.go.jp/development/ichiran.html url: "https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg", maxZoom: 6, minZoom: 2, }); view.addLayer({ type: "raster", source: satelliteSource, });
// Night tiles (optional) const nightSource = view.addSource({ type: "raster-tile", // Credit: // - NASA Earth at Night imagery (Converted as raster tiles) url: "/data/blue-marble-night/{z}/{x}/{y}.webp", maxZoom: 6, minZoom: 1, }); view.addLayer({ type: "raster", source: nightSource, raster: { opacity: 0.8, }, });
// Glow effect view.addMesh<GlowGlobeMeshDesc>({ glowGlobe: { radiusScale: 1.2, coefficient: 0.43, exponent: 40.0, glowColor: new Color().setStyle("#938cff"), opacity: 0.5, }, });
// Construct arch line data const { arcLines } = await constructData();
// Add arch line object const arcLineHandle = view.addMesh<ArclineMeshDesc>({ arcLines, });
// Dash animation const dashAnimFunc = () => { arcLines.forEach((arcLineDef) => { const baseSpeed = 5000; const distance = arcLineDef.distance || 1; const speedMultiplier = Math.sqrt(distance / 2000000); const speed = baseSpeed * speedMultiplier;
arcLineDef.dashOffset = (arcLineDef.dashOffset ?? 0) + speed; });
arcLineHandle.update({ arcLines }); requestAnimationFrame(dashAnimFunc); }; dashAnimFunc();
// Camera settings view.setCamera({ lng: 140, lat: 20, height: 12_600_000, heading: 0, pitch: -90, roll: 0 });}
run();