# Skyline: Animated City Skyline for React

> A printed city along the foot of a section: halftone towers in the distance, ink towers in front with their windows knocked out, a flat sun and stippled trees. The first time it shows, the towers rise out of the ground and their windows switch on one by one. Give it your numbers and each one becomes a tower with a sign on its roof. Hover a tower and it lights up floor by floor. An elevated line can run across the front, with a train you can send from a button.

Animated City Skyline for React: towers rise and light up, and can chart your data. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/skyline
- Tags: Hero, Chart, Canvas
- npm dependencies: motion, clsx, tailwind-merge
- Inspired by: [Citylines on vibrant.design](https://vibrant.design/p/ezibddx-citylines-transit-landing-page)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/skyline.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/skyline
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/skyline.json
```

## Usage

The main demo shows typical usage.

### Hero City

A flat-hunting app's hero with the city along its foot. "Check the commute" sends a train across the line.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/skyline-demo
```

```tsx title="components/skyline-demo.tsx"
"use client";

import * as React from "react";

import { Skyline } from "@/components/ui/skyline";

export function SkylineDemo() {
  const [trains, setTrains] = React.useState(0);

  return (
    <div className="relative w-full max-w-5xl overflow-hidden rounded-2xl border border-zinc-950/10 bg-[#fafaf9] text-zinc-950 dark:border-white/10 dark:bg-[#07080c] dark:text-zinc-100">
      <div className="relative z-10 mx-auto flex max-w-xl flex-col items-center px-6 pt-12 text-center sm:pt-16">
        <h2 className="font-serif text-4xl leading-[1.02] font-normal tracking-[-0.02em] text-balance sm:text-5xl">
          Rent the flat where the <em className="text-[#2747ff] dark:text-[#8292ff]">evening light</em> comes in
        </h2>
        <p className="mt-4 max-w-md text-[15px] leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
          Westlight maps which way every window faces and how far each door is from the station, so you can pick a
          home for the hours you are actually in it.
        </p>
        <div className="mt-7 flex flex-wrap items-center justify-center gap-2.5">
          <a
            href="#"
            className="inline-flex h-10 items-center gap-1.5 rounded-full bg-zinc-950 px-5 text-sm font-medium text-[#fafaf9] transition-colors hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-white dark:focus-visible:outline-zinc-100"
          >
            Browse west-facing flats
            <svg viewBox="0 0 16 16" fill="none" className="size-3.5" aria-hidden>
              <path d="M3 8h10m-4-4 4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          </a>
          <button
            type="button"
            onClick={() => setTrains((n) => n + 1)}
            className="inline-flex h-10 items-center rounded-full border border-zinc-950/20 px-5 text-sm font-medium transition-[background-color,scale] duration-150 hover:bg-zinc-950/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 active:scale-[0.97] dark:border-white/25 dark:hover:bg-white/5 dark:focus-visible:outline-zinc-100"
          >
            Check the commute
          </button>
        </div>
        <p className="mt-5 font-mono text-[11px] tracking-[0.12em] text-zinc-500 uppercase tabular-nums dark:text-zinc-500">
          1,284 flats <span className="text-[#ff5a1f]">·</span> 9 lines <span className="text-[#ff5a1f]">·</span> updated hourly
        </p>
      </div>
      <Skyline
        seed={12}
        train
        headway={7}
        departures={trains}
        sun={0.64}
        className="mt-6 h-56 [--skyline-sun:#ff5a1f] [--skyline-tint:#4562ff] sm:mt-2 sm:h-72 dark:text-[#1b1f2e] dark:[--skyline-light:#ffd27a] dark:[--skyline-tint:#3149f0]"
      />
    </div>
  );
}
```

## Examples

Finished sections built with Skyline. Each one installs whole, with the component included.

### City Stats

Studio hours booked per city as towers, each with its share on a rooftop sign. Switch the year and the towers grow or shrink to it.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/skyline-stats-demo
```

```tsx title="components/skyline-stats-demo.tsx"
"use client";

import * as React from "react";

import { Skyline, type SkylineTower } from "@/components/ui/skyline";

// Hours of studio time booked through Roomtone, by city.
const YEARS = {
  "2025": [
    { label: "Lisbon", value: 1420 },
    { label: "Berlin", value: 2980 },
    { label: "Lagos", value: 860 },
    { label: "Seoul", value: 3310 },
    { label: "Mexico City", value: 1890 },
    { label: "Toronto", value: 2240 },
    { label: "Melbourne", value: 1310 },
  ],
  "2026": [
    { label: "Lisbon", value: 2160 },
    { label: "Berlin", value: 3120 },
    { label: "Lagos", value: 1940 },
    { label: "Seoul", value: 4050 },
    { label: "Mexico City", value: 2460 },
    { label: "Toronto", value: 2380 },
    { label: "Melbourne", value: 1720 },
  ],
} satisfies Record<string, { label: string; value: number }[]>;

type Year = keyof typeof YEARS;

export function SkylineStatsDemo() {
  const [year, setYear] = React.useState<Year>("2026");
  const total = YEARS[year].reduce((sum, c) => sum + c.value, 0);
  const towers: SkylineTower[] = YEARS[year].map((c) => ({
    ...c,
    display: `${c.value.toLocaleString("en-US")} h · ${Math.round((c.value / total) * 100)}%`,
  }));

  return (
    <div className="w-full max-w-3xl rounded-2xl border border-zinc-950/10 bg-white p-5 text-zinc-900 shadow-sm sm:p-7 dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-100">
      <div className="flex flex-wrap items-end justify-between gap-4">
        <div>
          <h3 className="font-serif text-2xl tracking-[-0.015em] sm:text-[1.75rem]">
            Where the studios were <em className="text-[#2747ff] dark:text-[#8292ff]">busiest</em>
          </h3>
          <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
            Hours booked on Roomtone in {year},{" "}
            <span className="font-medium text-zinc-900 tabular-nums dark:text-zinc-100">
              {total.toLocaleString("en-US")} h
            </span>{" "}
            in all. Hover a city for its share.
          </p>
        </div>
        <div
          role="radiogroup"
          aria-label="Year"
          className="flex rounded-full border border-zinc-950/10 p-0.5 text-sm dark:border-white/10"
        >
          {(Object.keys(YEARS) as Year[]).map((y) => (
            <button
              key={y}
              type="button"
              role="radio"
              aria-checked={year === y}
              onClick={() => setYear(y)}
              className="h-8 rounded-full px-3.5 font-medium tabular-nums text-zinc-500 transition-colors duration-150 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 aria-checked:bg-zinc-900 aria-checked:text-white dark:text-zinc-400 dark:hover:text-zinc-100 dark:focus-visible:outline-zinc-100 dark:aria-checked:bg-zinc-100 dark:aria-checked:text-zinc-900"
            >
              {y}
            </button>
          ))}
        </div>
      </div>
      <Skyline
        towers={towers}
        seed={4}
        sun={0.86}
        trees={false}
        label={`Studio hours booked by city in ${year}`}
        className="mt-6 h-72 text-zinc-900 [--skyline-sun:#ff5a1f] [--skyline-tint:#bcc7ff] sm:h-80 dark:text-zinc-100 dark:[--skyline-sun:#ff6a2b] dark:[--skyline-tint:#202c78]"
      />
    </div>
  );
}
```

### Lights On

A late-night coworking club where every lit window is a member at a desk. Windows switch on and off as people come and go.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/skyline-lights-demo
```

```tsx title="components/skyline-lights-demo.tsx"
"use client";

import * as React from "react";

import { Skyline } from "@/components/ui/skyline";

const DESKS = 240;
const START = 148;

// A night-owl coworking club: every lit window is a member at a desk right now.
export function SkylineLightsDemo() {
  const [here, setHere] = React.useState(START);
  const [joined, setJoined] = React.useState(false);

  // People come and go every few seconds.
  React.useEffect(() => {
    const id = window.setInterval(() => {
      setHere((n) => Math.min(DESKS - 12, Math.max(90, n + Math.round(Math.random() * 14 - 6))));
    }, 2600);
    return () => window.clearInterval(id);
  }, []);

  const count = here + (joined ? 1 : 0);

  return (
    <div className="w-full max-w-3xl overflow-hidden rounded-2xl border border-zinc-950/10 bg-[#fafaf9] text-zinc-950 dark:border-white/10 dark:bg-[#07080c] dark:text-zinc-100">
      <div className="flex flex-wrap items-end justify-between gap-5 px-6 pt-7 sm:px-8 sm:pt-8">
        <div>
          <p className="font-serif text-5xl leading-none tracking-[-0.02em] tabular-nums sm:text-6xl" aria-live="polite">
            {count}
            <span className="ml-2 align-middle font-sans text-sm tracking-normal text-zinc-500 dark:text-zinc-400">
              of {DESKS} desks lit
            </span>
          </p>
          <p className="mt-3 max-w-sm text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
            Lantern Hall stays open until four. Every window up there is a member working tonight.
          </p>
        </div>
        <button
          type="button"
          aria-pressed={joined}
          onClick={() => setJoined((j) => !j)}
          className="inline-flex h-10 items-center gap-2 rounded-full bg-zinc-950 px-5 text-sm font-medium text-[#fafaf9] transition-[background-color,scale] duration-150 hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 active:scale-[0.97] aria-pressed:bg-[#ffb020] aria-pressed:text-zinc-950 dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-white dark:focus-visible:outline-zinc-100 dark:aria-pressed:bg-[#ffd27a]"
        >
          <span
            aria-hidden
            className="size-2 rounded-[1px] bg-current opacity-40 transition-opacity duration-150 [[aria-pressed=true]_&]:opacity-100"
          />
          {joined ? "You're at a desk" : "Take a desk"}
        </button>
      </div>
      <Skyline
        seed={21}
        lights={count / DESKS}
        sun={false}
        className="mt-6 h-64 text-zinc-950 [--skyline-light:#ffbf3f] [--skyline-tint:#c3cdff] sm:h-72 dark:text-[#1b1f2e] dark:[--skyline-light:#ffd27a] dark:[--skyline-tint:#202c78]"
      />
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `towers` | `SkylineTower[]` |  | One tower per number, left to right, labelled under it: `{ label, value, display? }`. The tallest stands nearly as tall as the scene, and `display` is what its sign says (the value with thousands separators by default). Leave it out for a city grown from `seed`. |
| `seed` | `number` | `7` | Reshapes the generated city, the far print, the clouds, the trees and which windows are lit. Any whole number. |
| `lights` | `number` | `0.6` | Share of the windows that are lit, from 0 to 1. A new value switches them on or off one at a time. |
| `sun` | `boolean \| number` | `true` | The sun disc behind the city. A number from 0 to 1 sets where it stands across the scene; the towers stand shorter around it. |
| `train` | `boolean` | `false` | An elevated line across the front, with trains crossing on it, behind the trees. |
| `headway` | `number` | `9` | Seconds between trains, counted from each departure. `0` runs them only when sent with `departures`. |
| `departures` | `number` | `0` | Bump this number to send a train at once, from a button for instance. |
| `trees` | `boolean` | `true` | Stippled trees along the foot of the scene, in front of everything. |
| `signs` | `"hover" \| "always"` | `"hover"` | With `towers`: show each tower's sign only while it's hovered, focused or tapped, or all the time. |
| `idle` | `boolean` | `true` | Let the city live while it's on screen: a window switches on or off every so often and far dots blink. |
| `intro` | `boolean` | `true` | Raise the towers and switch their lights on the first time the scene shows. |
| `riseDuration` | `number` | `0.55` | Seconds each tower takes to rise out of the ground the first time the scene shows. |
| `stagger` | `number` | `0.03` | Seconds between towers starting to rise, left to right. The spread stops growing after twelve towers. |
| `trainSpeed` | `number` | `1` | How fast trains cross, as a multiple of the default pace (about two seconds across 1280px). `2` is twice as fast. |
| `flicker` | `number` | `0.34` | Seconds between windows switching on or off while the city idles. |
| `label` | `string` |  | What the scene shows, for screen readers. Without `towers` and without a label, the scene is decorative. |
| `className` | `string` |  | Size and colors. Height comes from a class (`h-80` by default). The text color is the ink; set `--skyline-tint` (the far print), `--skyline-sun`, `--skyline-glow` (a lit-up tower) and `--skyline-light` (lit windows) as CSS variables. Windows and the train are cut from the background behind the scene, or from `--skyline-paper`. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/skyline.tsx`, keep these, which make it feel and behave right:

- Towers, windows and glow are painted on the `near` canvas by one rAF loop that stops once nothing moves; React only re-renders for layout, signs and focus, never per frame.
- Trains are two DOM layers moved by WAAPI `transform` alone, stacked between the `near` and `front` canvases, so they cross in front of the towers and behind the trees.
- Windows switch on and off whole, one at a time in random order: lights, not fades. A far dot's blink repaints only its own cell.
- Idle flicker, twinkles and the train timetable run only while the scene is on screen and the tab is visible.
- Reduced motion draws the city lit at once, with nothing rising, blinking or crossing; hover still lights a tower and shows its sign, and a departure fades a train in standing still.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/skyline.tsx"
"use client";

import * as React from "react";
import { cubicBezier, useReducedMotion } from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - Towers, windows and glow are painted on the `near` canvas by one rAF loop that stops once
 *   nothing moves; React only re-renders for layout, signs and focus, never per frame.
 * - Trains are two DOM layers moved by WAAPI `transform` alone, stacked between the `near` and
 *   `front` canvases, so they cross in front of the towers and behind the trees.
 * - Windows switch on and off whole, one at a time in random order: lights, not fades. A far dot's
 *   blink repaints only its own cell.
 * - Idle flicker, twinkles and the train timetable run only while the scene is on screen and the
 *   tab is visible.
 * - Reduced motion draws the city lit at once, with nothing rising, blinking or crossing; hover
 *   still lights a tower and shows its sign, and a departure fades a train in standing still.
 */
const TIMING = {
  /** An ink tower rising out of the ground on first view, in seconds (the `riseDuration` prop). */
  riseDuration: 0.55,
  /** Between towers rising, left to right, in seconds (the `stagger` prop). */
  stagger: 0.03,
  /** When a rising tower's windows start switching on, as a share of its rise. */
  land: 0.6,
  /** A landed tower's windows switching on, one by one, in seconds. */
  switchOn: 0.45,
  /** The far print, the trees and the sun fading up on first view, in ms. */
  appear: 320,
  /** The sun rising into place on first view, in ms. */
  sun: 600,
  /** The curve towers rise, reshape and light up on, as cubic-bezier points. */
  ease: [0.23, 1, 0.32, 1],
  /** The same curve for the first view's fade-ups and the sun, as a CSS easing. */
  cssEase: "cubic-bezier(0.23, 1, 0.32, 1)",
  /** A tower lighting up, bottom floor to top, in seconds. */
  glow: 0.18,
  /** And going back, in seconds. */
  unglow: 0.12,
  /** A new `lights` switching windows on or off, in seconds. */
  lights: 0.45,
  /** A tower growing or shrinking to a new value, in seconds. */
  reshape: 0.45,
  /** A sign riding along as its tower grows or shrinks, as Tailwind classes. */
  signRide: "duration-[450ms] ease-[cubic-bezier(0.23,1,0.32,1)]",
  /** A sign rising 6px out of the roof and back, as Tailwind classes. */
  sign: "duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",
  /** A tower's label brightening while it's lit, as a Tailwind class. */
  label: "duration-150",
  /** Between windows switching at idle, in seconds (the `flicker` prop). */
  flicker: 0.34,
  /** How long a switched window stays switched, min and max, in seconds. */
  flickerHold: [1.6, 4.2],
  /** Between far dots blinking out, in ms. */
  twinkle: 110,
  /** How long a far dot stays out, min and max, in seconds. */
  twinkleHold: [0.3, 1.2],
  /** Between trains, counted from each departure, in seconds (the `headway` prop). */
  headway: 9,
  /** After the towers start rising, before the first train leaves, in seconds. */
  firstTrain: 0.9,
  /** Back on screen or in the tab, before the next train, as a share of `headway`. */
  resume: 0.5,
  /** A crossing train: min and max px/s, and scene widths per second between them. */
  trainPace: [380, 820, 0.55],
  /** How fast trains cross, as a multiple of that pace (the `trainSpeed` prop). */
  trainSpeed: 1,
  /** A crossing train's curve: a steady speed. */
  trainEase: "linear",
  /** How long a train stands on the line under reduced motion, in seconds. */
  park: 2.4,
  /** A standing train fading in and out, in ms. */
  parkFade: 200,
} as const;

/* ─────────────────────────────────────────────────────────
 * SKYLINE STORYBOARD
 *
 *  rest      a printed city along the foot of the box: far towers
 *            screened in halftone dots in the tint, ink towers in
 *            front with paper windows knocked out of them, a flat sun
 *            disc behind and stippled trees along the bottom. Give it
 *            `towers` and each tower is one of your numbers, labelled
 *            under it; leave them out and a city grows from `seed`
 *  view      the first time a quarter of it shows, the far print and
 *            the trees fade up (0.32s) and the sun rises 14px into
 *            place, while the ink towers rise out of the ground left
 *            to right, 30ms apart (the spread capped at 0.36s), each in
 *            0.55s on a strong ease-out. As a tower lands its windows
 *            switch on one at a time, in random order, over 0.45s:
 *            lights, not fades
 *  hover     the tower under the pointer lights up bottom floor to
 *            top in the glow color in 0.18s and goes back in 0.12s.
 *            With `towers` the others' windows dim and a sign rises
 *            6px out of the roof with the label and the value (0.16s)
 *  lights    a new `lights` switches windows on or off one at a time,
 *            in random order, over 0.45s
 *  values    new `towers` values grow or shrink each tower in 0.45s
 *            from wherever it stands, its sign riding along
 *  train     with `train` an elevated line runs across the front. A
 *            train crosses it every `headway` seconds at a steady
 *            speed, about two seconds across 1280px, passing behind
 *            the trees; bump `departures` and one leaves at once. It
 *            moves by transform alone, on its own layer
 *  idle      while it's on screen a window switches on or off every
 *            0.34s and a few far dots blink out and back. All of it
 *            sleeps off screen and in a hidden tab
 *  touch     a tap on a tower shows its sign until the next tap
 *  keys      the towers are one tab stop: the arrows walk along them,
 *            Home and End jump to the ends, and focus shows the sign
 *  reduced   nothing rises, blinks or flickers: the city is drawn lit.
 *            Hover still lights a tower and shows its sign, at once. A
 *            departure fades a train in, standing on the line for 2.4s
 * ───────────────────────────────────────────────────────── */

const SKYLINE = {
  spread: 12, // towers; the stagger stops growing after this many (0.36s by default)
  sunRise: 14, // px the sun rises into place on first view
  dim: 0.55, // how far the other towers' windows dim while one is lit (with `towers`)
  spacing: 48, // px kept between two trains on the line
} as const;

const CITY = {
  pitch: 5, // px between the far print's dots
  floor: 7, // px per floor
  far: [26, 66], // px; a far tower's width
  near: [34, 82], // px; an ink tower's width, when the city grows from `seed`
  tree: [46, 110], // px between trees
  cloud: 460, // px of width per cloud
  top: 0.06, // share of the height left over the tallest tower, when the city grows from `seed`
  headroom: 44, // px left over the tallest tower for its sign, with `towers`
  labels: 28, // px; the strip under the towers for their labels
  deck: 0.27, // the line's deck above the ground, as a share of the height
  car: [14, 28, 0.072], // px min, max, and share of the height: a train car
  line: 0.3, // share of a generated city's towers drawn in line, paper with an ink outline
  gap: 1.5, // px of paper between overlapping towers
} as const;

const EASE_OUT = cubicBezier(...TIMING.ease);
const SUN_DEFAULT = "#ff5a1f";
const TAU = Math.PI * 2;

// The train's art, in viewBox units: four cars 78 long with 4 between, 24 tall.
const TRAIN = { cars: 4, car: 78, gap: 4, height: 24 } as const;
const TRAIN_WIDTH = TRAIN.cars * TRAIN.car + (TRAIN.cars - 1) * TRAIN.gap;
const TRAIN_CLASS = "pointer-events-none absolute top-0 left-0 opacity-0 will-change-transform";

const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));

