{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "skyline",
  "type": "registry:ui",
  "title": "Skyline",
  "description": "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.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "hero",
    "chart",
    "canvas"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/skyline",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/skyline.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cubicBezier, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - Towers, windows and glow are painted on the `near` canvas by one rAF loop that stops once\n *   nothing moves; React only re-renders for layout, signs and focus, never per frame.\n * - Trains are two DOM layers moved by WAAPI `transform` alone, stacked between the `near` and\n *   `front` canvases, so they cross in front of the towers and behind the trees.\n * - Windows switch on and off whole, one at a time in random order: lights, not fades. A far dot's\n *   blink repaints only its own cell.\n * - Idle flicker, twinkles and the train timetable run only while the scene is on screen and the\n *   tab is visible.\n * - Reduced motion draws the city lit at once, with nothing rising, blinking or crossing; hover\n *   still lights a tower and shows its sign, and a departure fades a train in standing still.\n */\nconst TIMING = {\n  /** An ink tower rising out of the ground on first view, in seconds (the `riseDuration` prop). */\n  riseDuration: 0.55,\n  /** Between towers rising, left to right, in seconds (the `stagger` prop). */\n  stagger: 0.03,\n  /** When a rising tower's windows start switching on, as a share of its rise. */\n  land: 0.6,\n  /** A landed tower's windows switching on, one by one, in seconds. */\n  switchOn: 0.45,\n  /** The far print, the trees and the sun fading up on first view, in ms. */\n  appear: 320,\n  /** The sun rising into place on first view, in ms. */\n  sun: 600,\n  /** The curve towers rise, reshape and light up on, as cubic-bezier points. */\n  ease: [0.23, 1, 0.32, 1],\n  /** The same curve for the first view's fade-ups and the sun, as a CSS easing. */\n  cssEase: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n  /** A tower lighting up, bottom floor to top, in seconds. */\n  glow: 0.18,\n  /** And going back, in seconds. */\n  unglow: 0.12,\n  /** A new `lights` switching windows on or off, in seconds. */\n  lights: 0.45,\n  /** A tower growing or shrinking to a new value, in seconds. */\n  reshape: 0.45,\n  /** A sign riding along as its tower grows or shrinks, as Tailwind classes. */\n  signRide: \"duration-[450ms] ease-[cubic-bezier(0.23,1,0.32,1)]\",\n  /** A sign rising 6px out of the roof and back, as Tailwind classes. */\n  sign: \"duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n  /** A tower's label brightening while it's lit, as a Tailwind class. */\n  label: \"duration-150\",\n  /** Between windows switching at idle, in seconds (the `flicker` prop). */\n  flicker: 0.34,\n  /** How long a switched window stays switched, min and max, in seconds. */\n  flickerHold: [1.6, 4.2],\n  /** Between far dots blinking out, in ms. */\n  twinkle: 110,\n  /** How long a far dot stays out, min and max, in seconds. */\n  twinkleHold: [0.3, 1.2],\n  /** Between trains, counted from each departure, in seconds (the `headway` prop). */\n  headway: 9,\n  /** After the towers start rising, before the first train leaves, in seconds. */\n  firstTrain: 0.9,\n  /** Back on screen or in the tab, before the next train, as a share of `headway`. */\n  resume: 0.5,\n  /** A crossing train: min and max px/s, and scene widths per second between them. */\n  trainPace: [380, 820, 0.55],\n  /** How fast trains cross, as a multiple of that pace (the `trainSpeed` prop). */\n  trainSpeed: 1,\n  /** A crossing train's curve: a steady speed. */\n  trainEase: \"linear\",\n  /** How long a train stands on the line under reduced motion, in seconds. */\n  park: 2.4,\n  /** A standing train fading in and out, in ms. */\n  parkFade: 200,\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * SKYLINE STORYBOARD\n *\n *  rest      a printed city along the foot of the box: far towers\n *            screened in halftone dots in the tint, ink towers in\n *            front with paper windows knocked out of them, a flat sun\n *            disc behind and stippled trees along the bottom. Give it\n *            `towers` and each tower is one of your numbers, labelled\n *            under it; leave them out and a city grows from `seed`\n *  view      the first time a quarter of it shows, the far print and\n *            the trees fade up (0.32s) and the sun rises 14px into\n *            place, while the ink towers rise out of the ground left\n *            to right, 30ms apart (the spread capped at 0.36s), each in\n *            0.55s on a strong ease-out. As a tower lands its windows\n *            switch on one at a time, in random order, over 0.45s:\n *            lights, not fades\n *  hover     the tower under the pointer lights up bottom floor to\n *            top in the glow color in 0.18s and goes back in 0.12s.\n *            With `towers` the others' windows dim and a sign rises\n *            6px out of the roof with the label and the value (0.16s)\n *  lights    a new `lights` switches windows on or off one at a time,\n *            in random order, over 0.45s\n *  values    new `towers` values grow or shrink each tower in 0.45s\n *            from wherever it stands, its sign riding along\n *  train     with `train` an elevated line runs across the front. A\n *            train crosses it every `headway` seconds at a steady\n *            speed, about two seconds across 1280px, passing behind\n *            the trees; bump `departures` and one leaves at once. It\n *            moves by transform alone, on its own layer\n *  idle      while it's on screen a window switches on or off every\n *            0.34s and a few far dots blink out and back. All of it\n *            sleeps off screen and in a hidden tab\n *  touch     a tap on a tower shows its sign until the next tap\n *  keys      the towers are one tab stop: the arrows walk along them,\n *            Home and End jump to the ends, and focus shows the sign\n *  reduced   nothing rises, blinks or flickers: the city is drawn lit.\n *            Hover still lights a tower and shows its sign, at once. A\n *            departure fades a train in, standing on the line for 2.4s\n * ───────────────────────────────────────────────────────── */\n\nconst SKYLINE = {\n  spread: 12, // towers; the stagger stops growing after this many (0.36s by default)\n  sunRise: 14, // px the sun rises into place on first view\n  dim: 0.55, // how far the other towers' windows dim while one is lit (with `towers`)\n  spacing: 48, // px kept between two trains on the line\n} as const;\n\nconst CITY = {\n  pitch: 5, // px between the far print's dots\n  floor: 7, // px per floor\n  far: [26, 66], // px; a far tower's width\n  near: [34, 82], // px; an ink tower's width, when the city grows from `seed`\n  tree: [46, 110], // px between trees\n  cloud: 460, // px of width per cloud\n  top: 0.06, // share of the height left over the tallest tower, when the city grows from `seed`\n  headroom: 44, // px left over the tallest tower for its sign, with `towers`\n  labels: 28, // px; the strip under the towers for their labels\n  deck: 0.27, // the line's deck above the ground, as a share of the height\n  car: [14, 28, 0.072], // px min, max, and share of the height: a train car\n  line: 0.3, // share of a generated city's towers drawn in line, paper with an ink outline\n  gap: 1.5, // px of paper between overlapping towers\n} as const;\n\nconst EASE_OUT = cubicBezier(...TIMING.ease);\nconst SUN_DEFAULT = \"#ff5a1f\";\nconst TAU = Math.PI * 2;\n\n// The train's art, in viewBox units: four cars 78 long with 4 between, 24 tall.\nconst TRAIN = { cars: 4, car: 78, gap: 4, height: 24 } as const;\nconst TRAIN_WIDTH = TRAIN.cars * TRAIN.car + (TRAIN.cars - 1) * TRAIN.gap;\nconst TRAIN_CLASS = \"pointer-events-none absolute top-0 left-0 opacity-0 will-change-transform\";\n\nconst clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));\n\n/** A small, fast, seedable random number generator (mulberry32). */\nfunction random(seed: number) {\n  let a = seed >>> 0 || 1;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nexport interface SkylineTower {\n  /** The tower's name, under it and on its sign. */\n  label: string;\n  /** Its height, in your own units: the tallest tower stands nearly as tall as the scene. */\n  value: number;\n  /** What its sign says. Defaults to `value` with thousands separators. */\n  display?: string;\n}\n\nexport interface SkylineProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** One tower per number, left to right, labelled under it. Leave it out for a city grown from `seed`. */\n  towers?: SkylineTower[];\n  /** Reshapes the generated city, the far print, the clouds, the trees and which windows are lit. Any whole number. */\n  seed?: number;\n  /** Share of the windows that are lit, from 0 to 1. A new value switches them on or off one at a time. */\n  lights?: number;\n  /** The sun disc behind the city. A number from 0 to 1 sets where it stands across the scene. */\n  sun?: boolean | number;\n  /** An elevated line across the front, with trains crossing on it. */\n  train?: boolean;\n  /** Seconds between trains, counted from each departure. `0` runs them only when sent with `departures`. */\n  headway?: number;\n  /** Bump this number to send a train at once, from a button for instance. */\n  departures?: number;\n  /** Stippled trees along the foot of the scene, in front of everything. */\n  trees?: boolean;\n  /** With `towers`: show each tower's sign only while it's hovered, focused or tapped, or all the time. */\n  signs?: \"hover\" | \"always\";\n  /** Let the city live while it's on screen: a window switches on or off every so often and far dots blink. */\n  idle?: boolean;\n  /** Raise the towers and switch their lights on the first time the scene shows. */\n  intro?: boolean;\n  /** Seconds each tower takes to rise out of the ground on first view. */\n  riseDuration?: number;\n  /** Seconds between towers rising, left to right, on first view. The spread stops growing after twelve towers. */\n  stagger?: number;\n  /** How fast trains cross, as a multiple of the default pace (about two seconds across 1280px). */\n  trainSpeed?: number;\n  /** Seconds between windows switching on or off while the city idles. */\n  flicker?: number;\n  /** What the scene shows, for screen readers. Without `towers` and without a label, the scene is decorative. */\n  label?: string;\n}\n\n/* ── The city ───────────────────────────────────────────── */\n\ntype Crown = \"flat\" | \"step\" | \"antenna\" | \"spire\" | \"slant\";\ntype Facade = \"grid\" | \"slits\" | \"bands\";\n\n/** A window, placed from the tower's left edge and up from the ground. */\ntype Win = { x: number; y: number; w: number; h: number; floor: number; r: number; on: number };\n\ntype Tower = {\n  index: number;\n  x: number;\n  w: number;\n  h: number; // target height of the roof over the ground, px\n  crown: Crown;\n  line: boolean; // drawn in line, paper with an ink outline, instead of solid ink\n  flip: boolean; // which way a slanted roof falls\n  wins: Win[]; // bottom floor first\n  from: number; // the rise or reshape tween\n  now: number;\n  start: number;\n  dur: number;\n  land: number; // s; when its windows start switching on\n  glow: number; // 0–1: how far up the glow has lit it\n  glowFrom: number;\n  glowTo: number;\n  glowStart: number;\n  glowDur: number;\n};\n\ntype Dot = { x: number; y: number; s: number; round: boolean };\ntype Tree = { x: number; trunk: number; blobs: { x: number; y: number; r: number }[] };\n\n/** What the overlay needs to place labels, signs and hit areas. */\nexport type SkylineLayout = {\n  width: number;\n  height: number;\n  ground: number;\n  risen: boolean;\n  towers: { x: number; w: number; h: number; slot: number; slotWidth: number }[];\n};\n\ntype Options = {\n  towers: SkylineTower[] | null;\n  seed: number;\n  lights: number;\n  sun: boolean;\n  sunX: number;\n  train: boolean;\n  headway: number;\n  trees: boolean;\n  idle: boolean;\n  intro: boolean;\n  rise: number;\n  stagger: number;\n  trainSpeed: number;\n  flicker: number;\n  reduced: boolean;\n};\n\ntype Parts = {\n  root: HTMLElement;\n  back: HTMLCanvasElement;\n  near: HTMLCanvasElement;\n  front: HTMLCanvasElement;\n  sun: HTMLElement;\n  trains: HTMLElement[];\n  tint: HTMLElement;\n  glow: HTMLElement;\n};\n\n/** How far below the roof the windows stop, for the crown's shape. */\nfunction crownDepth(t: Tower, h: number) {\n  if (t.crown === \"step\") return Math.min(26, h * 0.14) + 3;\n  if (t.crown === \"spire\") return Math.min(t.w * 0.8, h * 0.22, 40) + 3;\n  if (t.crown === \"slant\") return Math.min(t.w * 0.35, h * 0.2) + 3;\n  return 5;\n}\n\nfunction outline(t: Tower, ground: number, h: number) {\n  const p = new Path2D();\n  const x0 = t.x;\n  const x1 = t.x + t.w;\n  const top = ground - h;\n  if (t.crown === \"step\") {\n    const sh = Math.min(26, h * 0.14);\n    const inset = t.w * 0.16;\n    p.moveTo(x0, ground);\n    p.lineTo(x0, top + sh);\n    p.lineTo(x0 + inset, top + sh);\n    p.lineTo(x0 + inset, top);\n    p.lineTo(x1 - inset, top);\n    p.lineTo(x1 - inset, top + sh);\n    p.lineTo(x1, top + sh);\n    p.lineTo(x1, ground);\n  } else if (t.crown === \"spire\") {\n    const sh = Math.min(t.w * 0.8, h * 0.22, 40);\n    p.moveTo(x0, ground);\n    p.lineTo(x0, top + sh);\n    p.lineTo(x0 + t.w * 0.28, top + sh);\n    p.lineTo((x0 + x1) / 2, top);\n    p.lineTo(x1 - t.w * 0.28, top + sh);\n    p.lineTo(x1, top + sh);\n    p.lineTo(x1, ground);\n  } else if (t.crown === \"slant\") {\n    const d = Math.min(t.w * 0.35, h * 0.2);\n    p.moveTo(x0, ground);\n    p.lineTo(x0, top + (t.flip ? 0 : d));\n    p.lineTo(x1, top + (t.flip ? d : 0));\n    p.lineTo(x1, ground);\n  } else {\n    p.rect(x0, top, t.w, h);\n  }\n  p.closePath();\n  return p;\n}\n\n/** A tower's windows, floor by floor up to `height`. */\nfunction facade(rand: () => number, w: number, height: number, kind: Facade): Win[] {\n  const wins: Win[] = [];\n  const floors = Math.max(0, Math.floor((height - 6) / CITY.floor));\n  if (kind === \"bands\") {\n    const inset = Math.min(4, w * 0.15);\n    for (let f = 0; f < floors; f++) {\n      wins.push({ x: inset, y: 5 + f * CITY.floor + 2, w: w - inset * 2, h: 2, floor: f, r: rand(), on: rand() });\n    }\n    return wins;\n  }\n  const [pitch, ww, wh] = kind === \"slits\" ? [5, 2, CITY.floor - 3] : [6, 3, 3];\n  const cols = Math.max(1, Math.floor((w - 6 + (pitch - ww)) / pitch));\n  const left = (w - (cols * pitch - (pitch - ww))) / 2;\n  for (let f = 0; f < floors; f++) {\n    for (let c = 0; c < cols; c++) {\n      wins.push({ x: left + c * pitch, y: 5 + f * CITY.floor, w: ww, h: wh, floor: f, r: rand(), on: rand() });\n    }\n  }\n  return wins;\n}\n\n/** The nearest painted background behind the scene: windows and the train are cut from it. */\nfunction paperBehind(el: HTMLElement) {\n  for (let n: HTMLElement | null = el; n; n = n.parentElement) {\n    const bg = getComputedStyle(n).backgroundColor;\n    if (bg && bg !== \"transparent\" && !/(,\\s*0\\)|\\/\\s*0\\))$/.test(bg)) return bg;\n  }\n  return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"#0a0a0a\" : \"#ffffff\";\n}\n\nfunction createCity() {\n  let parts: Parts | null = null;\n  let back: CanvasRenderingContext2D | null = null;\n  let near: CanvasRenderingContext2D | null = null;\n  let front: CanvasRenderingContext2D | null = null;\n  let opts: Options = {\n    towers: null,\n    seed: 7,\n    lights: 0.6,\n    sun: true,\n    sunX: 0.62,\n    train: false,\n    headway: TIMING.headway,\n    trees: true,\n    idle: true,\n    intro: true,\n    rise: TIMING.riseDuration,\n    stagger: TIMING.stagger,\n    trainSpeed: TIMING.trainSpeed,\n    flicker: TIMING.flicker,\n    reduced: false,\n  };\n\n  let width = 0;\n  let height = 0;\n  let dpr = 1;\n  let ground = 0;\n  let deck = 0;\n  let car = 18;\n  let towers: Tower[] = [];\n  let order: Tower[] = []; // draw order: taller ones behind\n  let dots: Dot[] = [];\n  let twinkles: number[] = []; // dots that may blink: the square ones on far towers\n  let trees: Tree[] = [];\n  let colors = { ink: \"#000\", paper: \"#fff\", tint: \"#888\", glow: SUN_DEFAULT, light: \"#fff\" };\n  let lit = false; // a light color was set: the city is lit up, so every tower is solid ink\n\n  let entered = false; // the intro has started (or was skipped)\n  let risen = false; // and finished\n  let visible = false;\n  let raf = 0;\n  let pending = 0; // s; until when the intro's windows are still switching on\n  let threshold = { from: 0.6, to: 0.6, start: 0, value: 0.6 };\n  let active: number | null = null;\n  const flips = new Map<number, number>(); // tower * 4096 + window → s when it goes back\n  const out = new Map<number, number>(); // far dot → s when it comes back\n  const running = new Map<HTMLElement, Animation>();\n  let lastDeparture = -Infinity;\n  let flickerTimer = 0;\n  let twinkleTimer = 0;\n  let headTimer = 0;\n  let listener: ((layout: SkylineLayout) => void) | null = null;\n  let jitter = random(1);\n  const cleanups: (() => void)[] = [];\n\n  const clock = () => performance.now() / 1000;\n  const between = (a: number, b: number) => a + (b - a) * jitter();\n  const isData = () => Boolean(opts.towers && opts.towers.length);\n\n  function readColors() {\n    if (!parts) return;\n    const root = getComputedStyle(parts.root);\n    const own = (name: string) => root.getPropertyValue(name).trim();\n    const paper = own(\"--skyline-paper\") || paperBehind(parts.root);\n    colors = {\n      ink: root.color,\n      paper,\n      tint: getComputedStyle(parts.tint).color,\n      glow: getComputedStyle(parts.glow).color,\n      light: own(\"--skyline-light\") || paper,\n    };\n    lit = Boolean(own(\"--skyline-light\"));\n    parts.root.style.setProperty(\"--skyline-paper-now\", paper);\n  }\n\n  /** Grow the city for the current size and options. Keeps each tower's height where it stood. */\n  function build(keep: boolean) {\n    const rand = random(opts.seed * 7919 + 13);\n    const data = opts.towers && opts.towers.length ? opts.towers : null;\n    ground = data ? height - CITY.labels : height;\n    const top = data ? CITY.headroom : height * CITY.top;\n    const avail = Math.max(0, ground - top);\n    car = clamp(height * CITY.car[2], CITY.car[0], CITY.car[1]);\n    deck = ground - height * CITY.deck;\n    const previous = keep ? towers : [];\n    const t0 = clock();\n    // Towers stand shorter around the sun, so it sets in a gap instead of behind a wall.\n    const dip = (center: number) => (opts.sun ? Math.exp(-((center - opts.sunX) ** 2) / 0.006) : 0);\n\n    // The far print: towers and clouds screened onto one grid of dots.\n    const far: { x: number; w: number; h: number; tone: number }[] = [];\n    for (let x = -rand() * 30; x < width; ) {\n      const w = CITY.far[0] + (CITY.far[1] - CITY.far[0]) * rand();\n      const center = (x + w / 2) / Math.max(1, width);\n      const swell = (0.7 + 0.3 * Math.exp(-((center - 0.55) ** 2) / 0.09)) * (1 - 0.3 * dip(center));\n      far.push({ x, w, h: avail * (0.32 + 0.6 * rand()) * swell, tone: 0.6 + 0.4 * rand() });\n      x += w + (-12 + 18 * rand());\n    }\n    const clouds: { x: number; y: number; rx: number; ry: number }[] = [];\n    if (!data) {\n      const count = Math.max(1, Math.round(width / CITY.cloud));\n      for (let i = 0; i < count; i++) {\n        const rx = 46 + 54 * rand();\n        clouds.push({\n          x: (width * (i + 0.15 + 0.7 * rand())) / count,\n          y: top + avail * (0.04 + 0.26 * rand()),\n          rx,\n          ry: rx * (0.3 + 0.12 * rand()),\n        });\n      }\n    }\n    const p = CITY.pitch;\n    const cols = Math.ceil(width / p);\n    const rows = Math.ceil(ground / p);\n    dots = [];\n    twinkles = [];\n    for (let gy = 0; gy < rows; gy++) {\n      const cy = gy * p + p / 2;\n      for (let gx = 0; gx < cols; gx++) {\n        const cx = gx * p + p / 2;\n        let tone = 0;\n        let round = false;\n        for (const f of far) {\n          if (cx < f.x || cx >= f.x + f.w || cy < ground - f.h) continue;\n          tone = Math.max(tone, f.tone * (0.3 + 0.7 * ((ground - cy) / f.h) ** 0.8));\n        }\n        for (const c of clouds) {\n          const d = ((cx - c.x) / c.rx) ** 2 + ((cy - c.y) / c.ry) ** 2;\n          if (d >= 1) continue;\n          const t = (1 - d) ** 0.7 * 0.9;\n          if (t > tone) {\n            tone = t;\n            round = true;\n          }\n        }\n        const r = rand();\n        if (tone < 0.1 || (!round && r < 0.07)) continue;\n        tone *= 0.88 + 0.2 * rand();\n        if (!round) twinkles.push(dots.length);\n        dots.push({ x: cx, y: cy, s: round ? p * 0.5 * Math.sqrt(tone) : p * 0.86 * Math.sqrt(tone), round });\n      }\n    }\n\n    // The ink towers.\n    towers = [];\n    const make = (\n      index: number,\n      x: number,\n      w: number,\n      h: number,\n      crown: Crown,\n      kind: Facade,\n      reach: number,\n      line = false,\n    ): Tower => {\n      const before = previous[index];\n      const from = before ? before.now : 0;\n      return {\n        index,\n        x,\n        w,\n        h,\n        crown,\n        line,\n        flip: rand() < 0.5,\n        wins: facade(rand, w, reach, kind),\n        from,\n        now: entered ? from : 0,\n        start: t0,\n        dur: before && !opts.reduced ? TIMING.reshape : 0,\n        land: entered ? -Infinity : Infinity,\n        glow: 0,\n        glowFrom: 0,\n        glowTo: 0,\n        glowStart: 0,\n        glowDur: 0,\n      };\n    };\n    if (data) {\n      const pad = 8;\n      const slot = (width - pad * 2) / data.length;\n      const w = clamp(slot * 0.58, 4, 112);\n      const max = Math.max(0, ...data.map((d) => d.value)) || 1;\n      const kinds: Facade[] = [\"grid\", \"slits\", \"grid\", \"bands\"];\n      data.forEach((d, i) => {\n        const h = d.value > 0 ? Math.max(CITY.floor * 2 + 6, (d.value / max) * avail) : 0;\n        towers.push(make(i, pad + i * slot + (slot - w) / 2, w, h, i % 3 === 1 ? \"step\" : \"flat\", kinds[i % 4], avail));\n      });\n      order = towers;\n    } else {\n      const crowns: Crown[] = [\"flat\", \"flat\", \"step\", \"antenna\", \"spire\", \"slant\"];\n      const kinds: Facade[] = [\"grid\", \"grid\", \"slits\", \"bands\"];\n      let i = 0;\n      for (let x = -(4 + 20 * rand()); x < width + 10; ) {\n        const w = CITY.near[0] + (CITY.near[1] - CITY.near[0]) * rand();\n        const center = (x + w / 2) / Math.max(1, width);\n        const swell = (0.55 + 0.45 * Math.exp(-((center - 0.58) ** 2) / 0.07)) * (1 - 0.5 * dip(center));\n        const h = avail * clamp((0.18 + 0.7 * rand()) * swell + 0.06, 0.14, 0.96);\n        const crown = crowns[Math.floor(rand() * crowns.length)];\n        const line = rand() < CITY.line;\n        towers.push(make(i++, x, w, h, crown, kinds[Math.floor(rand() * kinds.length)], h, line));\n        x += w + (-10 + 50 * rand());\n      }\n      order = [...towers].sort((a, b) => b.h - a.h);\n    }\n\n    // Trees along the foot.\n    trees = [];\n    if (opts.trees) {\n      const scale = clamp(height / 420, 0.85, 1.3);\n      for (let x = -10 + 30 * rand(); x < width + 20; x += CITY.tree[0] + (CITY.tree[1] - CITY.tree[0]) * rand()) {\n        const r = (17 + 12 * rand()) * scale;\n        const cy = ground - r * (1.05 + 0.55 * rand());\n        const blobs = [{ x, y: cy, r }];\n        const extra = 2 + Math.floor(rand() * 3);\n        for (let b = 0; b < extra; b++) {\n          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()) });\n        }\n        trees.push({ x, trunk: cy, blobs });\n      }\n    }\n\n    jitter = random(opts.seed * 31 + 5);\n    flips.clear();\n    out.clear();\n  }\n\n  function size() {\n    if (!parts) return;\n    width = parts.root.clientWidth;\n    height = parts.root.clientHeight;\n    dpr = Math.min(window.devicePixelRatio || 1, 2);\n    for (const canvas of [parts.back, parts.near, parts.front]) {\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n    }\n    for (const ctx of [back, near, front]) ctx?.setTransform(dpr, 0, 0, dpr, 0, 0);\n  }\n\n  function placeTrains() {\n    if (!parts) return;\n    for (const el of parts.trains) {\n      el.style.top = `${deck - car + 1}px`;\n      el.style.height = `${car}px`;\n      el.style.width = `${(car * TRAIN_WIDTH) / TRAIN.height}px`;\n    }\n  }\n\n  function notify() {\n    if (!listener || !isData()) return;\n    const n = towers.length;\n    const pad = 8;\n    const slot = (width - pad * 2) / Math.max(1, n);\n    listener({\n      width,\n      height,\n      ground,\n      risen,\n      towers: towers.map((t, i) => ({ x: t.x, w: t.w, h: t.h, slot: pad + i * slot, slotWidth: slot })),\n    });\n  }\n\n  /* ── Drawing ── */\n\n  function drawDot(ctx: CanvasRenderingContext2D, d: Dot) {\n    if (d.round) {\n      ctx.beginPath();\n      ctx.arc(d.x, d.y, d.s, 0, TAU);\n      ctx.fill();\n    } else {\n      ctx.fillRect(d.x - d.s / 2, d.y - d.s / 2, d.s, d.s);\n    }\n  }\n\n  function drawBack() {\n    if (!back) return;\n    back.clearRect(0, 0, width, height);\n    back.fillStyle = colors.tint;\n    back.beginPath();\n    dots.forEach((d, i) => {\n      if (!d.round || out.has(i)) return;\n      back!.moveTo(d.x + d.s, d.y);\n      back!.arc(d.x, d.y, d.s, 0, TAU);\n    });\n    back.fill();\n    dots.forEach((d, i) => {\n      if (!d.round && !out.has(i)) back!.fillRect(d.x - d.s / 2, d.y - d.s / 2, d.s, d.s);\n    });\n  }\n\n  /** Repaint one far dot's cell, so a blink costs one dot, not the print. */\n  function paintDot(i: number, on: boolean) {\n    const d = dots[i];\n    if (!back || !d) return;\n    const half = CITY.pitch / 2;\n    back.clearRect(d.x - half, d.y - half, CITY.pitch, CITY.pitch);\n    if (!on) return;\n    back.fillStyle = colors.tint;\n    drawDot(back, d);\n  }\n\n  function drawFront() {\n    if (!front) return;\n    front.clearRect(0, 0, width, height);\n    if (!trees.length) return;\n    front.fillStyle = colors.ink;\n    front.strokeStyle = colors.ink;\n    front.lineWidth = 1.5;\n    for (const t of trees) {\n      front.beginPath();\n      front.moveTo(t.x, ground);\n      front.lineTo(t.x, t.trunk);\n      front.stroke();\n      front.beginPath();\n      for (const b of t.blobs) {\n        front.moveTo(b.x + b.r, b.y);\n        front.arc(b.x, b.y, b.r, 0, TAU);\n      }\n      front.fill();\n    }\n    // Knock the light out of each crown: specks that grow toward the upper left,\n    // so the dark side reads as shade, like a stipple drawing.\n    const rand = random(opts.seed * 131 + 7);\n    const step = 3.4;\n    front.globalCompositeOperation = \"destination-out\";\n    front.beginPath();\n    for (const t of trees) {\n      let x0 = Infinity;\n      let x1 = -Infinity;\n      let y0 = Infinity;\n      let y1 = -Infinity;\n      for (const b of t.blobs) {\n        x0 = Math.min(x0, b.x - b.r);\n        x1 = Math.max(x1, b.x + b.r);\n        y0 = Math.min(y0, b.y - b.r);\n        y1 = Math.max(y1, b.y + b.r);\n      }\n      let row = 0;\n      for (let y = y0; y <= y1; y += step * 0.87, row++) {\n        for (let x = x0 + (row % 2 ? step / 2 : 0); x <= x1; x += step) {\n          let best = -1;\n          let nx = 0;\n          let ny = 0;\n          for (const b of t.blobs) {\n            const dx = (x - b.x) / b.r;\n            const dy = (y - b.y) / b.r;\n            const inside = 1 - dx * dx - dy * dy;\n            if (inside > best) {\n              best = inside;\n              nx = dx;\n              ny = dy;\n            }\n          }\n          if (best <= 0) continue;\n          const nz = Math.sqrt(best);\n          const light = -0.55 * nx - 0.62 * ny + 0.56 * nz;\n          let r = clamp((light - 0.12) / 0.88) * step * 0.52;\n          if (rand() < 0.12) r += 0.45;\n          if (r < 0.3) continue;\n          front.moveTo(x + r, y);\n          front.arc(x, y, r, 0, TAU);\n        }\n      }\n    }\n    front.fill();\n    front.globalCompositeOperation = \"source-over\";\n  }\n\n  function drawLine() {\n    if (!near) return;\n    const ink = colors.ink;\n    const paper = colors.paper;\n    const deckH = Math.max(4, car * 0.42);\n    const pillar = Math.max(5, car * 0.42);\n    const spans = Math.max(1, Math.round(width / 150));\n    const span = width / spans;\n    for (let x = span / 2; x < width + pillar; x += span) {\n      near.fillStyle = paper;\n      near.fillRect(x - pillar / 2, deck + deckH, pillar, ground - deck - deckH);\n      near.strokeStyle = ink;\n      near.lineWidth = 1;\n      near.strokeRect(x - pillar / 2 + 0.5, deck + deckH, pillar - 1, ground - deck - deckH);\n      near.fillStyle = ink;\n      near.fillRect(x - pillar * 0.9, deck + deckH, pillar * 1.8, 2);\n    }\n    near.fillStyle = paper;\n    near.fillRect(0, deck, width, deckH);\n    near.fillStyle = ink;\n    near.fillRect(0, deck, width, 1.25);\n    near.fillRect(0, deck + deckH - 1.25, width, 1.25);\n    for (let x = 2; x < width; x += 6) near.fillRect(x, deck + 2.5, 1, deckH - 5);\n  }\n\n  function drawNear(now: number) {\n    if (!near) return;\n    near.clearRect(0, 0, width, height);\n    const data = isData();\n    let focus = 0;\n    if (data) for (const t of towers) focus = Math.max(focus, t.glow);\n    const snap = (v: number) => Math.round(v * dpr) / dpr;\n    for (const t of order) {\n      const h = t.now;\n      if (h < 0.5) continue;\n      const line = t.line && !lit;\n      const path = outline(t, ground, h);\n      near.lineJoin = \"miter\";\n      near.strokeStyle = colors.paper;\n      near.lineWidth = CITY.gap * 2;\n      near.stroke(path);\n      near.fillStyle = line ? colors.paper : colors.ink;\n      near.fill(path);\n      if (line) {\n        near.strokeStyle = colors.ink;\n        near.lineWidth = 1.25;\n        near.stroke(path);\n      }\n      if (t.crown === \"antenna\") {\n        const mast = Math.min(30, h * 0.16);\n        const cx = snap(t.x + t.w / 2);\n        near.fillRect(cx - 0.75, ground - h - mast, 1.5, mast);\n        near.fillRect(cx - 3, ground - h - mast * 0.55, 6, 1.25);\n      }\n      const limit = h - crownDepth(t, h);\n      const floors = Math.max(1, Math.floor((limit - 5) / CITY.floor));\n      const dimmed = data && t.glow === 0 ? 1 - SKYLINE.dim * focus : 1;\n      let fill = \"\";\n      for (let i = 0; i < t.wins.length; i++) {\n        const w = t.wins[i];\n        if (w.y + w.h > limit) break;\n        let color: string;\n        if (t.glow > 0 && (w.floor + 0.5) / floors <= t.glow) {\n          color = colors.glow;\n          near.globalAlpha = 1;\n        } else {\n          let on = w.r < threshold.value;\n          if (flips.has(t.index * 4096 + i)) on = !on;\n          if (!on || now < t.land + w.on * TIMING.switchOn) continue;\n          // On a paper tower a lit window is drawn in ink: it reads as a pane, not a hole.\n          color = line ? colors.ink : colors.light;\n          near.globalAlpha = dimmed;\n        }\n        if (color !== fill) {\n          near.fillStyle = color;\n          fill = color;\n        }\n        near.fillRect(snap(t.x + w.x), snap(ground - w.y - w.h), snap(w.w) || 1 / dpr, snap(w.h) || 1 / dpr);\n      }\n      near.globalAlpha = 1;\n    }\n    if (opts.train) drawLine();\n    if (data) {\n      near.fillStyle = colors.ink;\n      near.fillRect(0, snap(ground), width, 1);\n    }\n  }\n\n  /* ── The loop ── */\n\n  /** Advance every tween; true while anything still moves. */\n  function step(now: number) {\n    let moving = false;\n    for (const t of towers) {\n      if (t.now !== t.h) {\n        const p = t.dur > 0 ? clamp((now - t.start) / t.dur) : 1;\n        t.now = p >= 1 ? t.h : t.from + (t.h - t.from) * EASE_OUT(p);\n        if (now < t.start) t.now = t.from;\n        moving = true;\n      }\n      if (t.glow !== t.glowTo) {\n        const p = t.glowDur > 0 ? clamp((now - t.glowStart) / t.glowDur) : 1;\n        t.glow = p >= 1 ? t.glowTo : t.glowFrom + (t.glowTo - t.glowFrom) * EASE_OUT(p);\n        moving = true;\n      }\n    }\n    if (threshold.value !== threshold.to) {\n      const p = clamp((now - threshold.start) / TIMING.lights);\n      threshold.value = p >= 1 ? threshold.to : threshold.from + (threshold.to - threshold.from) * p;\n      moving = true;\n    }\n    if (now < pending) moving = true;\n    return moving;\n  }\n\n  function tick() {\n    raf = 0;\n    const now = clock();\n    const moving = step(now);\n    drawNear(now);\n    if (moving) raf = requestAnimationFrame(tick);\n    else if (entered && !risen) {\n      risen = true;\n      notify();\n    }\n  }\n\n  function wake() {\n    if (!raf && parts) raf = requestAnimationFrame(tick);\n  }\n\n  /** Draw everything at rest, as it stands now. */\n  function repaint() {\n    drawBack();\n    drawFront();\n    drawNear(clock());\n  }\n\n  /* ── First view ── */\n\n  function enter() {\n    if (entered || !parts) return;\n    entered = true;\n    const now = clock();\n    const animate = opts.intro && !opts.reduced;\n    if (!animate) {\n      for (const t of towers) {\n        t.now = t.h;\n        t.land = -Infinity;\n      }\n      for (const el of [parts.back, parts.front, parts.sun]) el.style.opacity = \"1\";\n      risen = true;\n      notify();\n      repaint();\n      schedule(TIMING.firstTrain);\n      syncIdle();\n      return;\n    }\n    const byX = [...towers].sort((a, b) => a.x - b.x);\n    byX.forEach((t, i) => {\n      t.from = 0;\n      t.now = 0;\n      t.start = now + Math.min(i, SKYLINE.spread) * opts.stagger;\n      t.dur = opts.rise;\n      t.land = t.start + opts.rise * TIMING.land;\n    });\n    pending = now + SKYLINE.spread * opts.stagger + opts.rise * TIMING.land + TIMING.switchOn;\n    const fade = [{ opacity: 0 }, { opacity: 1 }];\n    for (const el of [parts.back, parts.front]) {\n      el.animate(fade, { duration: TIMING.appear, easing: TIMING.cssEase });\n      el.style.opacity = \"1\";\n    }\n    parts.sun.style.opacity = \"1\";\n    parts.sun.animate(\n      [\n        { opacity: 0, transform: `translateY(${SKYLINE.sunRise}px)` },\n        { opacity: 1, transform: \"translateY(0px)\" },\n      ],\n      { duration: TIMING.sun, easing: TIMING.cssEase },\n    );\n    wake();\n    schedule(TIMING.firstTrain);\n    syncIdle();\n  }\n\n  /* ── Trains ── */\n\n  function speed() {\n    const [lo, hi, share] = TIMING.trainPace;\n    return clamp(width * share, lo, hi) * opts.trainSpeed;\n  }\n\n  function depart() {\n    if (!parts || !opts.train || !width) return;\n    const el = parts.trains.find((t) => !running.has(t));\n    if (!el) return;\n    const long = (car * TRAIN_WIDTH) / TRAIN.height;\n    let animation: Animation;\n    if (opts.reduced) {\n      if (running.size) return;\n      el.style.transform = `translateX(${(width - long) / 2}px)`;\n      const total = TIMING.park * 1000 + TIMING.parkFade * 2;\n      const edge = TIMING.parkFade / total;\n      animation = el.animate(\n        [{ opacity: 0 }, { opacity: 1, offset: edge }, { opacity: 1, offset: 1 - edge }, { opacity: 0 }],\n        { duration: total },\n      );\n    } else {\n      // Leave room behind a train that's still pulling out of the left edge.\n      const v = speed();\n      const now = clock();\n      const clear = (long + SKYLINE.spacing) / v;\n      const wait = Math.max(0, lastDeparture + clear - now);\n      lastDeparture = now + wait;\n      animation = el.animate(\n        [\n          { transform: `translateX(${-long}px)`, opacity: 1 },\n          { transform: `translateX(${width}px)`, opacity: 1 },\n        ],\n        { duration: ((width + long) / v) * 1000, delay: wait * 1000, easing: TIMING.trainEase, fill: \"backwards\" },\n      );\n    }\n    running.set(el, animation);\n    const done = () => {\n      if (running.get(el) === animation) running.delete(el);\n    };\n    animation.onfinish = done;\n    animation.oncancel = done;\n  }\n\n  function schedule(after: number) {\n    clearTimeout(headTimer);\n    headTimer = 0;\n    if (!opts.train || opts.headway <= 0 || opts.reduced || !entered || !visible || document.hidden) return;\n    headTimer = window.setTimeout(() => {\n      depart();\n      schedule(opts.headway);\n    }, after * 1000);\n  }\n\n  /* ── Idle life ── */\n\n  function flicker() {\n    const now = clock();\n    for (const [k, until] of flips) if (until <= now) flips.delete(k);\n    const t = towers[Math.floor(jitter() * towers.length)];\n    if (t && t.now > 0) {\n      const limit = t.now - crownDepth(t, t.now);\n      let count = 0;\n      while (count < t.wins.length && t.wins[count].y + t.wins[count].h <= limit) count++;\n      if (count) flips.set(t.index * 4096 + Math.floor(jitter() * count), now + between(...TIMING.flickerHold));\n    }\n    wake();\n  }\n\n  function twinkle() {\n    const now = clock();\n    for (const [i, until] of out) {\n      if (until > now) continue;\n      out.delete(i);\n      paintDot(i, true);\n    }\n    for (let k = 0; k < 3 && twinkles.length; k++) {\n      const i = twinkles[Math.floor(jitter() * twinkles.length)];\n      if (out.has(i)) continue;\n      out.set(i, now + between(...TIMING.twinkleHold));\n      paintDot(i, false);\n    }\n  }\n\n  function syncIdle() {\n    const live = opts.idle && !opts.reduced && entered && visible && !document.hidden;\n    if (live && !flickerTimer) {\n      flickerTimer = window.setInterval(flicker, opts.flicker * 1000);\n      twinkleTimer = window.setInterval(twinkle, TIMING.twinkle);\n    } else if (!live && flickerTimer) {\n      clearInterval(flickerTimer);\n      clearInterval(twinkleTimer);\n      flickerTimer = 0;\n      twinkleTimer = 0;\n      if (flips.size || out.size) {\n        flips.clear();\n        out.clear();\n        if (parts) repaint();\n      }\n    }\n  }\n\n  /** Light one tower (by index), or none: its windows fill with the glow color, bottom floor to top. */\n  function light(index: number | null) {\n    if (index === active) return;\n    active = index;\n    const now = clock();\n    for (const t of towers) {\n      const to = t.index === index ? 1 : 0;\n      if (t.glowTo === to) continue;\n      t.glowFrom = t.glow;\n      t.glowTo = to;\n      t.glowStart = now;\n      t.glowDur = opts.reduced ? 0 : to ? TIMING.glow : TIMING.unglow;\n    }\n    wake();\n  }\n\n  /** Rebuild for a new size or new options and draw it. */\n  function layout(keep: boolean) {\n    if (!parts) return;\n    size();\n    build(keep);\n    if (entered && (!keep || opts.reduced)) {\n      for (const t of towers) {\n        t.now = t.h;\n        t.land = -Infinity;\n      }\n    }\n    placeTrains();\n    notify();\n    drawBack();\n    drawFront();\n    if (entered) wake();\n    drawNear(clock());\n  }\n\n  return {\n    connect(el: Parts) {\n      parts = el;\n      back = el.back.getContext(\"2d\");\n      near = el.near.getContext(\"2d\");\n      front = el.front.getContext(\"2d\");\n      readColors();\n      layout(false);\n      if (!opts.intro || opts.reduced) {\n        for (const node of [el.back, el.front, el.sun]) node.style.opacity = \"1\";\n      }\n\n      let w = width;\n      let h = height;\n      const resize = new ResizeObserver(() => {\n        if (!parts || (parts.root.clientWidth === w && parts.root.clientHeight === h)) return;\n        w = parts.root.clientWidth;\n        h = parts.root.clientHeight;\n        layout(false);\n      });\n      resize.observe(el.root);\n\n      const io = new IntersectionObserver(\n        ([entry]) => {\n          visible = entry.isIntersecting;\n          // The first view schedules its own first train; later ones pick the timetable back up.\n          if (entered) schedule(opts.headway * TIMING.resume);\n          else if (entry.intersectionRatio >= 0.25) enter();\n          syncIdle();\n        },\n        { threshold: [0, 0.25] },\n      );\n      io.observe(el.root);\n\n      const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n      const recolor = () => {\n        readColors();\n        repaint();\n      };\n      scheme.addEventListener(\"change\", recolor);\n      const theme = new MutationObserver(recolor);\n      theme.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"data-theme\", \"style\"] });\n\n      const page = () => {\n        schedule(opts.headway * TIMING.resume);\n        syncIdle();\n      };\n      document.addEventListener(\"visibilitychange\", page);\n\n      // Without `towers` the scene has no buttons; a mouse over a tower lights it.\n      const point = (e: PointerEvent) => {\n        if (e.pointerType !== \"mouse\" || isData() || !parts || !risen) return;\n        const r = parts.root.getBoundingClientRect();\n        const x = e.clientX - r.left;\n        const y = e.clientY - r.top;\n        let hit: number | null = null;\n        for (let i = order.length - 1; i >= 0; i--) {\n          const t = order[i];\n          if (x >= t.x && x <= t.x + t.w && y >= ground - t.now && y <= ground) {\n            hit = t.index;\n            break;\n          }\n        }\n        light(hit);\n      };\n      const leave = () => {\n        if (!isData()) light(null);\n      };\n      el.root.addEventListener(\"pointermove\", point);\n      el.root.addEventListener(\"pointerleave\", leave);\n\n      cleanups.push(\n        () => resize.disconnect(),\n        () => io.disconnect(),\n        () => scheme.removeEventListener(\"change\", recolor),\n        () => theme.disconnect(),\n        () => document.removeEventListener(\"visibilitychange\", page),\n        () => {\n          el.root.removeEventListener(\"pointermove\", point);\n          el.root.removeEventListener(\"pointerleave\", leave);\n        },\n      );\n      return () => {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        clearTimeout(headTimer);\n        clearInterval(flickerTimer);\n        clearInterval(twinkleTimer);\n        flickerTimer = 0;\n        twinkleTimer = 0;\n        for (const a of running.values()) a.cancel();\n        running.clear();\n        cleanups.splice(0).forEach((fn) => fn());\n        parts = null;\n        back = near = front = null;\n        // A remount (Strict Mode, or a new key) starts from the top.\n        entered = false;\n        risen = false;\n      };\n    },\n\n    setOptions(next: Options) {\n      const prev = opts;\n      opts = next;\n      if (!parts) {\n        threshold = { from: next.lights, to: next.lights, start: 0, value: next.lights };\n        return;\n      }\n      if (next.lights !== threshold.to) {\n        threshold = next.reduced\n          ? { from: next.lights, to: next.lights, start: 0, value: next.lights }\n          : { from: threshold.value, to: next.lights, start: clock(), value: threshold.value };\n        wake();\n      }\n      const reshape = prev.towers !== next.towers;\n      const rebuild =\n        prev.seed !== next.seed ||\n      prev.trees !== next.trees ||\n      prev.train !== next.train ||\n      prev.reduced !== next.reduced ||\n      prev.sun !== next.sun ||\n      prev.sunX !== next.sunX;\n      if (rebuild || reshape) {\n        const sameCount = (prev.towers?.length ?? -1) === (next.towers?.length ?? -2);\n        layout(reshape && !rebuild && sameCount);\n      }\n      if (prev.reduced !== next.reduced && next.reduced) {\n        for (const a of running.values()) a.cancel();\n      }\n      schedule(next.headway);\n      if (prev.flicker !== next.flicker && flickerTimer) {\n        clearInterval(flickerTimer);\n        flickerTimer = window.setInterval(flicker, next.flicker * 1000);\n      }\n      syncIdle();\n    },\n\n    listen(fn: (layout: SkylineLayout) => void) {\n      listener = fn;\n      notify();\n      return () => {\n        if (listener === fn) listener = null;\n      };\n    },\n\n    /** Light one tower (by index), or none. */\n    setActive(index: number | null) {\n      light(index);\n    },\n\n    depart,\n  };\n}\n\ntype City = ReturnType<typeof createCity>;\n\n/* ── Train ──────────────────────────────────────────────── */\n\n// Four cars, nose to the right: paper bodies cut from the scene's background,\n// an ink outline, a row of windows and a stripe in the sun's color. With a\n// light color set, the windows are lit, so a train still shows at night.\nfunction TrainArt() {\n  return (\n    <svg viewBox={`0 0 ${TRAIN_WIDTH} ${TRAIN.height}`} className=\"block size-full overflow-visible\" fill=\"none\">\n      {Array.from({ length: TRAIN.cars }, (_, i) => {\n        const x = i * (TRAIN.car + TRAIN.gap);\n        const lead = i === TRAIN.cars - 1;\n        const body = lead\n          ? `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`\n          : `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`;\n        return (\n          <g key={i}>\n            {i > 0 && <rect x={x - TRAIN.gap} y={13} width={TRAIN.gap} height={1.5} fill=\"currentColor\" />}\n            <path d={body} fill=\"var(--skyline-paper-now, #fff)\" stroke=\"currentColor\" strokeWidth={1.25} />\n            {Array.from({ length: lead ? 6 : 7 }, (_, k) => (\n              <rect key={k} x={x + 6 + k * 9.6} y={6} width={6} height={4.5} rx={1} fill=\"var(--skyline-light, currentColor)\" />\n            ))}\n            {lead && <path d={`M${x + 64} 5.5h2.5c3 0 6 2.6 7.2 5H${x + 64}Z`} fill=\"currentColor\" />}\n            <rect x={x + 0.6} y={12.6} width={lead ? 76.5 : 76.8} height={2.2} fill={`var(--skyline-sun, ${SUN_DEFAULT})`} />\n            <rect x={x + 8} y={20} width={12} height={2.6} rx={1.2} fill=\"currentColor\" />\n            <rect x={x + 58} y={20} width={12} height={2.6} rx={1.2} fill=\"currentColor\" />\n          </g>\n        );\n      })}\n    </svg>\n  );\n}\n\n/* ── Skyline ────────────────────────────────────────────── */\n\nexport function Skyline({\n  towers,\n  seed = 7,\n  lights = 0.6,\n  sun = true,\n  train = false,\n  headway = TIMING.headway,\n  departures = 0,\n  trees = true,\n  signs = \"hover\",\n  idle = true,\n  intro = true,\n  riseDuration = TIMING.riseDuration,\n  stagger = TIMING.stagger,\n  trainSpeed = TIMING.trainSpeed,\n  flicker = TIMING.flicker,\n  label,\n  className,\n  style,\n  ...props\n}: SkylineProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const backRef = React.useRef<HTMLCanvasElement>(null);\n  const nearRef = React.useRef<HTMLCanvasElement>(null);\n  const frontRef = React.useRef<HTMLCanvasElement>(null);\n  const sunRef = React.useRef<HTMLDivElement>(null);\n  const trainARef = React.useRef<HTMLDivElement>(null);\n  const trainBRef = React.useRef<HTMLDivElement>(null);\n  const tintRef = React.useRef<HTMLSpanElement>(null);\n  const glowRef = React.useRef<HTMLSpanElement>(null);\n  const [city] = React.useState<City>(createCity);\n  const reduced = useReducedMotion() ?? false;\n  const [layout, setLayout] = React.useState<SkylineLayout | null>(null);\n  const [hover, setHover] = React.useState<number | null>(null);\n  const [focused, setFocused] = React.useState<number | null>(null);\n  const [pinned, setPinned] = React.useState<number | null>(null);\n  const [cursor, setCursor] = React.useState(0);\n  const pointerType = React.useRef(\"mouse\");\n  const sent = React.useRef(departures);\n\n  // A new array with the same numbers isn't a change.\n  const key = towers?.map((t) => `${t.label}\\u0000${t.value}\\u0000${t.display ?? \"\"}`).join(\"\\u0001\") ?? \"\";\n  const [data, setData] = React.useState<SkylineTower[] | null>(towers?.length ? towers : null);\n  const [dataKey, setDataKey] = React.useState(key);\n  if (key !== dataKey) {\n    setDataKey(key);\n    setData(towers?.length ? towers : null);\n  }\n\n  // Options first, so the first layout already has them.\n  React.useLayoutEffect(() => {\n    city.setOptions({\n      towers: data,\n      seed,\n      lights: clamp(lights),\n      sun: sun !== false,\n      sunX: typeof sun === \"number\" ? clamp(sun) : 0.62,\n      train,\n      headway: Math.max(0, headway),\n      trees,\n      idle,\n      intro,\n      rise: Math.max(0, riseDuration),\n      stagger: Math.max(0, stagger),\n      trainSpeed: Math.max(0.05, trainSpeed),\n      flicker: Math.max(0.05, flicker),\n      reduced,\n    });\n  }, [city, data, seed, lights, sun, train, headway, trees, idle, intro, riseDuration, stagger, trainSpeed, flicker, reduced]);\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    const back = backRef.current;\n    const near = nearRef.current;\n    const front = frontRef.current;\n    const sunEl = sunRef.current;\n    const a = trainARef.current;\n    const b = trainBRef.current;\n    const tint = tintRef.current;\n    const glow = glowRef.current;\n    if (!root || !back || !near || !front || !sunEl || !a || !b || !tint || !glow) return;\n    return city.connect({ root, back, near, front, sun: sunEl, trains: [a, b], tint, glow });\n  }, [city]);\n\n  React.useLayoutEffect(() => city.listen(setLayout), [city]);\n\n  React.useEffect(() => {\n    if (departures === sent.current) return;\n    sent.current = departures;\n    city.depart();\n  }, [city, departures]);\n\n  const active = hover ?? focused ?? pinned;\n  React.useEffect(() => {\n    if (data) city.setActive(active);\n  }, [city, data, active]);\n\n  const onKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (!data) return;\n    const n = data.length;\n    const from = Number(e.currentTarget.dataset.tower);\n    const to =\n      e.key === \"ArrowRight\" || e.key === \"ArrowDown\"\n        ? (from + 1) % n\n        : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\"\n          ? (from - 1 + n) % n\n          : e.key === \"Home\"\n            ? 0\n            : e.key === \"End\"\n              ? n - 1\n              : -1;\n    if (to < 0) return;\n    e.preventDefault();\n    setCursor(to);\n    e.currentTarget.parentElement?.querySelector<HTMLButtonElement>(`[data-tower=\"${to}\"]`)?.focus();\n  };\n\n  const sunX = typeof sun === \"number\" ? clamp(sun) : 0.62;\n  const hidden = intro ? { opacity: 0 } : undefined;\n  const decorative = !data && !label;\n\n  return (\n    <div\n      ref={rootRef}\n      role={data ? \"group\" : decorative ? undefined : \"img\"}\n      aria-label={data ? (label ?? \"Chart\") : label}\n      aria-hidden={decorative || undefined}\n      className={cn(\"relative isolate h-80 w-full touch-pan-y overflow-hidden select-none\", className)}\n      style={style}\n      {...props}\n    >\n      <div\n        ref={sunRef}\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute aspect-square h-[54%] -translate-x-1/2 rounded-full [background-color:var(--skyline-sun,#ff5a1f)]\",\n          sun === false && \"hidden\",\n        )}\n        style={{ left: `${sunX * 100}%`, bottom: data ? `calc(${CITY.labels}px + 24%)` : \"24%\", ...hidden }}\n      />\n      <canvas ref={backRef} aria-hidden className=\"pointer-events-none absolute inset-0 size-full\" style={hidden} />\n      <canvas ref={nearRef} aria-hidden className=\"pointer-events-none absolute inset-0 size-full\" />\n      {/* Two trains, so a second can follow the first onto the line. */}\n      <div ref={trainARef} aria-hidden className={TRAIN_CLASS}>\n        <TrainArt />\n      </div>\n      <div ref={trainBRef} aria-hidden className={TRAIN_CLASS}>\n        <TrainArt />\n      </div>\n      <canvas ref={frontRef} aria-hidden className=\"pointer-events-none absolute inset-0 size-full\" style={hidden} />\n      <span ref={tintRef} aria-hidden className=\"hidden [color:var(--skyline-tint,#4562ff)]\" />\n      <span ref={glowRef} aria-hidden className=\"hidden [color:var(--skyline-glow,var(--skyline-sun,#ff5a1f))]\" />\n\n      {data &&\n        layout &&\n        data.map((t, i) => {\n          const g = layout.towers[i];\n          if (!g) return null;\n          const text = t.display ?? t.value.toLocaleString(\"en-US\");\n          const shown = layout.risen && (signs === \"always\" || active === i);\n          const center = clamp(g.x + g.w / 2, 28, layout.width - 28);\n          return (\n            <React.Fragment key={i}>\n              <button\n                type=\"button\"\n                data-tower={i}\n                tabIndex={i === cursor ? 0 : -1}\n                aria-label={`${t.label}: ${text}`}\n                className=\"group/tower absolute top-0 cursor-default outline-none\"\n                style={{ left: g.slot, width: g.slotWidth, height: layout.ground }}\n                onPointerDown={(e) => {\n                  pointerType.current = e.pointerType;\n                }}\n                onPointerEnter={(e) => e.pointerType === \"mouse\" && setHover(i)}\n                onPointerLeave={(e) => e.pointerType === \"mouse\" && setHover((h) => (h === i ? null : h))}\n                onFocus={() => {\n                  setFocused(i);\n                  setCursor(i);\n                }}\n                onBlur={() => setFocused((f) => (f === i ? null : f))}\n                onClick={() => {\n                  if (pointerType.current !== \"mouse\") setPinned((p) => (p === i ? null : i));\n                }}\n                onKeyDown={onKey}\n              >\n                <span\n                  aria-hidden\n                  className=\"absolute bottom-0 rounded-[2px] outline-2 outline-offset-3 outline-current opacity-0 group-focus-visible/tower:opacity-100\"\n                  style={{ left: g.x - g.slot, width: g.w, height: Math.max(g.h, 2) }}\n                />\n              </button>\n              <span\n                aria-hidden\n                className={`pointer-events-none absolute top-0 left-0 transition-transform ${TIMING.signRide} motion-reduce:transition-none`}\n                style={{ transform: `translate(${center}px, ${layout.ground - g.h - 4}px)` }}\n              >\n                <span\n                  className={cn(\n                    `absolute bottom-0 left-0 flex -translate-x-1/2 flex-col items-center transition-[opacity,translate] ${TIMING.sign} motion-reduce:translate-y-0`,\n                    shown ? \"translate-y-0 opacity-100\" : \"translate-y-1.5 opacity-0\",\n                  )}\n                >\n                  <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\">\n                    <span className=\"[color:var(--skyline-paper-now,#fff)] opacity-70\">{t.label}</span>\n                    <span className=\"font-semibold [color:var(--skyline-paper-now,#fff)]\">{text}</span>\n                  </span>\n                  <span className=\"h-1.5 w-px bg-current\" />\n                </span>\n              </span>\n              <span\n                aria-hidden\n                className={cn(\n                  `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}`,\n                  active === i ? \"opacity-100\" : \"opacity-55\",\n                )}\n                style={{ left: g.slot, width: g.slotWidth, height: CITY.labels }}\n              >\n                {t.label}\n              </span>\n            </React.Fragment>\n          );\n        })}\n    </div>\n  );\n}\n"
    }
  ]
}