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:

YearbPaved lengthLabel
19480.6622,479 m07/25
19650.9083,397 m07/25
200413,742 m07L/25R
202213,742 m06L/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 },

Runway 06R/24L as three phases on one centreline: 2004 and 2022 cover 0 to 2,659 m, and the proposed 2036 phase adds hatched ends from −60 m (a = −0.023) to 3,099 m (b = 1.165)

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

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.