/** A small, fast, seedable random number generator (mulberry32). */
function random(seed: number) {
  let a = seed >>> 0 || 1;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export interface SkylineTower {
  /** The tower's name, under it and on its sign. */
  label: string;
  /** Its height, in your own units: the tallest tower stands nearly as tall as the scene. */
  value: number;
  /** What its sign says. Defaults to `value` with thousands separators. */
  display?: string;
}

export interface SkylineProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** One tower per number, left to right, labelled under it. Leave it out for a city grown from `seed`. */
  towers?: SkylineTower[];
  /** Reshapes the generated city, the far print, the clouds, the trees and which windows are lit. Any whole number. */
  seed?: number;
  /** Share of the windows that are lit, from 0 to 1. A new value switches them on or off one at a time. */
  lights?: number;
  /** The sun disc behind the city. A number from 0 to 1 sets where it stands across the scene. */
  sun?: boolean | number;
  /** An elevated line across the front, with trains crossing on it. */
  train?: boolean;
  /** Seconds between trains, counted from each departure. `0` runs them only when sent with `departures`. */
  headway?: number;
  /** Bump this number to send a train at once, from a button for instance. */
  departures?: number;
  /** Stippled trees along the foot of the scene, in front of everything. */
  trees?: boolean;
  /** With `towers`: show each tower's sign only while it's hovered, focused or tapped, or all the time. */
  signs?: "hover" | "always";
  /** Let the city live while it's on screen: a window switches on or off every so often and far dots blink. */
  idle?: boolean;
  /** Raise the towers and switch their lights on the first time the scene shows. */
  intro?: boolean;
  /** Seconds each tower takes to rise out of the ground on first view. */
  riseDuration?: number;
  /** Seconds between towers rising, left to right, on first view. The spread stops growing after twelve towers. */
  stagger?: number;
  /** How fast trains cross, as a multiple of the default pace (about two seconds across 1280px). */
  trainSpeed?: number;
  /** Seconds between windows switching on or off while the city idles. */
  flicker?: number;
  /** What the scene shows, for screen readers. Without `towers` and without a label, the scene is decorative. */
  label?: string;
}

