A runway is one line and two fractions
The timeline in Chronotopia is continuous. While you drag it, the year can be 1964.37, and the scene has to be right for that year: which buildings are there, how tall they are so far, how long each runway is. Most features only need two numbers for that, the year they were built and the year they were demolished. Runways need more, because they don’t appear and disappear: they get longer, sometimes at both ends, and some of their future length is only a plan.
This article is about the small data model that handles that, the one function that turns it into a length for any year, and the things it can’t represent. The ground the runways sit on is the subject of laying an airport on real ground.
One centreline, many extents
A runway is a straight line between two points, its centreline, plus a list of phases. Each phase says from which year the paved part runs from fraction a to fraction b of that line. From src/types.ts:
export interface RunwayPhase {
y: number;
a: number;
b: number;
ref?: string;
proposed?: boolean;
approx?: boolean;
}
ref is the designation painted on it and shown on its label, so a rename is just a phase with the same extent and a new ref. proposed marks a phase that is only planned, and approx a year we estimated.
Here is El Prat’s main runway, from scripts/sites/barcelona-elprat.mjs. The centreline is drawn to the full length of today’s pavement, and the older phases end where the pavement ended in the orthophotos of each period:
{
id: 'rwy-06L-24R', kind: 'runway', width: 60, geometry: line(R06L),
phases: [
{ y: 1948, a: 0, b: tAtLon(R06L, 2.0900), ref: '07/25' },
{ y: 1965, a: 0, b: tAtLon(R06L, 2.1000), ref: '07/25' },
{ y: 2004, a: 0, b: 1, ref: '07L/25R' },
{ y: 2022, a: 0, b: 1, ref: '06L/24R' },
],
tAtLon() returns the fraction of the line at a given longitude, so a phase can be written as “up to 2.09° E”, which is how you read it off a photo. With the centreline’s 3,742 m, those phases are:
| Year | b | Paved length | Label |
|---|---|---|---|
| 1948 | 0.662 | 2,479 m | 07/25 |
| 1965 | 0.908 | 3,397 m | 07/25 |
| 2004 | 1 | 3,742 m | 07L/25R |
| 2022 | 1 | 3,742 m | 06L/24R (renamed for magnetic variation) |
At Ibiza the sources give lengths instead of positions, so the phases are written in metres over the centreline length: b: 1180 / L0624, then 2000 / L0624, then 1. Either way, the model stores a fraction, and the fraction doesn’t care what the line’s length is in metres.
All runways in the model are drawn by hand. The script that imports OpenStreetMap drops OSM’s runways, because OSM only has today’s, and a runway is exactly the feature whose history we want.
Growing between phases
To draw a runway for a given year, Runway.extentAt() walks its phases in order. Each phase blends in over the year before its y, starting from the extent the previous phase left, so the 1965 extension grows through 1964 and is complete in 1965. From src/engine/runway.ts:
static extentAt(phases: RunwayPhase[], year: number, demolished?: number) {
const built = phases.filter((p) => !p.proposed);
let a = 0;
let b = 0;
let vis = 0;
let ref = phases[0]?.ref ?? '';
for (let i = 0; i < phases.length; i++) {
const p = phases[i];
const t = smooth01(year - (p.y - 1));
if (t <= 0) break;
if (i === 0) {
// First paving: grow from the start end.
a = p.a;
b = p.a + (p.b - p.a) * t;
vis = t;
} else {
a += (p.a - a) * t;
b += (p.b - b) * t;
}
if (t >= 1 && p.ref) ref = p.ref;
}
const lastBuilt = built.length ? built[built.length - 1] : { a: 0, b: 0 };
if (demolished !== undefined) vis *= 1 - smooth01(year - (demolished - 1));
// Proposed growth can happen at either end: [proposedTo, proposedFrom] is the built part.
return { a, b, proposedFrom: lastBuilt.b, proposedTo: lastBuilt.a, visible: vis, ref };
}
smooth01 is the smoothstep curve, t²(3 − 2t), so an extension starts slowly, speeds up and settles, rather than sliding at constant speed. The label only changes when a phase is complete. The first phase also fades the runway in, and a demolition fades it out without shortening it.
When the timeline plays, it moves at half a year per second, and at 0.3 years per second within 1.2 years of a milestone. Most runway phases fall on milestone years, so a runway extension takes about three seconds to grow on screen.
The extent drives everything else about the runway. The mesh is one flat rectangle, moved and stretched to cover a to b of the centreline. The markings are computed in the fragment shader in metres from the current ends: threshold stripes 6 to 40 m in, aiming-point blocks 300 to 345 m in, 30 m centreline dashes. So when a runway grows, its thresholds move with it. The edge lights are rebuilt whenever the extent changes by more than a thousandth of the line. And the traffic uses it too: arrivals use the longest runway in service that year, and only its built part.
Planned extensions, at both ends
Runway 06R/24L at El Prat is about 2,660 m long today. The 2025 agreement extends it by 500 m, 60 m at the west end towards El Remolar and 440 m at the east end towards La Ricarda. In the model that’s one more phase, with a fraction below zero and one above one:
{ y: 2004, a: 0, b: 1, ref: '07R/25L' },
{ y: 2022, a: 0, b: 1, ref: '06R/24L' },
// Approved plan: +60 m at the Remolar (WSW) end and +440 m at the La Ricarda (ENE) end.
{ y: 2036, a: -60 / L06R, b: 1 + 440 / L06R, ref: '06R/24L', proposed: true },
extentAt() returns the built part separately, as proposedTo and proposedFrom: the ends of the last phase that isn’t proposed. The shader hatches whatever lies outside that range, in 45° blue stripes 7 m wide, with no texture and no extra geometry. Hatching both ends needed proposedTo, which came in commit 2c018f5; before that, only growth beyond b could be hatched.
Other planned works use the same colour in two ways: planned buildings are translucent blue boxes with outlined edges, at 42% opacity, and planned aprons get a hatch texture tiled every 40 m.
Everything else only needs two dates
For buildings, aprons, roads and the rest, a single function gives how present a feature is in a given year, from 0 to 1. From src/engine/timeline.ts:
export function presence(year: number, built?: number, demolished?: number): number {
let v = built === undefined ? 1 : smooth01(year - (built - 1));
if (demolished !== undefined) v *= 1 - smooth01(year - (demolished - 1));
return v;
}
Flat features fade in with that value. Buildings don’t fade: they grow up out of the ground over their last year, and sink back into it when they’re demolished, which reads better than a translucent terminal.
Thousands of features share the same dates, because most are dated by zone. Features without a name that share their kind, build year, demolition year and status are merged into one mesh, so a whole group of 1975 roads fades in as a single draw call. A small index from triangle to feature keeps them clickable one by one.
The two highlighting modes are built on presence() as well. What’s new compares the current year with the previous milestone and tints in orange whatever wasn’t there. The Changes view compares two years: present in B but not in A is green, present in A but not in B is red. A runway that exists in both years is tinted a lighter green over its whole length if it grew by more than 2% of its centreline.
What one line can’t say
- Width is fixed. Each runway feature has one width for all its years. El Prat’s main runway is drawn 60 m wide from 1948, although it was 45 m until it was widened in 2004. Its card gives both.
- A runway can’t move or turn. When a strip was replaced by a runway on a different line, or by a paved one on the same line, the model has two features, one demolished as the other is built: the 1940s earth strip at El Prat and the runway that replaced it in 1950, or Ibiza’s 800 m earth strip, which is drawn on the line of the later runway because its exact course is unknown.
- Surface is per feature. An unpaved strip that is later paved is also two features, since
surfaceisn’t part of a phase. - Lengths are pavement, not declared distances. The phases follow the pavement in the photos, so El Prat’s main runway is 3,397 m long in 1965, while its declared length was 3,108 m. Displaced thresholds aren’t part of the runway at all; the approach-lights feature handles them.
- A runway can’t be partly demolished. El Prat’s 16-34 was a taxiway by 2003 but is drawn as a runway until it’s removed in 2005.
- Nothing gets shorter. The hatch compares the extent with the last built phase overall, not the last one before the current year. No runway at El Prat or Ibiza has ever been shortened, but if one were, the code would hatch the part that was lost as if it were planned.
For El Prat and Ibiza, with seven and two runway features, one line and a list of phases has been enough. Each exception above is handled with a second feature or a note in the runway’s card, and the card is where to look when a runway in the model doesn’t match what you remember.