Learn Creative Coding (#83) - Geographic Data Art
Learn Creative Coding (#83) - Geographic Data Art

Last episode we mapped data to visual properties -- position, size, color, opacity, shape, rotation. We built a complete vocabulary of visual channels and learned how to encode multiple data dimensions simultaneously. The map() function became our best friend. Same dataset, different mappings, completely different artwork. But all our data was generic -- fake weather, fake cities, fake sleep logs. Numbers without geography.
This episode is about geography. Data with coordinates. Latitude and longitude. The shape of the earth, flattened onto your canvas. Countries, cities, earthquakes, flight paths, GPS traces -- anything that lives on a map can become visual material for creative coding. And maps are inherently beautiful. Even a bare outline of coastlines has a kind of fractal elegance that no randomly generated shape can match. When you layer data onto that geographic skeleton, the results carry a sense of place that abstract visualizations can't.
We touched on geographic data briefly in episode 81 when we loaded JSON files and mentioned GeoJSON. Now we're going deep. Projections, boundaries, point data, connection lines, choropleths, and abstract geographic art where coordinates become inputs to generative algorithms.
The earth is round, your canvas is flat
This is the fundamental problem. The earth is a sphere (roughly). Your canvas is a rectangle. Transferring shapes from a sphere to a flat surface always introduces distortion. Always. There is no perfect way to do it. Every map projection is a compromise -- it preserves some property (area, shape, distance, direction) at the expense of others.
For creative coding, the simplest projection is equirectangular: longitude maps directly to x, latitude maps directly to y. That's it.
function projectEquirectangular(lon, lat, width, height) {
const x = ((lon + 180) / 360) * width;
const y = ((90 - lat) / 180) * height;
return { x, y };
}
// Antwerp: lon 4.40, lat 51.22
// on an 800x400 canvas:
const antwerp = projectEquirectangular(4.40, 51.22, 800, 400);
// x = ((4.40 + 180) / 360) * 800 = 409.8
// y = ((90 - 51.22) / 180) * 400 = 86.2
Longitude ranges from -180 (west) to +180 (east). Latitude ranges from -90 (south pole) to +90 (north pole). The mapping is linear. Simple, fast, and good enough for most creative coding. The distortion: areas near the poles are stretched horizontally. Greenland looks wider than it should. Antarctica is a massive band across the bottom. For a data art piece this usually doesn't matter -- you're not making a navigation chart.
GeoJSON: the shape of geography
GeoJSON is the standard format for geographic data in the browser. It's just JSON with a specific structure. A GeoJSON file contains a FeatureCollection, which is an array of Features. Each Feature has a geometry (coordinates) and properties (metadata like name, population, whatever).
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[[4.0, 51.0], [4.5, 51.0], [4.5, 51.5], [4.0, 51.5], [4.0, 51.0]]
]
},
"properties": {
"name": "Some Region",
"population": 529000
}
}
]
}
The geometry types you'll see most: Point (a single coordinate), LineString (a series of connected coordinates), Polygon (a closed ring of coordinates), and MultiPolygon (multiple polygons -- countries with islands). Coordinates are always [longitude, latitude]. Not [lat, lon]. This trips people up constantly. If your map looks mirrored or your cities are in the wrong ocean, check the coordinate order.
Where do you get GeoJSON files? Natural Earth (naturalearthdata.com) provides free datasets at multiple scales -- country boundaries, coastlines, rivers, cities, everything. The 110m (low resolution) dataset is perfect for creative coding: small file size, fast to render, enough detail for visual impact. For higher fidelity theres 50m and 10m versions.
Drawing country boundaries
Let's render a world map. Load a GeoJSON file of country boundaries and draw each polygon on canvas:
const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 450;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
// helper: equirectangular projection
function project(lon, lat) {
return {
x: ((lon + 180) / 360) * 900,
y: ((90 - lat) / 180) * 450
};
}
async function drawWorld() {
const response = await fetch('data/countries-110m.geojson');
const geo = await response.json();
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 900, 450);
for (const feature of geo.features) {
const geom = feature.geometry;
let polygons = [];
if (geom.type === 'Polygon') {
polygons = [geom.coordinates];
} else if (geom.type === 'MultiPolygon') {
polygons = geom.coordinates;
}
for (const polygon of polygons) {
// first ring is the outer boundary
const ring = polygon[0];
ctx.beginPath();
for (let i = 0; i < ring.length; i++) {
const p = project(ring[i][0], ring[i][1]);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.closePath();
ctx.fillStyle = 'rgba(40, 50, 70, 0.6)';
ctx.fill();
ctx.strokeStyle = 'rgba(80, 100, 140, 0.4)';
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
drawWorld();
The key detail: a GeoJSON Polygon's coordinates are an array of rings. The first ring is the outer boundary. Any additional rings are holes (like Lesotho inside South Africa). A MultiPolygon has multiple sets of rings -- one per disconnected land mass. Indonesia, for example, is a MultiPolygon with thousands of islands.
The beginShape/vertex pattern translates to beginPath/moveTo/lineTo in canvas. For each ring, move to the first point and line to each subsequent one. Close the path. Fill and stroke. That's your world map.
Point data on maps
Points are the simplest geographic visualization: a dot at each coordinate. Cities, earthquakes, airports, bird sightings -- anything with a lat/lon pair. The interesting part is what you map to the dot's visual properties.
async function drawEarthquakes() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 900, 450);
// draw country outlines first (from previous example)
// ... drawWorld() code here ...
// earthquake data: USGS provides GeoJSON feeds
// we'll use inline data for this example
const quakes = [
{ lon: 142.37, lat: 38.30, mag: 9.1, depth: 29, name: 'Tohoku 2011' },
{ lon: -72.90, lat: -36.12, mag: 8.8, depth: 22, name: 'Chile 2010' },
{ lon: 95.98, lat: 3.30, mag: 9.1, depth: 30, name: 'Sumatra 2004' },
{ lon: -73.05, lat: -36.20, mag: 8.3, depth: 25, name: 'Maule 2010' },
{ lon: 70.95, lat: 36.04, mag: 7.5, depth: 210, name: 'Afghanistan 2015' },
{ lon: 144.96, lat: -5.75, mag: 7.6, depth: 66, name: 'Papua 2018' },
{ lon: 28.15, lat: 37.17, mag: 7.0, depth: 7, name: 'Turkey 2020' },
{ lon: -70.77, lat: -33.45, mag: 6.9, depth: 50, name: 'Santiago' },
{ lon: 141.90, lat: 39.03, mag: 7.3, depth: 53, name: 'Japan 2021' },
{ lon: 121.57, lat: 23.85, mag: 7.4, depth: 15, name: 'Taiwan 2024' },
{ lon: -155.51, lat: 19.42, mag: 6.9, depth: 12, name: 'Hawaii 2018' },
{ lon: 131.07, lat: -0.04, mag: 7.5, depth: 18, name: 'Indonesia 2009' }
];
const maxMag = Math.max(...quakes.map(q => q.mag));
for (const q of quakes) {
const p = project(q.lon, q.lat);
// size from magnitude (area-proportional, like ep082 taught us)
const area = ((q.mag - 5) / (maxMag - 5)) * 2000;
const r = Math.sqrt(area / Math.PI);
// color from depth: shallow = red, deep = blue
const hue = (q.depth / 250) * 220;
// outer glow
ctx.beginPath();
ctx.arc(p.x, p.y, r + 4, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.15)`;
ctx.fill();
// inner circle
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`;
ctx.fill();
}
}
drawEarthquakes();
Three data dimensions encoded simultaneously: position from coordinates (lon/lat -> x/y), size from magnitude (area-proportional -- we learned this in ep082, remember the Math.sqrt(area / Math.PI) trick), color from depth (shallow quakes are red-orange, deep subduction zone quakes are blue). The glow ring adds visual emphasis without adding data -- it just makes the points more readable against the map background.
If you used the actual USGS earthquake feed (they publish GeoJSON at earthquake.usgs.gov), you'd have thousands of quakes. At that scale the individual points overlap and you start seeing the Ring of Fire emerge as a dense band of dots around the Pacific. The pattern is the data's story, not any individual point.
Connection maps: lines between places
Lines on maps show relationships between locations. Flight routes, trade flows, migration paths, communication networks. Each line connects an origin to a destination. Straight lines work but curved lines look much better -- they suggest the arc of travel across the earth's surface.
const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 450;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
function project(lon, lat) {
return {
x: ((lon + 180) / 360) * 900,
y: ((90 - lat) / 180) * 450
};
}
// flight routes from Brussels
const routes = [
{ to: { lon: -73.94, lat: 40.64 }, name: 'New York' },
{ to: { lon: 139.69, lat: 35.68 }, name: 'Tokyo' },
{ to: { lon: 55.27, lat: 25.25 }, name: 'Dubai' },
{ to: { lon: -3.70, lat: 40.42 }, name: 'Madrid' },
{ to: { lon: 28.98, lat: 41.01 }, name: 'Istanbul' },
{ to: { lon: -43.17, lat: -22.91 }, name: 'Rio' },
{ to: { lon: 37.62, lat: 55.76 }, name: 'Moscow' },
{ to: { lon: 18.42, lat: -33.92 }, name: 'Cape Town' }
];
const brussels = { lon: 4.35, lat: 50.85 };
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 900, 450);
// draw routes as quadratic bezier curves
const origin = project(brussels.lon, brussels.lat);
for (const route of routes) {
const dest = project(route.to.lon, route.to.lat);
// control point: midpoint lifted upward (shorter curve = less lift)
const midX = (origin.x + dest.x) / 2;
const midY = (origin.y + dest.y) / 2;
const dist = Math.sqrt((dest.x - origin.x) ** 2 + (dest.y - origin.y) ** 2);
const lift = dist * 0.3;
ctx.beginPath();
ctx.moveTo(origin.x, origin.y);
ctx.quadraticCurveTo(midX, midY - lift, dest.x, dest.y);
ctx.strokeStyle = 'rgba(100, 180, 255, 0.35)';
ctx.lineWidth = 1.5;
ctx.stroke();
// destination dot
ctx.beginPath();
ctx.arc(dest.x, dest.y, 3, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(100, 180, 255, 0.7)';
ctx.fill();
}
// origin dot (Brussels)
ctx.beginPath();
ctx.arc(origin.x, origin.y, 5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 200, 100, 0.9)';
ctx.fill();
The quadratic bezier curve bows upward from the origin to the destination. The lift amount is proportional to the distance -- long routes get taller arcs, short routes stay flatter. This visually suggests the great circle path of actual flight routes. It's not geographically accurate (a real great circle arc on a Mercator-style map would follow a different curve) but it looks right and that's what matters for creative coding.
When you have hundreds of routes, the overlapping arcs create a web structure. Dense hubs have thick clusters of lines converging on them. Remote destinations have isolated arcs reaching out. The network topology becomes visible through the density of the curves.
Choropleth maps: coloring regions by data
A choropleth colors each geographic region by a data value. Population density, GDP, average temperature, election results -- any value that varies by region. It's the classic thematic map, and also the most abused. Bad choropleths are everywhere. Good ones are rare.
async function drawChoropleth() {
const response = await fetch('data/countries-110m.geojson');
const geo = await response.json();
// fake population density data (people per km2)
const densityData = {
'Belgium': 376, 'Netherlands': 521, 'Germany': 240,
'France': 119, 'Spain': 94, 'Italy': 206,
'United Kingdom': 281, 'Poland': 124, 'Sweden': 25,
'Norway': 15, 'Finland': 18, 'Portugal': 112,
'Switzerland': 219, 'Austria': 109, 'Denmark': 137,
'Ireland': 72, 'Czech Republic': 139, 'Romania': 84,
'Greece': 80, 'Hungary': 105
};
const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 450;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 900, 450);
const maxDensity = 521; // Netherlands
for (const feature of geo.features) {
const name = feature.properties.name;
const density = densityData[name];
const geom = feature.geometry;
let polygons = [];
if (geom.type === 'Polygon') polygons = [geom.coordinates];
else if (geom.type === 'MultiPolygon') polygons = geom.coordinates;
for (const polygon of polygons) {
const ring = polygon[0];
ctx.beginPath();
for (let i = 0; i < ring.length; i++) {
const p = project(ring[i][0], ring[i][1]);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.closePath();
if (density !== undefined) {
// log scale for density (range is huge: 15 to 521)
const logNorm = (Math.log10(density) - Math.log10(10)) /
(Math.log10(maxDensity) - Math.log10(10));
const t = Math.max(0, Math.min(1, logNorm));
// single-hue gradient: light blue to dark blue
const lightness = 70 - t * 45;
ctx.fillStyle = `hsl(210, 50%, ${lightness}%)`;
} else {
ctx.fillStyle = 'rgba(30, 35, 45, 0.5)';
}
ctx.fill();
ctx.strokeStyle = 'rgba(60, 70, 90, 0.5)';
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
Countries with density data get colored on a blue gradient -- light blue for sparse (Norway, 15/km2) to dark blue for dense (Netherlands, 521/km2). Countries without data get a neutral dark fill. The log scale is important here because the density range is huge -- without it, only the Netherlands and Belgium would look different from the rest. We covered log scaling in ep081 for exactly this kind of skewed distribution.
The common choropleth mistake: using area-weighted maps to show population. Large countries (Russia, Canada, Brazil) dominate the visual even when their values are low. Small but significant countries (Netherlands, Belgium, Singapore) are nearly invisible. For creative coding this might not matter -- you're making art, not policy briefings. But it's worth knowing the bias so you can choose to use it or subvert it deliberately.
GPS traces: your personal geography
This is where geographic data gets intimate. Record your own movements with a phone GPS app (Strava, GPX Logger, Google Timeline export) and draw your paths on a map. Your commute, your running routes, your weekend walks -- the accumulated traces paint a portrait of how you inhabit your city.
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 800;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
// simulate a week of GPS traces around Antwerp
// in real life you'd load GPX data converted to JSON
function generateTrace(centerLon, centerLat, spread, points) {
const trace = [];
let lon = centerLon + (Math.random() - 0.5) * spread;
let lat = centerLat + (Math.random() - 0.5) * spread;
for (let i = 0; i < points; i++) {
lon += (Math.random() - 0.5) * 0.003;
lat += (Math.random() - 0.5) * 0.003;
// gentle pull toward center
lon += (centerLon - lon) * 0.01;
lat += (centerLat - lat) * 0.01;
trace.push([lon, lat]);
}
return trace;
}
// Antwerp center: 4.40, 51.22
const traces = [];
for (let day = 0; day < 7; day++) {
traces.push(generateTrace(4.40, 51.22, 0.05, 200 + Math.floor(Math.random() * 150)));
}
// find bounds for zoomed-in projection
const allPoints = traces.flat();
const lons = allPoints.map(p => p[0]);
const lats = allPoints.map(p => p[1]);
const minLon = Math.min(...lons) - 0.005;
const maxLon = Math.max(...lons) + 0.005;
const minLat = Math.min(...lats) - 0.005;
const maxLat = Math.max(...lats) + 0.005;
function projectLocal(lon, lat) {
return {
x: ((lon - minLon) / (maxLon - minLon)) * 780 + 10,
y: ((maxLat - lat) / (maxLat - minLat)) * 780 + 10
};
}
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 800);
const dayColors = [
'rgba(255, 100, 100, 0.12)',
'rgba(100, 200, 255, 0.12)',
'rgba(255, 200, 100, 0.12)',
'rgba(100, 255, 150, 0.12)',
'rgba(200, 130, 255, 0.12)',
'rgba(255, 150, 200, 0.12)',
'rgba(130, 200, 200, 0.12)'
];
for (let d = 0; d < traces.length; d++) {
const trace = traces[d];
ctx.strokeStyle = dayColors[d];
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < trace.length; i++) {
const p = projectLocal(trace[i][0], trace[i][1]);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
}
Seven traces, one per day, each a different color at very low opacity. Where paths overlap (your daily commute route, the walk to the bakery), the lines accumulate and the color intensifies. Areas you visit once stay faint. Areas you pass through daily become bright threads. The density map of your own life emerges from the overlapping semi-transparent paths. :-)
With real GPS data this is genuinely moving. You can see your habits, your routines, the places you keep returninng to. The paths between home and work form a thick corridor. Weekend traces wander more freely. There's a philosophical dimension here -- your geographic footprint is a kind of portrait that no other dataset captures.
Abstract geographic art
Here's where we stop making maps and start using geography as creative input. Take geographic coordinates and feed them into generative algorithms. Country centroids as Voronoi seeds. Coastline points as particle emitter positions. River coordinates as flow field guides. The geographic data provides structure, but the output doesn't have to look like a map.
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 800;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
// european city coordinates as seeds for a voronoi-like structure
const cities = [
{ lon: 4.40, lat: 51.22 }, // Antwerp
{ lon: 4.35, lat: 50.85 }, // Brussels
{ lon: 2.35, lat: 48.86 }, // Paris
{ lon: -3.70, lat: 40.42 }, // Madrid
{ lon: 12.50, lat: 41.90 }, // Rome
{ lon: 13.41, lat: 52.52 }, // Berlin
{ lon: -0.12, lat: 51.51 }, // London
{ lon: 23.72, lat: 37.97 }, // Athens
{ lon: 14.42, lat: 50.08 }, // Prague
{ lon: 16.37, lat: 48.21 }, // Vienna
{ lon: 21.01, lat: 52.23 }, // Warsaw
{ lon: 18.07, lat: 59.33 }, // Stockholm
{ lon: -9.14, lat: 38.74 } // Lisbon
];
// project into canvas space
const projected = cities.map(c => ({
x: ((c.lon + 10) / 35) * 780 + 10,
y: ((60 - c.lat) / 25) * 780 + 10
}));
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 800);
// for each pixel, find the two closest city seeds
// draw intensity based on difference in distances (voronoi edge detection)
const imageData = ctx.getImageData(0, 0, 800, 800);
const pixels = imageData.data;
for (let y = 0; y < 800; y++) {
for (let x = 0; x < 800; x++) {
let minDist = Infinity;
let secondDist = Infinity;
let closestIdx = 0;
for (let i = 0; i < projected.length; i++) {
const dx = x - projected[i].x;
const dy = y - projected[i].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < minDist) {
secondDist = minDist;
minDist = dist;
closestIdx = i;
} else if (dist < secondDist) {
secondDist = dist;
}
}
const edgeness = 1.0 - Math.min(1.0, (secondDist - minDist) / 30);
const cellColor = (closestIdx * 47) % 360;
const idx = (y * 800 + x) * 4;
// subtle cell fill
const hue = cellColor;
const h = hue / 360;
const s = 0.4;
const l = 0.08 + edgeness * 0.25;
// HSL to RGB (simplified)
const c = (1 - Math.abs(2 * l - 1)) * s;
const hp = h * 6;
const xc = c * (1 - Math.abs(hp % 2 - 1));
let r1 = 0, g1 = 0, b1 = 0;
if (hp < 1) { r1 = c; g1 = xc; }
else if (hp < 2) { r1 = xc; g1 = c; }
else if (hp < 3) { g1 = c; b1 = xc; }
else if (hp < 4) { g1 = xc; b1 = c; }
else if (hp < 5) { r1 = xc; b1 = c; }
else { r1 = c; b1 = xc; }
const m = l - c / 2;
pixels[idx] = Math.floor((r1 + m) * 255);
pixels[idx + 1] = Math.floor((g1 + m) * 255);
pixels[idx + 2] = Math.floor((b1 + m) * 255);
pixels[idx + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
// draw seed points
for (let i = 0; i < projected.length; i++) {
ctx.beginPath();
ctx.arc(projected[i].x, projected[i].y, 3, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.fill();
}
The city positions define Voronoi cells -- each pixel belongs to the nearest city seed. The cell boundaries glow where two cities have nearly equal influence. Each cell gets a unique hue derived from the seed index. The result doesn't look like a map at all. It looks like a stained glass window or a cellular structure. But the geometry is geographic -- the cell shapes are determined by the actual spatial relationships between European capitals. Move Madrid and the entire southwestern region reshapes. Add a city and new cells split from existing ones. The geography is the algorithm's input, not its output.
Hexbin maps: discretized density
When you have thousands of points on a map (every earthquake, every airport, every tweet), scatter plots become unreadable. Points overlap, clusters blur together, and the map is just a mess of dots. Hexbin maps solve this: divide the map into a grid of hexagons, count how many points fall in each hex, and color the hex by count.
const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 450;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
function project(lon, lat) {
return {
x: ((lon + 180) / 360) * 900,
y: ((90 - lat) / 180) * 450
};
}
// generate 2000 random "earthquake" points clustered along tectonic boundaries
const points = [];
for (let i = 0; i < 2000; i++) {
let lon, lat;
const r = Math.random();
if (r < 0.4) {
// ring of fire: pacific rim
const angle = Math.random() * Math.PI * 2;
lon = 180 * Math.cos(angle) + (Math.random() - 0.5) * 20;
lat = 20 * Math.sin(angle) + (Math.random() - 0.5) * 15;
} else if (r < 0.7) {
// Mediterranean-Himalayan belt
lon = -10 + Math.random() * 100;
lat = 30 + (Math.random() - 0.5) * 15;
} else {
// mid-atlantic ridge
lon = -30 + (Math.random() - 0.5) * 15;
lat = -40 + Math.random() * 80;
}
points.push(project(lon, lat));
}
// hexagonal binning
const hexSize = 18;
const hexW = hexSize * 2;
const hexH = Math.sqrt(3) * hexSize;
const bins = {};
for (const pt of points) {
// which hex column and row?
const col = Math.floor(pt.x / (hexW * 0.75));
const row = Math.floor(pt.y / hexH - (col % 2) * 0.5);
const key = `${col},${row}`;
bins[key] = (bins[key] || 0) + 1;
}
const maxCount = Math.max(...Object.values(bins));
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 900, 450);
// draw hexagons
for (const [key, count] of Object.entries(bins)) {
const [col, row] = key.split(',').map(Number);
const cx = col * hexW * 0.75 + hexSize;
const cy = row * hexH + (col % 2) * hexH * 0.5 + hexH / 2;
const t = count / maxCount;
const hue = 60 - t * 60; // yellow to red
const lightness = 15 + t * 40;
ctx.beginPath();
for (let a = 0; a < 6; a++) {
const angle = (a / 6) * Math.PI * 2 - Math.PI / 6;
const hx = cx + Math.cos(angle) * (hexSize - 1);
const hy = cy + Math.sin(angle) * (hexSize - 1);
if (a === 0) ctx.moveTo(hx, hy);
else ctx.lineTo(hx, hy);
}
ctx.closePath();
ctx.fillStyle = `hsla(${hue}, 60%, ${lightness}%, 0.8)`;
ctx.fill();
}
Each hexagon aggregates all points that fall within its bounds. Empty hexagons aren't drawn at all -- they stay as background void. Dense hexagons glow red-hot. Sparse ones are dim yellow. The tectonic plate boundaries emerge as bands of bright hexagons: the Ring of Fire around the Pacific, the Mediterranean belt stretching from Spain to Indonesia, the Mid-Atlantic Ridge running north-south.
Hexagons tile the plane without gaps or overlaps, and each hex has six equidistant neighbors (unlike squares which have four edge-neighbors and four corner-neighbors at different distances). This makes hexbin maps visually smoother than square grids. It's a subtle difference but it matters -- hexagonal patterns feel more organic.
Animated geographic data
Geographic data with a time dimension -- earthquake history, pandemic spread, migration over decades -- becomes especially powerful when animated. You can watch patterns evolve over time. The Ring of Fire doesn't just exist, it pulses.
const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 450;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
function project(lon, lat) {
return {
x: ((lon + 180) / 360) * 900,
y: ((90 - lat) / 180) * 450
};
}
// generate a sequence of "events" spread across time
const events = [];
for (let i = 0; i < 400; i++) {
const r = Math.random();
let lon, lat;
if (r < 0.5) {
const angle = Math.random() * Math.PI * 2;
lon = 160 * Math.cos(angle) + (Math.random() - 0.5) * 25;
lat = 15 * Math.sin(angle) + (Math.random() - 0.5) * 20;
} else {
lon = -10 + Math.random() * 90;
lat = 25 + (Math.random() - 0.5) * 20;
}
events.push({
pos: project(lon, lat),
time: i / 400, // normalized 0-1
magnitude: 4 + Math.random() * 5,
age: 0
});
}
let currentTime = 0;
const activeEvents = [];
function drawFrame() {
// semi-transparent overlay for fade trail
ctx.fillStyle = 'rgba(10, 10, 26, 0.06)';
ctx.fillRect(0, 0, 900, 450);
// activate events that have reached current time
while (events.length > 0 && events[0].time <= currentTime) {
const ev = events.shift();
ev.age = 0;
activeEvents.push(ev);
}
// draw and age active events
for (let i = activeEvents.length - 1; i >= 0; i--) {
const ev = activeEvents[i];
ev.age += 0.016;
if (ev.age > 2.0) {
activeEvents.splice(i, 1);
continue;
}
const fade = 1.0 - ev.age / 2.0;
const r = ev.magnitude * 1.5 * fade;
ctx.beginPath();
ctx.arc(ev.pos.x, ev.pos.y, r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, ${Math.floor(100 + ev.magnitude * 15)}, 50, ${fade * 0.6})`;
ctx.fill();
}
currentTime += 0.002;
if (currentTime > 1.0) currentTime = 0;
requestAnimationFrame(drawFrame);
}
drawFrame();
Events appear as expanding, fading circles. New ones pop up as the time cursor advances. Old ones fade to nothing. The animation loops endlessly, cycling through the event sequence. You watch the tectonic boundaries light up in bursts -- a cluster along the Pacific rim, then a flurry through the Mediterranean, then silence, then another burst. The temporal patterns become visible: earthquake swarms, aftershock sequences, quiet periods.
This is the power of animating geographic data. A static map of all 400 events is a cluster of overlapping dots. The animation separates them in time, lets you see the sequence, the rhythm, the spatial-temporal correlations. Where do events cluster together in both space AND time? The animation reveals what a static image hides.
Creative exercise: earthquakes on a map
Allez, time to build something complete. We'll combine country outlines with point data, using the earthquake dataset. Country boundaries as thin structural lines. Earthquakes as circles where size encodes magnitude, color encodes depth, and opacity encodes recency (older quakes fade). Layer them up and watch the tectonic story emerge.
const canvas = document.createElement('canvas');
canvas.width = 960;
canvas.height = 480;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
function project(lon, lat) {
return {
x: ((lon + 180) / 360) * 960,
y: ((90 - lat) / 180) * 480
};
}
// inline earthquake data (significant quakes, varied depths)
const quakes = [
{ lon: 142.37, lat: 38.30, mag: 9.1, depth: 29 },
{ lon: 95.98, lat: 3.30, mag: 9.1, depth: 30 },
{ lon: -72.90, lat: -36.12, mag: 8.8, depth: 22 },
{ lon: 143.05, lat: 37.52, mag: 7.9, depth: 32 },
{ lon: 101.37, lat: 2.09, mag: 8.6, depth: 30 },
{ lon: -70.77, lat: -33.45, mag: 6.9, depth: 50 },
{ lon: 28.15, lat: 37.17, mag: 7.0, depth: 7 },
{ lon: 121.57, lat: 23.85, mag: 7.4, depth: 15 },
{ lon: -155.51, lat: 19.42, mag: 6.9, depth: 12 },
{ lon: 131.07, lat: -0.04, mag: 7.5, depth: 18 },
{ lon: 141.90, lat: 39.03, mag: 7.3, depth: 53 },
{ lon: 70.95, lat: 36.04, mag: 7.5, depth: 210 },
{ lon: -73.05, lat: -36.20, mag: 8.3, depth: 25 },
{ lon: 174.78, lat: -41.51, mag: 7.8, depth: 23 },
{ lon: 93.19, lat: 12.88, mag: 8.6, depth: 26 },
{ lon: 126.74, lat: 1.27, mag: 7.5, depth: 31 },
{ lon: -75.27, lat: -15.39, mag: 7.1, depth: 40 },
{ lon: 67.45, lat: 36.53, mag: 7.3, depth: 190 },
{ lon: 154.52, lat: -6.19, mag: 7.9, depth: 55 },
{ lon: -88.77, lat: 13.35, mag: 7.3, depth: 65 },
{ lon: 144.96, lat: -5.75, mag: 7.6, depth: 66 },
{ lon: 141.30, lat: -6.27, mag: 7.5, depth: 48 },
{ lon: 122.23, lat: 0.73, mag: 7.5, depth: 20 },
{ lon: -71.64, lat: -30.12, mag: 7.6, depth: 62 }
];
ctx.fillStyle = '#060610';
ctx.fillRect(0, 0, 960, 480);
// sort by magnitude so smaller quakes draw on top of larger ones
quakes.sort((a, b) => b.mag - a.mag);
for (const q of quakes) {
const p = project(q.lon, q.lat);
// area-proportional sizing
const area = ((q.mag - 6) / 3.5) * 1800;
const r = Math.sqrt(Math.max(area, 20) / Math.PI);
// depth -> color: shallow (red-orange) to deep (blue-purple)
const depthNorm = Math.min(q.depth / 200, 1.0);
const hue = depthNorm * 240; // 0=red, 240=blue
// outer glow
const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 2);
gradient.addColorStop(0, `hsla(${hue}, 60%, 50%, 0.4)`);
gradient.addColorStop(0.5, `hsla(${hue}, 60%, 50%, 0.1)`);
gradient.addColorStop(1, `hsla(${hue}, 60%, 50%, 0.0)`);
ctx.beginPath();
ctx.arc(p.x, p.y, r * 2, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
// core
ctx.beginPath();
ctx.arc(p.x, p.y, r * 0.6, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 65%, 55%, 0.8)`;
ctx.fill();
}
The large quakes (magnitude 8-9) dominate with wide glowing halos. The smaller quakes (6-7) are compact bright dots. Shallow quakes glow warm red-orange. Deep subduction quakes glow cold blue. The radial gradient creates a soft glow effect that makes the quakes look like heat signatures or energy pulses. You can immediately see the Ring of Fire as a constellation of warm dots circling the Pacific. The deep blue dots in Central Asia mark the Hindu Kush subduction zone where the Indian plate dives under the Eurasian plate at 200+ km depth.
If you layered the country outlines underneath (from our earlier code), the quakes would sit on top of the geography and the spatial relationship would be explicit. But even without the map, the clustering pattern is recognizable -- your brain fills in the continents from the distribution of dots alone. The data contains the geography.
What's coming
We can put data on maps now. Country boundaries, point clouds, connections, choropleths, hexbins, GPS traces, animations. The geographic dimension adds a sense of place that makes data feel grounded in the physical world. And the abstract approach -- using geography as input to generative algorithms -- opens up a whole space where the map is a creative constraint, not a literal representation.
But geography is just one kind of spatial organization for data. Time is another. Temporal datasets -- stock prices, weather over months, heartbeat rhythms, music -- have their own visual languages. Timelines, spirals, calendars, rhythmic patterns. How you represent the flow of time on a canvas is a creative decision with as much weight as how you represent space. That's the direction we're heading.
't Komt erop neer...
- The earth is round, your canvas is flat. The equirectangular projection is the simplest:
x = ((lon + 180) / 360) * width,y = ((90 - lat) / 180) * height. It distorts area near the poles (Greenland looks too wide) but works well for creative coding where geographic accuracy matters less than visual impact - GeoJSON is the standard format for geographic data in the browser. A FeatureCollection contains Features, each with a geometry (Point, LineString, Polygon, MultiPolygon) and properties. Coordinates are always
[longitude, latitude]-- not lat/lon. Natural Earth (naturalearthdata.com) provides free datasets at multiple scales - Drawing country boundaries: iterate the GeoJSON features, extract polygon coordinate rings, draw with
beginPath/moveTo/lineTo/closePath. MultiPolygons have multiple rings (islands). The first ring in each polygon is the outer boundary, additional rings are holes - Point data on maps: project each coordinate, draw a circle. Map data dimensions to visual channels -- size from magnitude (area-proportional,
Math.sqrt(area / Math.PI)), color from depth or category, opacity from recency. At scale (thousands of points), clusters emerge naturally and reveal geographic patterns like the Ring of Fire - Connection maps: curved lines between origin-destination pairs. Quadratic bezier curves with a control point lifted above the midpoint approximate great circle arcs. Longer routes get taller arcs. Dense hubs show as thick clusters of converging curves
- Choropleth maps color regions by data values. Use log scaling for skewed distributions (population density ranges from 15 to 521 per km2). Use single-hue gradients (light to dark blue) for quantitative data -- avoid rainbow colormaps. Large countries visually dominate regardless of their data value, which can mislead
- GPS trace art draws your own movement paths on a map. Semi-transparent overlapping lines accumulate where you travel frequently, creating a density portrait of your habits. Real GPS data from your phone turns this into a deeply personal visualization
- Abstract geographic art uses coordinates as inputs to generative algorithms instead of drawing literal maps. City positions as Voronoi seeds, coastlines as particle emitter shapes, rivers as flow field guides. The output doesn't look like a map but its structure is geographic
- Hexbin maps aggregate dense point data into hexagonal bins. Hex count maps to color intensity. Better than scatter plots for thousands of overlapping points -- reveals density patterns. Hexagons tile smoothly with six equidistant neighbors, giving a more organic feel than square grids
- Animated geographic data reveals spatial-temporal patterns that static maps hide. Events appear as expanding, fading circles. You watch earthquake swarms cluster along plate boundaries, see gaps and bursts. The temporal dimension adds narrative to the geographic structure
Sallukes! Thanks for reading.
X
Your post has been curated by Ecency. Enjoy the upvote!