/* ── The city ───────────────────────────────────────────── */

type Crown = "flat" | "step" | "antenna" | "spire" | "slant";
type Facade = "grid" | "slits" | "bands";

/** A window, placed from the tower's left edge and up from the ground. */
type Win = { x: number; y: number; w: number; h: number; floor: number; r: number; on: number };

type Tower = {
  index: number;
  x: number;
  w: number;
  h: number; // target height of the roof over the ground, px
  crown: Crown;
  line: boolean; // drawn in line, paper with an ink outline, instead of solid ink
  flip: boolean; // which way a slanted roof falls
  wins: Win[]; // bottom floor first
  from: number; // the rise or reshape tween
  now: number;
  start: number;
  dur: number;
  land: number; // s; when its windows start switching on
  glow: number; // 0–1: how far up the glow has lit it
  glowFrom: number;
  glowTo: number;
  glowStart: number;
  glowDur: number;
};

type Dot = { x: number; y: number; s: number; round: boolean };
type Tree = { x: number; trunk: number; blobs: { x: number; y: number; r: number }[] };

/** What the overlay needs to place labels, signs and hit areas. */
export type SkylineLayout = {
  width: number;
  height: number;
  ground: number;
  risen: boolean;
  towers: { x: number; w: number; h: number; slot: number; slotWidth: number }[];
};

type Options = {
  towers: SkylineTower[] | null;
  seed: number;
  lights: number;
  sun: boolean;
  sunX: number;
  train: boolean;
  headway: number;
  trees: boolean;
  idle: boolean;
  intro: boolean;
  rise: number;
  stagger: number;
  trainSpeed: number;
  flicker: number;
  reduced: boolean;
};

type Parts = {
  root: HTMLElement;
  back: HTMLCanvasElement;
  near: HTMLCanvasElement;
  front: HTMLCanvasElement;
  sun: HTMLElement;
  trains: HTMLElement[];
  tint: HTMLElement;
  glow: HTMLElement;
};

/** How far below the roof the windows stop, for the crown's shape. */
function crownDepth(t: Tower, h: number) {
  if (t.crown === "step") return Math.min(26, h * 0.14) + 3;
  if (t.crown === "spire") return Math.min(t.w * 0.8, h * 0.22, 40) + 3;
  if (t.crown === "slant") return Math.min(t.w * 0.35, h * 0.2) + 3;
  return 5;
}

function outline(t: Tower, ground: number, h: number) {
  const p = new Path2D();
  const x0 = t.x;
  const x1 = t.x + t.w;
  const top = ground - h;
  if (t.crown === "step") {
    const sh = Math.min(26, h * 0.14);
    const inset = t.w * 0.16;
    p.moveTo(x0, ground);
    p.lineTo(x0, top + sh);
    p.lineTo(x0 + inset, top + sh);
    p.lineTo(x0 + inset, top);
    p.lineTo(x1 - inset, top);
    p.lineTo(x1 - inset, top + sh);
    p.lineTo(x1, top + sh);
    p.lineTo(x1, ground);
  } else if (t.crown === "spire") {
    const sh = Math.min(t.w * 0.8, h * 0.22, 40);
    p.moveTo(x0, ground);
    p.lineTo(x0, top + sh);
    p.lineTo(x0 + t.w * 0.28, top + sh);
    p.lineTo((x0 + x1) / 2, top);
    p.lineTo(x1 - t.w * 0.28, top + sh);
    p.lineTo(x1, top + sh);
    p.lineTo(x1, ground);
  } else if (t.crown === "slant") {
    const d = Math.min(t.w * 0.35, h * 0.2);
    p.moveTo(x0, ground);
    p.lineTo(x0, top + (t.flip ? 0 : d));
    p.lineTo(x1, top + (t.flip ? d : 0));
    p.lineTo(x1, ground);
  } else {
    p.rect(x0, top, t.w, h);
  }
  p.closePath();
  return p;
}

/** A tower's windows, floor by floor up to `height`. */
function facade(rand: () => number, w: number, height: number, kind: Facade): Win[] {
  const wins: Win[] = [];
  const floors = Math.max(0, Math.floor((height - 6) / CITY.floor));
  if (kind === "bands") {
    const inset = Math.min(4, w * 0.15);
    for (let f = 0; f < floors; f++) {
      wins.push({ x: inset, y: 5 + f * CITY.floor + 2, w: w - inset * 2, h: 2, floor: f, r: rand(), on: rand() });
    }
    return wins;
  }
  const [pitch, ww, wh] = kind === "slits" ? [5, 2, CITY.floor - 3] : [6, 3, 3];
  const cols = Math.max(1, Math.floor((w - 6 + (pitch - ww)) / pitch));
  const left = (w - (cols * pitch - (pitch - ww))) / 2;
  for (let f = 0; f < floors; f++) {
    for (let c = 0; c < cols; c++) {
      wins.push({ x: left + c * pitch, y: 5 + f * CITY.floor, w: ww, h: wh, floor: f, r: rand(), on: rand() });
    }
  }
  return wins;
}

/** The nearest painted background behind the scene: windows and the train are cut from it. */
function paperBehind(el: HTMLElement) {
  for (let n: HTMLElement | null = el; n; n = n.parentElement) {
    const bg = getComputedStyle(n).backgroundColor;
    if (bg && bg !== "transparent" && !/(,\s*0\)|\/\s*0\))$/.test(bg)) return bg;
  }
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "#0a0a0a" : "#ffffff";
}

function createCity() {
  let parts: Parts | null = null;
  let back: CanvasRenderingContext2D | null = null;
  let near: CanvasRenderingContext2D | null = null;
  let front: CanvasRenderingContext2D | null = null;
  let opts: Options = {
    towers: null,
    seed: 7,
    lights: 0.6,
    sun: true,
    sunX: 0.62,
    train: false,
    headway: TIMING.headway,
    trees: true,
    idle: true,
    intro: true,
    rise: TIMING.riseDuration,
    stagger: TIMING.stagger,
    trainSpeed: TIMING.trainSpeed,
    flicker: TIMING.flicker,
    reduced: false,
  };

  let width = 0;
  let height = 0;
  let dpr = 1;
  let ground = 0;
  let deck = 0;
  let car = 18;
  let towers: Tower[] = [];
  let order: Tower[] = []; // draw order: taller ones behind
  let dots: Dot[] = [];
  let twinkles: number[] = []; // dots that may blink: the square ones on far towers
  let trees: Tree[] = [];
  let colors = { ink: "#000", paper: "#fff", tint: "#888", glow: SUN_DEFAULT, light: "#fff" };
  let lit = false; // a light color was set: the city is lit up, so every tower is solid ink

  let entered = false; // the intro has started (or was skipped)
  let risen = false; // and finished
  let visible = false;
  let raf = 0;
  let pending = 0; // s; until when the intro's windows are still switching on
  let threshold = { from: 0.6, to: 0.6, start: 0, value: 0.6 };
  let active: number | null = null;
  const flips = new Map<number, number>(); // tower * 4096 + window → s when it goes back
  const out = new Map<number, number>(); // far dot → s when it comes back
  const running = new Map<HTMLElement, Animation>();
  let lastDeparture = -Infinity;
  let flickerTimer = 0;
  let twinkleTimer = 0;
  let headTimer = 0;
  let listener: ((layout: SkylineLayout) => void) | null = null;
  let jitter = random(1);
  const cleanups: (() => void)[] = [];

  const clock = () => performance.now() / 1000;
  const between = (a: number, b: number) => a + (b - a) * jitter();
  const isData = () => Boolean(opts.towers && opts.towers.length);

  function readColors() {
    if (!parts) return;
    const root = getComputedStyle(parts.root);
    const own = (name: string) => root.getPropertyValue(name).trim();
    const paper = own("--skyline-paper") || paperBehind(parts.root);
    colors = {
      ink: root.color,
      paper,
      tint: getComputedStyle(parts.tint).color,
      glow: getComputedStyle(parts.glow).color,
      light: own("--skyline-light") || paper,
    };
    lit = Boolean(own("--skyline-light"));
    parts.root.style.setProperty("--skyline-paper-now", paper);
  }

  /** Grow the city for the current size and options. Keeps each tower's height where it stood. */
  function build(keep: boolean) {
    const rand = random(opts.seed * 7919 + 13);
    const data = opts.towers && opts.towers.length ? opts.towers : null;
    ground = data ? height - CITY.labels : height;
    const top = data ? CITY.headroom : height * CITY.top;
    const avail = Math.max(0, ground - top);
    car = clamp(height * CITY.car[2], CITY.car[0], CITY.car[1]);
    deck = ground - height * CITY.deck;
    const previous = keep ? towers : [];
    const t0 = clock();
    // Towers stand shorter around the sun, so it sets in a gap instead of behind a wall.
    const dip = (center: number) => (opts.sun ? Math.exp(-((center - opts.sunX) ** 2) / 0.006) : 0);

    // The far print: towers and clouds screened onto one grid of dots.
    const far: { x: number; w: number; h: number; tone: number }[] = [];
    for (let x = -rand() * 30; x < width; ) {
      const w = CITY.far[0] + (CITY.far[1] - CITY.far[0]) * rand();
      const center = (x + w / 2) / Math.max(1, width);
      const swell = (0.7 + 0.3 * Math.exp(-((center - 0.55) ** 2) / 0.09)) * (1 - 0.3 * dip(center));
      far.push({ x, w, h: avail * (0.32 + 0.6 * rand()) * swell, tone: 0.6 + 0.4 * rand() });
      x += w + (-12 + 18 * rand());
    }
    const clouds: { x: number; y: number; rx: number; ry: number }[] = [];
    if (!data) {
      const count = Math.max(1, Math.round(width / CITY.cloud));
      for (let i = 0; i < count; i++) {
        const rx = 46 + 54 * rand();
        clouds.push({
          x: (width * (i + 0.15 + 0.7 * rand())) / count,
          y: top + avail * (0.04 + 0.26 * rand()),
          rx,
          ry: rx * (0.3 + 0.12 * rand()),
        });
      }
    }
    const p = CITY.pitch;
    const cols = Math.ceil(width / p);
    const rows = Math.ceil(ground / p);
    dots = [];
    twinkles = [];
    for (let gy = 0; gy < rows; gy++) {
      const cy = gy * p + p / 2;
      for (let gx = 0; gx < cols; gx++) {
        const cx = gx * p + p / 2;
        let tone = 0;
        let round = false;
        for (const f of far) {
          if (cx < f.x || cx >= f.x + f.w || cy < ground - f.h) continue;
          tone = Math.max(tone, f.tone * (0.3 + 0.7 * ((ground - cy) / f.h) ** 0.8));
        }
        for (const c of clouds) {
          const d = ((cx - c.x) / c.rx) ** 2 + ((cy - c.y) / c.ry) ** 2;
          if (d >= 1) continue;
          const t = (1 - d) ** 0.7 * 0.9;
          if (t > tone) {
            tone = t;
            round = true;
          }
        }
        const r = rand();
        if (tone < 0.1 || (!round && r < 0.07)) continue;
        tone *= 0.88 + 0.2 * rand();
        if (!round) twinkles.push(dots.length);
        dots.push({ x: cx, y: cy, s: round ? p * 0.5 * Math.sqrt(tone) : p * 0.86 * Math.sqrt(tone), round });
      }
    }

    // The ink towers.
    towers = [];
    const make = (
      index: number,
      x: number,
      w: number,
      h: number,
      crown: Crown,
      kind: Facade,
      reach: number,
      line = false,
    ): Tower => {
      const before = previous[index];
      const from = before ? before.now : 0;
      return {
        index,
        x,
        w,
        h,
        crown,
        line,
        flip: rand() < 0.5,
        wins: facade(rand, w, reach, kind),
        from,
        now: entered ? from : 0,
        start: t0,
        dur: before && !opts.reduced ? TIMING.reshape : 0,
        land: entered ? -Infinity : Infinity,
        glow: 0,
        glowFrom: 0,
        glowTo: 0,
        glowStart: 0,
        glowDur: 0,
      };
    };
    if (data) {
      const pad = 8;
      const slot = (width - pad * 2) / data.length;
      const w = clamp(slot * 0.58, 4, 112);
      const max = Math.max(0, ...data.map((d) => d.value)) || 1;
      const kinds: Facade[] = ["grid", "slits", "grid", "bands"];
      data.forEach((d, i) => {
        const h = d.value > 0 ? Math.max(CITY.floor * 2 + 6, (d.value / max) * avail) : 0;
        towers.push(make(i, pad + i * slot + (slot - w) / 2, w, h, i % 3 === 1 ? "step" : "flat", kinds[i % 4], avail));
      });
      order = towers;
    } else {
      const crowns: Crown[] = ["flat", "flat", "step", "antenna", "spire", "slant"];
      const kinds: Facade[] = ["grid", "grid", "slits", "bands"];
      let i = 0;
      for (let x = -(4 + 20 * rand()); x < width + 10; ) {
        const w = CITY.near[0] + (CITY.near[1] - CITY.near[0]) * rand();
        const center = (x + w / 2) / Math.max(1, width);
        const swell = (0.55 + 0.45 * Math.exp(-((center - 0.58) ** 2) / 0.07)) * (1 - 0.5 * dip(center));
        const h = avail * clamp((0.18 + 0.7 * rand()) * swell + 0.06, 0.14, 0.96);
        const crown = crowns[Math.floor(rand() * crowns.length)];
        const line = rand() < CITY.line;
        towers.push(make(i++, x, w, h, crown, kinds[Math.floor(rand() * kinds.length)], h, line));
        x += w + (-10 + 50 * rand());
      }
      order = [...towers].sort((a, b) => b.h - a.h);
    }

    // Trees along the foot.
    trees = [];
    if (opts.trees) {
      const scale = clamp(height / 420, 0.85, 1.3);
      for (let x = -10 + 30 * rand(); x < width + 20; x += CITY.tree[0] + (CITY.tree[1] - CITY.tree[0]) * rand()) {
        const r = (17 + 12 * rand()) * scale;
        const cy = ground - r * (1.05 + 0.55 * rand());
        const blobs = [{ x, y: cy, r }];
        const extra = 2 + Math.floor(rand() * 3);
        for (let b = 0; b < extra; b++) {
          blobs.push({ x: x + r * (-0.75 + 1.5 * rand()), y: cy + r * (-0.35 + 0.8 * rand()), r: r * (0.55 + 0.3 * rand()) });
        }
        trees.push({ x, trunk: cy, blobs });
      }
    }

    jitter = random(opts.seed * 31 + 5);
    flips.clear();
    out.clear();
  }

  function size() {
    if (!parts) return;
    width = parts.root.clientWidth;
    height = parts.root.clientHeight;
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    for (const canvas of [parts.back, parts.near, parts.front]) {
      canvas.width = Math.round(width * dpr);
      canvas.height = Math.round(height * dpr);
    }
    for (const ctx of [back, near, front]) ctx?.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  function placeTrains() {
    if (!parts) return;
    for (const el of parts.trains) {
      el.style.top = `${deck - car + 1}px`;
      el.style.height = `${car}px`;
      el.style.width = `${(car * TRAIN_WIDTH) / TRAIN.height}px`;
    }
  }

  function notify() {
    if (!listener || !isData()) return;
    const n = towers.length;
    const pad = 8;
    const slot = (width - pad * 2) / Math.max(1, n);
    listener({
      width,
      height,
      ground,
      risen,
      towers: towers.map((t, i) => ({ x: t.x, w: t.w, h: t.h, slot: pad + i * slot, slotWidth: slot })),
    });
  }

  /* ── Drawing ── */

  function drawDot(ctx: CanvasRenderingContext2D, d: Dot) {
    if (d.round) {
      ctx.beginPath();
      ctx.arc(d.x, d.y, d.s, 0, TAU);
      ctx.fill();
    } else {
      ctx.fillRect(d.x - d.s / 2, d.y - d.s / 2, d.s, d.s);
    }
  }

  function drawBack() {
    if (!back) return;
    back.clearRect(0, 0, width, height);
    back.fillStyle = colors.tint;
    back.beginPath();
    dots.forEach((d, i) => {
      if (!d.round || out.has(i)) return;
      back!.moveTo(d.x + d.s, d.y);
      back!.arc(d.x, d.y, d.s, 0, TAU);
    });
    back.fill();
    dots.forEach((d, i) => {
      if (!d.round && !out.has(i)) back!.fillRect(d.x - d.s / 2, d.y - d.s / 2, d.s, d.s);
    });
  }

  /** Repaint one far dot's cell, so a blink costs one dot, not the print. */
  function paintDot(i: number, on: boolean) {
    const d = dots[i];
    if (!back || !d) return;
    const half = CITY.pitch / 2;
    back.clearRect(d.x - half, d.y - half, CITY.pitch, CITY.pitch);
    if (!on) return;
    back.fillStyle = colors.tint;
    drawDot(back, d);
  }

  function drawFront() {
    if (!front) return;
    front.clearRect(0, 0, width, height);
    if (!trees.length) return;
    front.fillStyle = colors.ink;
    front.strokeStyle = colors.ink;
    front.lineWidth = 1.5;
    for (const t of trees) {
      front.beginPath();
      front.moveTo(t.x, ground);
      front.lineTo(t.x, t.trunk);
      front.stroke();
      front.beginPath();
      for (const b of t.blobs) {
        front.moveTo(b.x + b.r, b.y);
        front.arc(b.x, b.y, b.r, 0, TAU);
      }
      front.fill();
    }
    // Knock the light out of each crown: specks that grow toward the upper left,
    // so the dark side reads as shade, like a stipple drawing.
    const rand = random(opts.seed * 131 + 7);
    const step = 3.4;
    front.globalCompositeOperation = "destination-out";
    front.beginPath();
    for (const t of trees) {
      let x0 = Infinity;
      let x1 = -Infinity;
      let y0 = Infinity;
      let y1 = -Infinity;
      for (const b of t.blobs) {
        x0 = Math.min(x0, b.x - b.r);
        x1 = Math.max(x1, b.x + b.r);
        y0 = Math.min(y0, b.y - b.r);
        y1 = Math.max(y1, b.y + b.r);
      }
      let row = 0;
      for (let y = y0; y <= y1; y += step * 0.87, row++) {
        for (let x = x0 + (row % 2 ? step / 2 : 0); x <= x1; x += step) {
          let best = -1;
          let nx = 0;
          let ny = 0;
          for (const b of t.blobs) {
            const dx = (x - b.x) / b.r;
            const dy = (y - b.y) / b.r;
            const inside = 1 - dx * dx - dy * dy;
            if (inside > best) {
              best = inside;
              nx = dx;
              ny = dy;
            }
          }
          if (best <= 0) continue;
          const nz = Math.sqrt(best);
          const light = -0.55 * nx - 0.62 * ny + 0.56 * nz;
          let r = clamp((light - 0.12) / 0.88) * step * 0.52;
          if (rand() < 0.12) r += 0.45;
          if (r < 0.3) continue;
          front.moveTo(x + r, y);
          front.arc(x, y, r, 0, TAU);
        }
      }
    }
    front.fill();
    front.globalCompositeOperation = "source-over";
  }

  function drawLine() {
    if (!near) return;
    const ink = colors.ink;
    const paper = colors.paper;
    const deckH = Math.max(4, car * 0.42);
    const pillar = Math.max(5, car * 0.42);
    const spans = Math.max(1, Math.round(width / 150));
    const span = width / spans;
    for (let x = span / 2; x < width + pillar; x += span) {
      near.fillStyle = paper;
      near.fillRect(x - pillar / 2, deck + deckH, pillar, ground - deck - deckH);
      near.strokeStyle = ink;
      near.lineWidth = 1;
      near.strokeRect(x - pillar / 2 + 0.5, deck + deckH, pillar - 1, ground - deck - deckH);
      near.fillStyle = ink;
      near.fillRect(x - pillar * 0.9, deck + deckH, pillar * 1.8, 2);
    }
    near.fillStyle = paper;
    near.fillRect(0, deck, width, deckH);
    near.fillStyle = ink;
    near.fillRect(0, deck, width, 1.25);
    near.fillRect(0, deck + deckH - 1.25, width, 1.25);
    for (let x = 2; x < width; x += 6) near.fillRect(x, deck + 2.5, 1, deckH - 5);
  }

  function drawNear(now: number) {
    if (!near) return;
    near.clearRect(0, 0, width, height);
    const data = isData();
    let focus = 0;
    if (data) for (const t of towers) focus = Math.max(focus, t.glow);
    const snap = (v: number) => Math.round(v * dpr) / dpr;
    for (const t of order) {
      const h = t.now;
      if (h < 0.5) continue;
      const line = t.line && !lit;
      const path = outline(t, ground, h);
      near.lineJoin = "miter";
      near.strokeStyle = colors.paper;
      near.lineWidth = CITY.gap * 2;
      near.stroke(path);
      near.fillStyle = line ? colors.paper : colors.ink;
      near.fill(path);
      if (line) {
        near.strokeStyle = colors.ink;
        near.lineWidth = 1.25;
        near.stroke(path);
      }
      if (t.crown === "antenna") {
        const mast = Math.min(30, h * 0.16);
        const cx = snap(t.x + t.w / 2);
        near.fillRect(cx - 0.75, ground - h - mast, 1.5, mast);
        near.fillRect(cx - 3, ground - h - mast * 0.55, 6, 1.25);
      }
      const limit = h - crownDepth(t, h);
      const floors = Math.max(1, Math.floor((limit - 5) / CITY.floor));
      const dimmed = data && t.glow === 0 ? 1 - SKYLINE.dim * focus : 1;
      let fill = "";
      for (let i = 0; i < t.wins.length; i++) {
        const w = t.wins[i];
        if (w.y + w.h > limit) break;
        let color: string;
        if (t.glow > 0 && (w.floor + 0.5) / floors <= t.glow) {
          color = colors.glow;
          near.globalAlpha = 1;
        } else {
          let on = w.r < threshold.value;
          if (flips.has(t.index * 4096 + i)) on = !on;
          if (!on || now < t.land + w.on * TIMING.switchOn) continue;
          // On a paper tower a lit window is drawn in ink: it reads as a pane, not a hole.
          color = line ? colors.ink : colors.light;
          near.globalAlpha = dimmed;
        }
        if (color !== fill) {
          near.fillStyle = color;
          fill = color;
        }
        near.fillRect(snap(t.x + w.x), snap(ground - w.y - w.h), snap(w.w) || 1 / dpr, snap(w.h) || 1 / dpr);
      }
      near.globalAlpha = 1;
    }
    if (opts.train) drawLine();
    if (data) {
      near.fillStyle = colors.ink;
      near.fillRect(0, snap(ground), width, 1);
    }
  }

  /* ── The loop ── */

  /** Advance every tween; true while anything still moves. */
  function step(now: number) {
    let moving = false;
    for (const t of towers) {
      if (t.now !== t.h) {
        const p = t.dur > 0 ? clamp((now - t.start) / t.dur) : 1;
        t.now = p >= 1 ? t.h : t.from + (t.h - t.from) * EASE_OUT(p);
        if (now < t.start) t.now = t.from;
        moving = true;
      }
      if (t.glow !== t.glowTo) {
        const p = t.glowDur > 0 ? clamp((now - t.glowStart) / t.glowDur) : 1;
        t.glow = p >= 1 ? t.glowTo : t.glowFrom + (t.glowTo - t.glowFrom) * EASE_OUT(p);
        moving = true;
      }
    }
    if (threshold.value !== threshold.to) {
      const p = clamp((now - threshold.start) / TIMING.lights);
      threshold.value = p >= 1 ? threshold.to : threshold.from + (threshold.to - threshold.from) * p;
      moving = true;
    }
    if (now < pending) moving = true;
    return moving;
  }

  function tick() {
    raf = 0;
    const now = clock();
    const moving = step(now);
    drawNear(now);
    if (moving) raf = requestAnimationFrame(tick);
    else if (entered && !risen) {
      risen = true;
      notify();
    }
  }

  function wake() {
    if (!raf && parts) raf = requestAnimationFrame(tick);
  }

  /** Draw everything at rest, as it stands now. */
  function repaint() {
    drawBack();
    drawFront();
    drawNear(clock());
  }

  /* ── First view ── */

  function enter() {
    if (entered || !parts) return;
    entered = true;
    const now = clock();
    const animate = opts.intro && !opts.reduced;
    if (!animate) {
      for (const t of towers) {
        t.now = t.h;
        t.land = -Infinity;
      }
      for (const el of [parts.back, parts.front, parts.sun]) el.style.opacity = "1";
      risen = true;
      notify();
      repaint();
      schedule(TIMING.firstTrain);
      syncIdle();
      return;
    }
    const byX = [...towers].sort((a, b) => a.x - b.x);
    byX.forEach((t, i) => {
      t.from = 0;
      t.now = 0;
      t.start = now + Math.min(i, SKYLINE.spread) * opts.stagger;
      t.dur = opts.rise;
      t.land = t.start + opts.rise * TIMING.land;
    });
    pending = now + SKYLINE.spread * opts.stagger + opts.rise * TIMING.land + TIMING.switchOn;
    const fade = [{ opacity: 0 }, { opacity: 1 }];
    for (const el of [parts.back, parts.front]) {
      el.animate(fade, { duration: TIMING.appear, easing: TIMING.cssEase });
      el.style.opacity = "1";
    }
    parts.sun.style.opacity = "1";
    parts.sun.animate(
      [
        { opacity: 0, transform: `translateY(${SKYLINE.sunRise}px)` },
        { opacity: 1, transform: "translateY(0px)" },
      ],
      { duration: TIMING.sun, easing: TIMING.cssEase },
    );
    wake();
    schedule(TIMING.firstTrain);
    syncIdle();
  }

  /* ── Trains ── */

  function speed() {
    const [lo, hi, share] = TIMING.trainPace;
    return clamp(width * share, lo, hi) * opts.trainSpeed;
  }

  function depart() {
    if (!parts || !opts.train || !width) return;
    const el = parts.trains.find((t) => !running.has(t));
    if (!el) return;
    const long = (car * TRAIN_WIDTH) / TRAIN.height;
    let animation: Animation;
    if (opts.reduced) {
      if (running.size) return;
      el.style.transform = `translateX(${(width - long) / 2}px)`;
      const total = TIMING.park * 1000 + TIMING.parkFade * 2;
      const edge = TIMING.parkFade / total;
      animation = el.animate(
        [{ opacity: 0 }, { opacity: 1, offset: edge }, { opacity: 1, offset: 1 - edge }, { opacity: 0 }],
        { duration: total },
      );
    } else {
      // Leave room behind a train that's still pulling out of the left edge.
      const v = speed();
      const now = clock();
      const clear = (long + SKYLINE.spacing) / v;
      const wait = Math.max(0, lastDeparture + clear - now);
      lastDeparture = now + wait;
      animation = el.animate(
        [
          { transform: `translateX(${-long}px)`, opacity: 1 },
          { transform: `translateX(${width}px)`, opacity: 1 },
        ],
        { duration: ((width + long) / v) * 1000, delay: wait * 1000, easing: TIMING.trainEase, fill: "backwards" },
      );
    }
    running.set(el, animation);
    const done = () => {
      if (running.get(el) === animation) running.delete(el);
    };
    animation.onfinish = done;
    animation.oncancel = done;
  }

  function schedule(after: number) {
    clearTimeout(headTimer);
    headTimer = 0;
    if (!opts.train || opts.headway <= 0 || opts.reduced || !entered || !visible || document.hidden) return;
    headTimer = window.setTimeout(() => {
      depart();
      schedule(opts.headway);
    }, after * 1000);
  }

  /* ── Idle life ── */

  function flicker() {
    const now = clock();
    for (const [k, until] of flips) if (until <= now) flips.delete(k);
    const t = towers[Math.floor(jitter() * towers.length)];
    if (t && t.now > 0) {
      const limit = t.now - crownDepth(t, t.now);
      let count = 0;
      while (count < t.wins.length && t.wins[count].y + t.wins[count].h <= limit) count++;
      if (count) flips.set(t.index * 4096 + Math.floor(jitter() * count), now + between(...TIMING.flickerHold));
    }
    wake();
  }

  function twinkle() {
    const now = clock();
    for (const [i, until] of out) {
      if (until > now) continue;
      out.delete(i);
      paintDot(i, true);
    }
    for (let k = 0; k < 3 && twinkles.length; k++) {
      const i = twinkles[Math.floor(jitter() * twinkles.length)];
      if (out.has(i)) continue;
      out.set(i, now + between(...TIMING.twinkleHold));
      paintDot(i, false);
    }
  }

  function syncIdle() {
    const live = opts.idle && !opts.reduced && entered && visible && !document.hidden;
    if (live && !flickerTimer) {
      flickerTimer = window.setInterval(flicker, opts.flicker * 1000);
      twinkleTimer = window.setInterval(twinkle, TIMING.twinkle);
    } else if (!live && flickerTimer) {
      clearInterval(flickerTimer);
      clearInterval(twinkleTimer);
      flickerTimer = 0;
      twinkleTimer = 0;
      if (flips.size || out.size) {
        flips.clear();
        out.clear();
        if (parts) repaint();
      }
    }
  }

  /** Light one tower (by index), or none: its windows fill with the glow color, bottom floor to top. */
  function light(index: number | null) {
    if (index === active) return;
    active = index;
    const now = clock();
    for (const t of towers) {
      const to = t.index === index ? 1 : 0;
      if (t.glowTo === to) continue;
      t.glowFrom = t.glow;
      t.glowTo = to;
      t.glowStart = now;
      t.glowDur = opts.reduced ? 0 : to ? TIMING.glow : TIMING.unglow;
    }
    wake();
  }

  /** Rebuild for a new size or new options and draw it. */
  function layout(keep: boolean) {
    if (!parts) return;
    size();
    build(keep);
    if (entered && (!keep || opts.reduced)) {
      for (const t of towers) {
        t.now = t.h;
        t.land = -Infinity;
      }
    }
    placeTrains();
    notify();
    drawBack();
    drawFront();
    if (entered) wake();
    drawNear(clock());
  }

  return {
    connect(el: Parts) {
      parts = el;
      back = el.back.getContext("2d");
      near = el.near.getContext("2d");
      front = el.front.getContext("2d");
      readColors();
      layout(false);
      if (!opts.intro || opts.reduced) {
        for (const node of [el.back, el.front, el.sun]) node.style.opacity = "1";
      }

      let w = width;
      let h = height;
      const resize = new ResizeObserver(() => {
        if (!parts || (parts.root.clientWidth === w && parts.root.clientHeight === h)) return;
        w = parts.root.clientWidth;
        h = parts.root.clientHeight;
        layout(false);
      });
      resize.observe(el.root);

      const io = new IntersectionObserver(
        ([entry]) => {
          visible = entry.isIntersecting;
          // The first view schedules its own first train; later ones pick the timetable back up.
          if (entered) schedule(opts.headway * TIMING.resume);
          else if (entry.intersectionRatio >= 0.25) enter();
          syncIdle();
        },
        { threshold: [0, 0.25] },
      );
      io.observe(el.root);

      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      const recolor = () => {
        readColors();
        repaint();
      };
      scheme.addEventListener("change", recolor);
      const theme = new MutationObserver(recolor);
      theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] });

      const page = () => {
        schedule(opts.headway * TIMING.resume);
        syncIdle();
      };
      document.addEventListener("visibilitychange", page);

      // Without `towers` the scene has no buttons; a mouse over a tower lights it.
      const point = (e: PointerEvent) => {
        if (e.pointerType !== "mouse" || isData() || !parts || !risen) return;
        const r = parts.root.getBoundingClientRect();
        const x = e.clientX - r.left;
        const y = e.clientY - r.top;
        let hit: number | null = null;
        for (let i = order.length - 1; i >= 0; i--) {
          const t = order[i];
          if (x >= t.x && x <= t.x + t.w && y >= ground - t.now && y <= ground) {
            hit = t.index;
            break;
          }
        }
        light(hit);
      };
      const leave = () => {
        if (!isData()) light(null);
      };
      el.root.addEventListener("pointermove", point);
      el.root.addEventListener("pointerleave", leave);

      cleanups.push(
        () => resize.disconnect(),
        () => io.disconnect(),
        () => scheme.removeEventListener("change", recolor),
        () => theme.disconnect(),
        () => document.removeEventListener("visibilitychange", page),
        () => {
          el.root.removeEventListener("pointermove", point);
          el.root.removeEventListener("pointerleave", leave);
        },
      );
      return () => {
        cancelAnimationFrame(raf);
        raf = 0;
        clearTimeout(headTimer);
        clearInterval(flickerTimer);
        clearInterval(twinkleTimer);
        flickerTimer = 0;
        twinkleTimer = 0;
        for (const a of running.values()) a.cancel();
        running.clear();
        cleanups.splice(0).forEach((fn) => fn());
        parts = null;
        back = near = front = null;
        // A remount (Strict Mode, or a new key) starts from the top.
        entered = false;
        risen = false;
      };
    },

    setOptions(next: Options) {
      const prev = opts;
      opts = next;
      if (!parts) {
        threshold = { from: next.lights, to: next.lights, start: 0, value: next.lights };
        return;
      }
      if (next.lights !== threshold.to) {
        threshold = next.reduced
          ? { from: next.lights, to: next.lights, start: 0, value: next.lights }
          : { from: threshold.value, to: next.lights, start: clock(), value: threshold.value };
        wake();
      }
      const reshape = prev.towers !== next.towers;
      const rebuild =
        prev.seed !== next.seed ||
      prev.trees !== next.trees ||
      prev.train !== next.train ||
      prev.reduced !== next.reduced ||
      prev.sun !== next.sun ||
      prev.sunX !== next.sunX;
      if (rebuild || reshape) {
        const sameCount = (prev.towers?.length ?? -1) === (next.towers?.length ?? -2);
        layout(reshape && !rebuild && sameCount);
      }
      if (prev.reduced !== next.reduced && next.reduced) {
        for (const a of running.values()) a.cancel();
      }
      schedule(next.headway);
      if (prev.flicker !== next.flicker && flickerTimer) {
        clearInterval(flickerTimer);
        flickerTimer = window.setInterval(flicker, next.flicker * 1000);
      }
      syncIdle();
    },

    listen(fn: (layout: SkylineLayout) => void) {
      listener = fn;
      notify();
      return () => {
        if (listener === fn) listener = null;
      };
    },

    /** Light one tower (by index), or none. */
    setActive(index: number | null) {
      light(index);
    },

    depart,
  };
}

type City = ReturnType<typeof createCity>;

/* ── Train ──────────────────────────────────────────────── */

// Four cars, nose to the right: paper bodies cut from the scene's background,
// an ink outline, a row of windows and a stripe in the sun's color. With a
// light color set, the windows are lit, so a train still shows at night.
function TrainArt() {
  return (
    <svg viewBox={`0 0 ${TRAIN_WIDTH} ${TRAIN.height}`} className="block size-full overflow-visible" fill="none">
      {Array.from({ length: TRAIN.cars }, (_, i) => {
        const x = i * (TRAIN.car + TRAIN.gap);
        const lead = i === TRAIN.cars - 1;
        const body = lead
          ? `M${x + 3} 2H${x + 64}C${x + 72} 2 ${x + 78} 8 ${x + 78} 14V17a3 3 0 0 1-3 3H${x + 3}a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3Z`
          : `M${x + 3} 2H${x + 75}a3 3 0 0 1 3 3V17a3 3 0 0 1-3 3H${x + 3}a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3Z`;
        return (
          <g key={i}>
            {i > 0 && <rect x={x - TRAIN.gap} y={13} width={TRAIN.gap} height={1.5} fill="currentColor" />}
            <path d={body} fill="var(--skyline-paper-now, #fff)" stroke="currentColor" strokeWidth={1.25} />
            {Array.from({ length: lead ? 6 : 7 }, (_, k) => (
              <rect key={k} x={x + 6 + k * 9.6} y={6} width={6} height={4.5} rx={1} fill="var(--skyline-light, currentColor)" />
            ))}
            {lead && <path d={`M${x + 64} 5.5h2.5c3 0 6 2.6 7.2 5H${x + 64}Z`} fill="currentColor" />}
            <rect x={x + 0.6} y={12.6} width={lead ? 76.5 : 76.8} height={2.2} fill={`var(--skyline-sun, ${SUN_DEFAULT})`} />
            <rect x={x + 8} y={20} width={12} height={2.6} rx={1.2} fill="currentColor" />
            <rect x={x + 58} y={20} width={12} height={2.6} rx={1.2} fill="currentColor" />
          </g>
        );
      })}
    </svg>
  );
}

/* ── Skyline ────────────────────────────────────────────── */

export function Skyline({
  towers,
  seed = 7,
  lights = 0.6,
  sun = true,
  train = false,
  headway = TIMING.headway,
  departures = 0,
  trees = true,
  signs = "hover",
  idle = true,
  intro = true,
  riseDuration = TIMING.riseDuration,
  stagger = TIMING.stagger,
  trainSpeed = TIMING.trainSpeed,
  flicker = TIMING.flicker,
  label,
  className,
  style,
  ...props
}: SkylineProps) {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const backRef = React.useRef<HTMLCanvasElement>(null);
  const nearRef = React.useRef<HTMLCanvasElement>(null);
  const frontRef = React.useRef<HTMLCanvasElement>(null);
  const sunRef = React.useRef<HTMLDivElement>(null);
  const trainARef = React.useRef<HTMLDivElement>(null);
  const trainBRef = React.useRef<HTMLDivElement>(null);
  const tintRef = React.useRef<HTMLSpanElement>(null);
  const glowRef = React.useRef<HTMLSpanElement>(null);
  const [city] = React.useState<City>(createCity);
  const reduced = useReducedMotion() ?? false;
  const [layout, setLayout] = React.useState<SkylineLayout | null>(null);
  const [hover, setHover] = React.useState<number | null>(null);
  const [focused, setFocused] = React.useState<number | null>(null);
  const [pinned, setPinned] = React.useState<number | null>(null);
  const [cursor, setCursor] = React.useState(0);
  const pointerType = React.useRef("mouse");
  const sent = React.useRef(departures);

  // A new array with the same numbers isn't a change.
  const key = towers?.map((t) => `${t.label}\u0000${t.value}\u0000${t.display ?? ""}`).join("\u0001") ?? "";
  const [data, setData] = React.useState<SkylineTower[] | null>(towers?.length ? towers : null);
  const [dataKey, setDataKey] = React.useState(key);
  if (key !== dataKey) {
    setDataKey(key);
    setData(towers?.length ? towers : null);
  }

  // Options first, so the first layout already has them.
  React.useLayoutEffect(() => {
    city.setOptions({
      towers: data,
      seed,
      lights: clamp(lights),
      sun: sun !== false,
      sunX: typeof sun === "number" ? clamp(sun) : 0.62,
      train,
      headway: Math.max(0, headway),
      trees,
      idle,
      intro,
      rise: Math.max(0, riseDuration),
      stagger: Math.max(0, stagger),
      trainSpeed: Math.max(0.05, trainSpeed),
      flicker: Math.max(0.05, flicker),
      reduced,
    });
  }, [city, data, seed, lights, sun, train, headway, trees, idle, intro, riseDuration, stagger, trainSpeed, flicker, reduced]);

  React.useLayoutEffect(() => {
    const root = rootRef.current;
    const back = backRef.current;
    const near = nearRef.current;
    const front = frontRef.current;
    const sunEl = sunRef.current;
    const a = trainARef.current;
    const b = trainBRef.current;
    const tint = tintRef.current;
    const glow = glowRef.current;
    if (!root || !back || !near || !front || !sunEl || !a || !b || !tint || !glow) return;
    return city.connect({ root, back, near, front, sun: sunEl, trains: [a, b], tint, glow });
  }, [city]);

  React.useLayoutEffect(() => city.listen(setLayout), [city]);

  React.useEffect(() => {
    if (departures === sent.current) return;
    sent.current = departures;
    city.depart();
  }, [city, departures]);

  const active = hover ?? focused ?? pinned;
  React.useEffect(() => {
    if (data) city.setActive(active);
  }, [city, data, active]);

  const onKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if (!data) return;
    const n = data.length;
    const from = Number(e.currentTarget.dataset.tower);
    const to =
      e.key === "ArrowRight" || e.key === "ArrowDown"
        ? (from + 1) % n
        : e.key === "ArrowLeft" || e.key === "ArrowUp"
          ? (from - 1 + n) % n
          : e.key === "Home"
            ? 0
            : e.key === "End"
              ? n - 1
              : -1;
    if (to < 0) return;
    e.preventDefault();
    setCursor(to);
    e.currentTarget.parentElement?.querySelector<HTMLButtonElement>(`[data-tower="${to}"]`)?.focus();
  };

  const sunX = typeof sun === "number" ? clamp(sun) : 0.62;
  const hidden = intro ? { opacity: 0 } : undefined;
  const decorative = !data && !label;

  return (
    <div
      ref={rootRef}
      role={data ? "group" : decorative ? undefined : "img"}
      aria-label={data ? (label ?? "Chart") : label}
      aria-hidden={decorative || undefined}
      className={cn("relative isolate h-80 w-full touch-pan-y overflow-hidden select-none", className)}
      style={style}
      {...props}
    >
      <div
        ref={sunRef}
        aria-hidden
        className={cn(
          "pointer-events-none absolute aspect-square h-[54%] -translate-x-1/2 rounded-full [background-color:var(--skyline-sun,#ff5a1f)]",
          sun === false && "hidden",
        )}
        style={{ left: `${sunX * 100}%`, bottom: data ? `calc(${CITY.labels}px + 24%)` : "24%", ...hidden }}
      />
      <canvas ref={backRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" style={hidden} />
      <canvas ref={nearRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" />
      {/* Two trains, so a second can follow the first onto the line. */}
      <div ref={trainARef} aria-hidden className={TRAIN_CLASS}>
        <TrainArt />
      </div>
      <div ref={trainBRef} aria-hidden className={TRAIN_CLASS}>
        <TrainArt />
      </div>
      <canvas ref={frontRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" style={hidden} />
      <span ref={tintRef} aria-hidden className="hidden [color:var(--skyline-tint,#4562ff)]" />
      <span ref={glowRef} aria-hidden className="hidden [color:var(--skyline-glow,var(--skyline-sun,#ff5a1f))]" />

      {data &&
        layout &&
        data.map((t, i) => {
          const g = layout.towers[i];
          if (!g) return null;
          const text = t.display ?? t.value.toLocaleString("en-US");
          const shown = layout.risen && (signs === "always" || active === i);
          const center = clamp(g.x + g.w / 2, 28, layout.width - 28);
          return (
            <React.Fragment key={i}>
              <button
                type="button"
                data-tower={i}
                tabIndex={i === cursor ? 0 : -1}
                aria-label={`${t.label}: ${text}`}
                className="group/tower absolute top-0 cursor-default outline-none"
                style={{ left: g.slot, width: g.slotWidth, height: layout.ground }}
                onPointerDown={(e) => {
                  pointerType.current = e.pointerType;
                }}
                onPointerEnter={(e) => e.pointerType === "mouse" && setHover(i)}
                onPointerLeave={(e) => e.pointerType === "mouse" && setHover((h) => (h === i ? null : h))}
                onFocus={() => {
                  setFocused(i);
                  setCursor(i);
                }}
                onBlur={() => setFocused((f) => (f === i ? null : f))}
                onClick={() => {
                  if (pointerType.current !== "mouse") setPinned((p) => (p === i ? null : i));
                }}
                onKeyDown={onKey}
              >
                <span
                  aria-hidden
                  className="absolute bottom-0 rounded-[2px] outline-2 outline-offset-3 outline-current opacity-0 group-focus-visible/tower:opacity-100"
                  style={{ left: g.x - g.slot, width: g.w, height: Math.max(g.h, 2) }}
                />
              </button>
              <span
                aria-hidden
                className={`pointer-events-none absolute top-0 left-0 transition-transform ${TIMING.signRide} motion-reduce:transition-none`}
                style={{ transform: `translate(${center}px, ${layout.ground - g.h - 4}px)` }}
              >
                <span
                  className={cn(
                    `absolute bottom-0 left-0 flex -translate-x-1/2 flex-col items-center transition-[opacity,translate] ${TIMING.sign} motion-reduce:translate-y-0`,
                    shown ? "translate-y-0 opacity-100" : "translate-y-1.5 opacity-0",
                  )}
                >
                  <span className="flex items-baseline gap-1.5 rounded-[3px] bg-current px-1.5 py-1 font-mono text-[10px] leading-none whitespace-nowrap tabular-nums">
                    <span className="[color:var(--skyline-paper-now,#fff)] opacity-70">{t.label}</span>
                    <span className="font-semibold [color:var(--skyline-paper-now,#fff)]">{text}</span>
                  </span>
                  <span className="h-1.5 w-px bg-current" />
                </span>
              </span>
              <span
                aria-hidden
                className={cn(
                  `pointer-events-none absolute bottom-0 flex items-center justify-center truncate px-0.5 font-mono text-[10px] tracking-[0.08em] uppercase transition-opacity ${TIMING.label}`,
                  active === i ? "opacity-100" : "opacity-55",
                )}
                style={{ left: g.slot, width: g.slotWidth, height: CITY.labels }}
              >
                {t.label}
              </span>
            </React.Fragment>
          );
        })}
    </div>
  );
}
```
