Empty Docks

The code of the Empty Docks demo, 2 files.

Run the demo All demos

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Empty Docks | WildflowerJS Demo</title>
    <script defer src="/js/analytics.js"></script>
    <!-- The framework loads, then Trillium for the town in three.js, then
         Kinetic with its preview bar. The scenes come last, as a module that
         imports three.js. -->
    <script defer src="/js/dist/wildflower.lite.min.js"></script>
    <script defer src="/js/extensions/three.wf.min.js"></script>
    <script defer src="/js/extensions/kinetic.wf.min.js" data-preview></script>
    <script type="importmap">
    {
        "imports": {
            "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js"
        }
    }
    </script>
    <script type="module" src="app.js"></script>
    <style>
        * { box-sizing: border-box; }
        html, body { margin: 0; background: #0a0b10; color: #e6e6f0; font-family: system-ui, sans-serif; }
        .demo-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; background: #12141c; border-bottom: 1px solid rgba(255,255,255,0.08); }
        .demo-toolbar a { display: flex; align-items: center; gap: 10px; text-decoration: none; color: #5fa6a6; font-size: 1rem; }
        .demo-toolbar .logo { height: 32px; }
        .demo-toolbar .brand-wf { font-weight: 520; }
        .demo-toolbar .brand-js { font-weight: 700; }
        .demo-toolbar .demo-info { font-size: 11px; color: rgba(255,255,255,0.6); text-align: right; }
        .demo-toolbar .demo-info strong { color: rgba(255,255,255,0.9); }
        .frame { padding: 24px; }

        /* Hidden until Kinetic marks the root ready, since before that the
           stage holds unbound markup and every clip at once. */
        #stage:not([data-kinetic-ready]) { visibility: hidden; }

        #stage {
            --ink: #1d2433; --paper: #fbf8f1; --teal: #1f6f8b; --coral: #e2583e; --amber: #e9a53a; --muted: #6b7385;
            --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            --serif: Georgia, "Times New Roman", serif;
            --mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
            position: relative; width: 1280px; height: 720px; overflow: hidden; background: #efe8d9;
            font-family: var(--sans); color: var(--ink);
        }
        .layer { position: absolute; inset: 0; }
        .layer canvas { position: absolute; left: 0; top: 0; width: 1280px; height: 720px; }

        /* The clock, and a pin over each dock the film is showing */
        .clock { position: absolute; left: 32px; top: 28px; padding: 10px 18px 11px; border-radius: 6px; background: var(--ink); color: var(--paper); }
        .clock b { display: block; font-size: 40px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
        .clock span { display: block; margin-top: 5px; font-size: 13px; color: rgba(251,248,241,0.72); }
        .pin { position: absolute; left: 0; top: 0; width: 0; height: 0; }
        .pin .stem { position: absolute; left: -1px; top: 0; width: 2px; background: var(--ink); }
        .pin .card { position: absolute; left: 0; top: 0; transform-origin: 50% 100%; padding: 8px 16px 9px; border: 2px solid var(--ink); border-radius: 4px; background: var(--paper); white-space: nowrap; text-align: center; box-shadow: 0 4px 12px rgba(29,36,51,0.12); }
        .pin .card b { display: block; font-size: 26px; font-weight: 800; line-height: 1.1; letter-spacing: -0.01em; }
        .pin .card span { display: block; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }

        /* The slope, in the side view */
        .slope { position: absolute; left: 0; top: 0; overflow: visible; }
        .slope .arc { fill: none; stroke: var(--coral); stroke-width: 5; stroke-linecap: round; stroke-dasharray: 1; }
        .head { position: absolute; left: 0; top: 0; width: 26px; height: 24px; margin: -12px 0 0 -16px; background: var(--coral); clip-path: polygon(0 0, 100% 50%, 0 100%); }
        .spot { position: absolute; left: 0; top: 0; padding: 7px 14px 8px; border-radius: 4px; background: var(--paper); text-align: center; white-space: nowrap; box-shadow: 0 4px 12px rgba(29,36,51,0.14); }
        .spot b { display: block; font-size: 22px; font-weight: 800; line-height: 1.1; }
        .spot span { display: block; font-size: 15px; color: var(--muted); }

        /* The captions along the bottom */
        .caption { position: absolute; left: 32px; bottom: 34px; max-width: 760px; margin: 0; padding: 12px 18px 13px; border-radius: 6px; background: rgba(251,248,241,0.94); font-size: 26px; font-weight: 650; line-height: 1.25; letter-spacing: -0.01em; box-shadow: 0 6px 18px rgba(29,36,51,0.12); }
        .caption em { font-style: normal; color: var(--coral); }
        .caption i { font-style: normal; color: var(--teal); }

        /* The title */
        .title { position: absolute; left: 0; right: 0; top: 0; height: 720px; display: flex; flex-direction: column; justify-content: center; align-items: center; background: linear-gradient(180deg, rgba(239,232,217,0.0) 0%, rgba(239,232,217,0.55) 40%, rgba(239,232,217,0.55) 60%, rgba(239,232,217,0) 100%); }
        .title h1 { display: flex; margin: 0; font-size: 104px; font-weight: 850; letter-spacing: -0.04em; line-height: 1; }
        .title .ch { display: inline-block; white-space: pre; }
        .title p { margin: 16px 0 0; font: italic 400 30px/1.2 var(--serif); color: var(--muted); }

        /* The chart of one dock's morning */
        .chart { position: absolute; right: 32px; top: 28px; width: 380px; padding: 16px 18px 12px; border-radius: 8px; background: rgba(251,248,241,0.95); box-shadow: 0 6px 18px rgba(29,36,51,0.12); }
        .chart h2 { margin: 0; font-size: 17px; font-weight: 750; }
        .chart small { display: block; margin-top: 2px; font-size: 12px; color: var(--muted); }
        .chart svg { display: block; margin-top: 8px; overflow: visible; }
        .chart .grid { stroke: rgba(29,36,51,0.12); stroke-width: 1; }
        .chart .axis { font: 600 10px var(--mono); fill: var(--muted); }
        .chart .before { fill: none; stroke: var(--teal); stroke-width: 3; stroke-linejoin: round; stroke-linecap: round; }
        .chart .after { fill: none; stroke: var(--amber); stroke-width: 3; stroke-linejoin: round; stroke-linecap: round; }
        .chart .now { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 3 3; }
        .chart .key { display: flex; gap: 14px; margin-top: 6px; font-size: 12px; color: var(--muted); }
        .chart .key i { display: inline-block; width: 14px; height: 3px; margin-right: 5px; vertical-align: middle; border-radius: 2px; }

        /* Rides down the hill against rides up it */
        .stats { position: absolute; left: 32px; bottom: 34px; width: 420px; padding: 22px 26px; border-radius: 8px; background: rgba(251,248,241,0.95); box-shadow: 0 6px 18px rgba(29,36,51,0.12); }
        .stats small { display: block; font-size: 13px; color: var(--muted); }
        .stats .row { display: flex; align-items: baseline; gap: 14px; margin-top: 14px; }
        .stats .row b { min-width: 150px; font-size: 64px; font-weight: 850; letter-spacing: -0.03em; line-height: 1; font-variant-numeric: tabular-nums; text-align: right; }
        .stats .row span { font-size: 20px; font-weight: 650; line-height: 1.2; }
        .stats .bar { height: 10px; margin-top: 8px; border-radius: 5px; }

        /* The end */
        .end { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; background: rgba(239,232,217,0.82); text-align: center; }
        .end h1 { margin: 0; font-size: 92px; font-weight: 850; letter-spacing: -0.04em; line-height: 1; }
        .end p { margin: 14px 0 0; font: italic 400 30px/1.2 var(--serif); color: var(--muted); }
        .end .credit { position: absolute; left: 0; right: 0; bottom: 40px; font: 600 12px/1.6 var(--mono); letter-spacing: 0.12em; color: var(--muted); }

        .about { max-width: 1280px; margin: 0 auto; padding: 0 24px 96px; color: #a9adc2; font-size: 14px; line-height: 1.55; }
        .about p { margin: 0 0 10px; }
        .about b { color: #e6e6f0; font-weight: 600; }
        .about code { color: #e6e6f0; font-size: 13px; }
    </style>
</head>
<body>
    <div class="demo-toolbar">
        <a href="/demos/">
            <img class="logo" src="/assets/wildflower_96.png" alt="WildflowerJS">
            <span><span class="brand-wf">Wildflower</span><span class="brand-js">JS</span> Demos</span>
        </a>
        <div class="demo-info"><strong>Empty Docks</strong>: a data story</div>
    </div>

    <div class="frame">
        <div id="stage" data-composition-id="docks" data-start="0" data-duration="36" data-width="1280" data-height="720" data-fps="30">

            <audio id="music" src="music.m4a" data-start="0" data-duration="36" data-fade-out="1.5"></audio>

            <!-- The town, the clock and the pins, for the whole film -->
            <div class="layer" data-component="docks-town" data-start="0" data-duration="36">
                <canvas width="1280" height="720"></canvas>
                <div data-list="pins" data-key="key">
                    <template>
                        <div class="pin" data-bind-style="place">
                            <i class="stem" data-bind-style="stem"></i>
                            <div class="card" data-bind-style="card"><b data-bind="name"></b><span data-bind="count"></span></div>
                        </div>
                    </template>
                </div>
                <svg class="slope" viewBox="0 0 1280 720" width="1280" height="720" aria-hidden="true">
                    <path class="arc" pathLength="1" data-bind-attr="{ d: slopePath }" data-bind-style="slopeLine"></path>
                </svg>
                <i class="head" data-bind-style="slopeHead"></i>
                <div class="spot" data-bind-style="hillSpot"><b>Hilltop</b><span>60 m above the river</span></div>
                <div class="spot" data-bind-style="townSpot"><b>Downtown</b><span>by the river</span></div>
                <div class="clock" data-bind-style="clockIn"><b data-bind="clockText"></b><span data-bind="day"></span></div>
            </div>

            <div class="layer" data-component="docks-title" data-start="0" data-duration="4.4" data-clip-exit="fade 12f">
                <div class="title">
                    <h1 data-list="letters" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></h1>
                    <p data-bind-style="sub">Why the hill runs out of bikes every weekday morning</p>
                </div>
            </div>

            <p class="caption" data-start="4.4" data-duration="3.4" data-clip-enter="wipe from-left 15f power3.out" data-clip-exit="fade 9f">6:30. The docks on the hill are <i>nearly full</i>.</p>
            <p class="caption" data-start="8" data-duration="4.6" data-clip-enter="wipe from-left 15f power3.out" data-clip-exit="fade 9f">The commute starts, and almost every ride goes <i>downhill</i>.</p>
            <p class="caption" data-start="13" data-duration="3.8" data-clip-enter="wipe from-left 15f power3.out" data-clip-exit="fade 9f">Central Station fills while the hill docks empty.</p>
            <p class="caption" data-start="17.2" data-duration="3.2" data-clip-enter="wipe from-left 15f power3.out" data-clip-exit="fade 9f">8:15. The hill is <em>empty</em>.</p>
            <p class="caption" data-start="26.4" data-duration="3.4" data-clip-enter="wipe from-left 15f power3.out" data-clip-exit="fade 9f">A van takes 30 bikes up the hill at 7:30.</p>
            <p class="caption" data-start="30.2" data-duration="2.4" data-clip-enter="wipe from-left 15f power3.out" data-clip-exit="fade 9f">Fernhill keeps its bikes past 8:15.</p>

            <!-- Fernhill's morning, then the same morning with the van -->
            <div class="layer" data-component="docks-chart" data-start="9.6" data-duration="7.8" data-clip-enter="blur 15f" data-clip-exit="fade 12f">
                <div class="chart">
                    <h2>Fernhill, bikes in the dock</h2>
                    <small data-bind="subtitle"></small>
                    <svg width="344" height="150" viewBox="0 0 344 150" aria-hidden="true">
                        <path class="grid" data-bind-attr="{ d: grid }"></path>
                        <path class="before" data-bind-attr="{ d: before }"></path>
                        <path class="after" data-bind-attr="{ d: after }"></path>
                        <path class="now" data-bind-attr="{ d: now }"></path>
                        <text class="axis" x="-6" y="143" text-anchor="end">0</text>
                        <text class="axis" x="-6" y="97" text-anchor="end">10</text>
                        <text class="axis" x="-6" y="50" text-anchor="end">20</text>
                        <text class="axis" x="0" y="156">6:30</text>
                        <text class="axis" x="138" y="156" text-anchor="middle">7:30</text>
                        <text class="axis" x="275" y="156" text-anchor="middle">8:30</text>
                    </svg>
                    <div class="key"><span><i style="background: var(--teal)"></i>A typical morning</span><span data-bind-style="vanKey"><i style="background: var(--amber)"></i>With the 7:30 van</span></div>
                </div>
            </div>
            <div class="layer" data-component="docks-chart" data-start="27" data-duration="5.6" data-prop-van="true" data-clip-enter="blur 15f" data-clip-exit="fade 12f">
                <div class="chart">
                    <h2>Fernhill, bikes in the dock</h2>
                    <small data-bind="subtitle"></small>
                    <svg width="344" height="150" viewBox="0 0 344 150" aria-hidden="true">
                        <path class="grid" data-bind-attr="{ d: grid }"></path>
                        <path class="before" data-bind-attr="{ d: before }"></path>
                        <path class="after" data-bind-attr="{ d: after }"></path>
                        <path class="now" data-bind-attr="{ d: now }"></path>
                        <text class="axis" x="-6" y="143" text-anchor="end">0</text>
                        <text class="axis" x="-6" y="97" text-anchor="end">10</text>
                        <text class="axis" x="-6" y="50" text-anchor="end">20</text>
                        <text class="axis" x="0" y="156">6:30</text>
                        <text class="axis" x="138" y="156" text-anchor="middle">7:30</text>
                        <text class="axis" x="275" y="156" text-anchor="middle">8:30</text>
                    </svg>
                    <div class="key"><span><i style="background: var(--teal)"></i>A typical morning</span><span data-bind-style="vanKey"><i style="background: var(--amber)"></i>With the 7:30 van</span></div>
                </div>
            </div>

            <!-- Why: rides down the hill against rides up it -->
            <div class="layer" data-component="docks-why" data-start="20.6" data-duration="5.4" data-clip-enter="cover from-left 18f power3.out" data-clip-exit="fade 12f">
                <div class="stats">
                    <small>A typical weekday, 6:30 to 9:00</small>
                    <div class="row"><b data-bind="down"></b><span>rides down the hill</span></div>
                    <div class="bar" data-bind-style="downBar"></div>
                    <div class="row"><b data-bind="up"></b><span>rides back up</span></div>
                    <div class="bar" data-bind-style="upBar"></div>
                </div>
            </div>

            <div class="layer" data-component="docks-end" data-start="32.6" data-duration="3.4" data-clip-enter="fade 18f">
                <div class="end">
                    <h1 data-bind-style="rise">Empty by 8:15</h1>
                    <p data-bind-style="riseLate" data-bind="line"></p>
                    <div class="credit" data-bind-style="riseLate">MADE-UP DATA &nbsp;ยท&nbsp; MADE WITH WILDFLOWERJS</div>
                </div>
            </div>
        </div>
    </div>

    <div class="about">
        <p>Why a bike share's hill docks are empty by 8:15, told on a 3D town. The town and its numbers are made up.</p>
    </div>
</body>
</html>

app.js

// Empty Docks: a 36-second data story on a 3D town. The town is three.js,
// drawn through Trillium; the clock, the pins and the panels are Kinetic
// scenes. A made-up weekday morning is simulated twice when the page loads,
// once without and once with a van that takes bikes up the hill, and every
// value on the stage comes from the composition's time through those two
// mornings, so any frame renders on its own and in any order.

import * as THREE from 'three';

const { scene, styleAt, interpolate, ramp, random, split, stagger, format } = wildflower.kinetic;

wildflower.three.use(THREE);

// ---- Shared ------------------------------------------------------------------

const TEAL = 0x1f6f8b, CORAL = 0xe2583e, AMBER = 0xe9a53a;
const fx = (v, digits) => v.toFixed(digits === undefined ? 2 : digits);
const px = (v) => fx(v) + 'px';

// "6:45 am" from minutes after midnight.
const clockText = (m) => {
    const whole = Math.floor(m), h = Math.floor(whole / 60), min = whole % 60;
    return ((h + 11) % 12 + 1) + ':' + String(min).padStart(2, '0') + (h < 12 ? ' am' : ' pm');
};

// The story's clock, in minutes after midnight, as a track of the
// composition's seconds. It holds on the title, gathers speed into the
// commute, slows into 8:15 and on to 8:17, holds while the side view
// explains, runs back to 7:20 and plays the morning again with the van,
// slowly while the van climbs. Each ease leaves a segment at the speed the
// next one starts with, so riders never stop and leap.
const CLOCK = {
    at: [0, 4.4, 7, 15.5, 17, 20, 26, 27, 30, 31.6, 36],
    to: [390, 390, 405, 488, 495, 497, 497, 440, 464, 495, 500],
    ease: ['linear', 'sine.in', 'linear', 'power2.out', 'power1.out', 'linear', 'power2.inOut', 'sine.inOut', 'power1.inOut', 'linear'],
};
// The second morning, with the van, begins once the clock runs back past the
// van's departure, where the two mornings are still the same.
const VAN_FROM = 26, VAN_LEAVES = 450;
const withVan = (t, clock) => t >= VAN_FROM && (t >= CLOCK.at[7] || clock < VAN_LEAVES);

// ---- The town ------------------------------------------------------------------

// World units, with x running east and z running south.
const STREETS_X = [-16, -12, -8, -4, 0, 4, 8, 12, 16];
const STREETS_Z = [-12, -8, -4, 0, 4, 8];
const RIVER = [8.8, 11.6];            // the river's banks, z
const STREET = 0.38;                  // half a street's width

// The ground: flat downtown in the south, rising to a hill in the north.
const heightAt = (x, z) => 4.2 * (1 - ramp(z, -10, 1.5, 'smooth')) * (1 + 0.12 * Math.sin(x * 0.45 + 1.3));
// The ground's normal at a point, from the height's slope, so a dock lies flat on it.
const normalAt = (x, z) => new THREE.Vector3(heightAt(x - 0.05, z) - heightAt(x + 0.05, z), 0.1, heightAt(x, z - 0.05) - heightAt(x, z + 0.05)).normalize();

const DOCKS = [
    { key: 'fernhill', name: 'Fernhill', x: -6, z: -6, hill: true, bikes: 18, cap: 20 },
    { key: 'summit', name: 'Summit Park', x: 2, z: -6.2, hill: true, bikes: 15, cap: 20 },
    { key: 'ridge', name: 'Ridge Street', x: 6, z: -4.4, hill: true, bikes: 16, cap: 20 },
    { key: 'larch', name: 'Larch Hill', x: -2, z: -2.1, hill: true, bikes: 14, cap: 20 },
    { key: 'market', name: 'Market Square', x: -6, z: 6, bikes: 6, cap: 24 },
    { key: 'station', name: 'Central Station', x: 2, z: 6, bikes: 8, cap: 24 },
    { key: 'river', name: 'Riverside', x: 6, z: 2.2, bikes: 5, cap: 24 },
    { key: 'mill', name: 'Mill Yard', x: -2, z: 2, bikes: 7, cap: 24 },
];
const DOCK = Object.fromEntries(DOCKS.map((d, i) => [d.key, Object.assign({ index: i }, d)]));

// A route from one dock to another along the streets: out to the nearest
// street running north and south, along it to the street nearest the other
// dock, across, and in. The corners are rounded by cutting them (Chaikin),
// and the route sits just above the ground.
const nearest = (v, lines) => lines.reduce((a, b) => (Math.abs(b - v) < Math.abs(a - v) ? b : a));
function routeCurve(from, to) {
    const a = DOCK[from], b = DOCK[to];
    const sx = nearest(a.x, STREETS_X), sz = nearest(b.z, STREETS_Z);
    let pts = [[a.x, a.z], [sx, a.z], [sx, sz], [b.x, sz], [b.x, b.z]];
    for (let k = 0; k < 3; k++) {
        const next = [pts[0]];
        for (let i = 0; i < pts.length - 1; i++) {
            const [x0, z0] = pts[i], [x1, z1] = pts[i + 1];
            next.push([0.75 * x0 + 0.25 * x1, 0.75 * z0 + 0.25 * z1], [0.25 * x0 + 0.75 * x1, 0.25 * z0 + 0.75 * z1]);
        }
        next.push(pts[pts.length - 1]);
        pts = next;
    }
    return new THREE.CatmullRomCurve3(pts.map(([x, z]) => new THREE.Vector3(x, heightAt(x, z) + 0.07, z)), false, 'centripetal');
}

// Each dock on the hill sends its riders to two docks downtown.
const ROUTES = [
    ['fernhill', 'market'], ['fernhill', 'station'], ['summit', 'station'], ['summit', 'mill'],
    ['ridge', 'river'], ['ridge', 'station'], ['larch', 'mill'], ['larch', 'market'],
    ['station', 'summit'], ['mill', 'larch'],
].map(([from, to], i) => { const curve = routeCurve(from, to); return { key: from + '-' + to, i, from, to, curve, length: curve.getLength() }; });
const routeFrom = (from) => ROUTES.filter((r) => r.from === from && DOCK[r.to].hill !== true);

// The van's run from the station up the hill, one leg per stop. It carries
// 30 bikes and tops each hill dock up to its capacity while the load lasts.
const VAN_STOPS = [['station', 'larch'], ['larch', 'fernhill'], ['fernhill', 'summit'], ['summit', 'ridge']];
const VAN_LOAD = 30;

// ---- The morning, simulated -----------------------------------------------------

const RIDE_SPEED = 2.3;              // world units a minute
const TAILS = 24;                    // the most riders on the road at once with a tail (12 were measured)
const TAIL = 3.6;                    // world units of route behind a rider
const TAIL_STEPS = 28;
const BALL = 0.21, RIM = 0.06;       // a rider's radius, and its white border's thickness
const LIFT = 0.25;                   // the ball's centre above the route, so the border clears the street
const TAIL_WIDTH = 0.16;             // half the tail's width at the rider, inside the ball's edge
const VAN_SPEED = 3.4;
// Riders who want a bike on the hill: how many, centred when, spread how
// wide (minutes). Their times are the quantiles of that spread, nudged by a
// seeded generator, so the morning is the same on every load.
const DEMAND = { fernhill: [24, 481, 20], summit: [20, 478, 21], ridge: [21, 474, 19], larch: [17, 476, 22] };
// Two riders go up the hill, at 7:17 and 8:29.
const UPHILL = [['station', 'summit', 437], ['mill', 'larch', 509]];

// The inverse of the normal distribution's CDF (Acklam), for the quantiles.
function probit(p) {
    const a = [-39.6968302866538, 220.946098424521, -275.928510446969, 138.357751867269, -30.6647980661472, 2.50662827745924];
    const b = [-54.4760987982241, 161.585836858041, -155.698979859887, 66.8013118877197, -13.2806815528857];
    const c = [-0.00778489400243029, -0.322396458041136, -2.40075827716184, -2.54973253934373, 4.37466414146497, 2.93816398269878];
    const d = [0.00778469570904146, 0.32246712907004, 2.445134137143, 3.75440866190742];
    const q = p < 0.5 ? p : 1 - p;
    if (q < 0.02425) {
        const r = Math.sqrt(-2 * Math.log(q));
        const v = (((((c[0] * r + c[1]) * r + c[2]) * r + c[3]) * r + c[4]) * r + c[5]) / ((((d[0] * r + d[1]) * r + d[2]) * r + d[3]) * r + 1);
        return p < 0.5 ? v : -v;
    }
    const r = q - 0.5, s = r * r;
    const v = (((((a[0] * s + a[1]) * s + a[2]) * s + a[3]) * s + a[4]) * s + a[5]) * r / (((((b[0] * s + b[1]) * s + b[2]) * s + b[3]) * s + b[4]) * s + 1);
    return p < 0.5 ? v : -v;
}

// One morning: every rider who wants a bike takes one if the dock has one.
// Returns the rides taken, the riders turned away, the van's stops, and each
// dock's changes in time order, from which its count at any minute follows.
function simulate(van) {
    const rnd = random(11);   // the same seed, so the same riders on both mornings
    const wants = [];
    for (const [key, [n, centre, spread]] of Object.entries(DEMAND)) {
        const routes = routeFrom(key);
        for (let i = 0; i < n; i++) {
            const at = centre + spread * probit((i + 0.5) / n) + (rnd() - 0.5) * 3;
            wants.push({ at, route: routes[i % routes.length] });
        }
    }
    for (const [from, to, at] of UPHILL) wants.push({ at, route: ROUTES.find((r) => r.from === from && r.to === to) });
    wants.sort((p, q) => p.at - q.at);

    const stops = [];
    if (van) {
        let at = VAN_LEAVES;
        for (const [from, to] of VAN_STOPS) {
            const route = routeCurve(from, to);
            const leg = { from, to, curve: route, leaves: at, arrives: at + route.getLength() / VAN_SPEED, delivered: 0 };
            stops.push(leg);
            at = leg.arrives + 1.5;   // a minute and a half to unload
        }
    }

    // Arrivals and van drops are applied in time order with the departures,
    // so a rider finds the dock as it is at that minute.
    const changes = DOCKS.map(() => []);
    const count = DOCKS.map((d) => d.bikes);
    const events = wants.map((w) => ({ at: w.at, want: w }))
        .concat(stops.map((s) => ({ at: s.arrives, drop: s })));
    const rides = [], turnedAway = [];
    const pending = [];   // rides on the road, by arrival
    events.sort((p, q) => p.at - q.at);
    const land = (until) => {
        pending.sort((p, q) => p.arrives - q.arrives);
        // A dock at capacity takes no more bikes; the rider docks elsewhere,
        // off the map.
        while (pending.length && pending[0].arrives <= until) {
            const r = pending.shift(), k = DOCK[r.route.to].index;
            if (count[k] >= DOCKS[k].cap) continue;
            count[k]++;
            changes[k].push([r.arrives, count[k]]);
        }
    };
    let load = VAN_LOAD;
    for (const e of events) {
        land(e.at);
        if (e.drop) {
            const k = DOCK[e.drop.to].index;
            e.drop.delivered = Math.min(load, DOCKS[k].cap - count[k]);
            load -= e.drop.delivered;
            count[k] += e.drop.delivered;
            changes[k].push([e.at, count[k]]);
            continue;
        }
        const k = DOCK[e.want.route.from].index;
        if (count[k] === 0) { turnedAway.push(e.want); continue; }
        count[k]--;
        changes[k].push([e.at, count[k]]);
        const ride = { route: e.want.route, leaves: e.at, arrives: e.at + e.want.route.length / RIDE_SPEED };
        rides.push(ride);
        pending.push(ride);
    }
    land(Infinity);
    return { rides, turnedAway, stops, changes };
}

const MORNING = simulate(false);
const VAN_MORNING = simulate(true);
const morningAt = (t, clock) => (withVan(t, clock) ? VAN_MORNING : MORNING);

// A dock's count at a minute: its last change at or before it.
function bikesAt(morning, index, m) {
    let n = DOCKS[index].bikes;
    for (const [at, value] of morning.changes[index]) { if (at <= m) n = value; else break; }
    return n;
}

// The ride counts the film quotes, from the simulated morning.
const DOWN = MORNING.rides.filter((r) => DOCK[r.route.from].hill).length;
const UP = MORNING.rides.length - DOWN;

// ---- The camera ----------------------------------------------------------------

// Each part of the shot is a track: the point looked at (x, z; its height is
// the ground's), the distance to it, the camera's height as an angle, and its
// turn about the point (0 looks north from the south; negative comes round
// from the west). Between 20.2 s and 21.6 s the camera swings round to look
// across the hill, where its slope shows in profile.
const SHOT_AT = [0, 4.4, 7, 12, 15.5, 17.5, 20.2, 21.6, 25.6, 26.8, 29, 32, 36];
const shotTrack = (to) => ({ at: SHOT_AT, to, ease: 'power2.inOut' });
const SHOT = {
    camX: shotTrack([0, -3, -4.5, -1.5, 0, -2.5, -2.5, -2, -2, -2, -3.8, -1.5, 0]),
    camZ: shotTrack([0, -2.5, -4.8, -1, 2, -4.4, -4.4, 2, 2, -3, -4.6, -3, 0]),
    camDist: shotTrack([34, 22, 16.5, 21, 22, 19, 19, 31, 30, 20, 15.5, 19, 31]),
    camElev: shotTrack([58, 46, 42, 44, 46, 44, 44, 8, 10, 44, 42, 46, 56]),
    camAz: shotTrack([-12, -16, -20, -12, -8, -18, -18, -88, -82, -16, -22, -12, -8]),
};
const FOV = 32;

// Places a camera for a shot.
function aim(camera, shot) {
    const e = shot.camElev * Math.PI / 180, a = shot.camAz * Math.PI / 180;
    const ty = heightAt(shot.camX, shot.camZ);
    camera.position.set(
        shot.camX + shot.camDist * Math.cos(e) * Math.sin(a),
        ty + shot.camDist * Math.sin(e),
        shot.camZ + shot.camDist * Math.cos(e) * Math.cos(a));
    camera.lookAt(shot.camX, ty, shot.camZ);
    camera.updateMatrixWorld();
}

// A camera of the same lens, aimed for the scene's time. The town's view
// projects a world point through it into composition pixels, for the pins,
// the slope's arc and its two labels. Behind the camera, or before init()
// has made the view, the point is 0, 0 and not in front.
const probe = new THREE.PerspectiveCamera(FOV, 1280 / 720, 0.1, 200);
let townView = null;
function onScreen(shot, x, y, z) {
    aim(probe, shot);
    const p = townView && townView.projectPoint(x, y, z, probe);
    return p ? { x: p.x, y: p.y, front: true } : { x: 0, y: 0, front: false };
}

// ---- The town scene --------------------------------------------------------------

// Where a ride is at a minute: eased at both ends, as a rider starts and stops.
function rideAt(ride, m) {
    return ride.route.curve.getPointAt(ramp(m, ride.leaves, ride.arrives, 'smooth'));
}

// Where the van is at a minute, and which way it faces: on the road, or
// standing at the dock it reached last while it unloads, and at the last one
// after its run. Before it sets off, nowhere.
function vanAt(stops, m) {
    if (!stops.length || m < stops[0].leaves) return null;
    let leg = stops[0];
    for (const s of stops) if (m >= s.leaves) leg = s;
    const u = ramp(m, leg.leaves, leg.arrives, 'smooth');
    return { at: leg.curve.getPointAt(u), heading: leg.curve.getTangentAt(Math.min(0.999, u)) };
}

// Who gets a pin, when: Fernhill through the commute, the station as it
// fills, every hill dock at 8:15, and Fernhill and the van in the second
// morning.
const PIN_TIMES = {
    fernhill: [[4.6, 20.2], [26.8, 32.4]],
    station: [[13.2, 17.2]],
    summit: [[17.4, 20.2]],
    ridge: [[17.5, 20.2]],
    larch: [[17.6, 20.2]],
};
const shown = (t, spans) => spans.reduce((v, [a, b]) => Math.max(v, Math.min(ramp(t, a, a + 0.4, 'power2.out'), 1 - ramp(t, b - 0.3, b))), 0);

// A pin's styles: its card stands on a stem above the point, and eases
// round to hang below it as the point nears the top of the frame, so the
// card never leaves the frame.
function pinStyle(t, at, show, stem, border) {
    const below = 1 - ramp(at.y, 110, 170, 'smooth');
    const lift = -stem + 2 * stem * below;           // the card's edge nearest the point
    return {
        place: styleAt(t, { translateX: at.x, translateY: at.y, opacity: at.front ? show : 0 }),
        stem: { height: px(stem), ...styleAt(t, { translateY: -stem * (1 - below) }) },
        card: { borderColor: border, transform: 'translate(-50%, calc(' + fx(-100 * (1 - below)) + '% + ' + px(lift) + ')) scale(' + fx(0.9 + 0.1 * show, 3) + ')' },
    };
}

scene('docks-town', {
    // The clock fades out during the side view, when it stands still, and
    // before the end card.
    keyframes: Object.assign({ clock: CLOCK, clockIn: { opacity: { at: [4.2, 4.7, 21.2, 21.6, 26.2, 26.6, 32.4, 32.9], to: [0, 1, 1, 0, 0, 1, 1, 0] } } }, SHOT),
    computed: {
        shot() { return { camX: this.camX, camZ: this.camZ, camDist: this.camDist, camElev: this.camElev, camAz: this.camAz }; },
        morning() { return morningAt(this.time, this.clock); },
        van() { return withVan(this.time, this.clock); },
        // Each dock's count now, and whether it is empty or full.
        docks() {
            const m = this.clock;
            return DOCKS.map((d, i) => {
                const n = bikesAt(this.morning, i, m);
                // Empty is the trouble on the hill; full is the trouble
                // downtown, where a rider then has nowhere to leave a bike.
                const state = d.hill ? (n === 0 ? 'empty' : 'ok') : n >= d.cap ? 'full' : 'ok';
                return { key: d.key, n, fill: Math.min(1, n / d.cap), state };
            });
        },
        // Each rider on the road: where it is, and how far along its route,
        // which its tail is drawn back from.
        riders() {
            const m = this.clock, out = [];
            for (const r of this.morning.rides) {
                if (m < r.leaves || m > r.arrives) continue;
                const u = ramp(m, r.leaves, r.arrives, 'smooth'), p = r.route.curve.getPointAt(u);
                out.push({ x: p.x, y: p.y + LIFT, z: p.z, route: r.route, u });
            }
            return out;
        },
        vanPose() { return vanAt(this.morning.stops, this.clock); },
        // The low morning sun climbs, and the shadows shorten, as the clock runs.
        sun() {
            const k = ramp(this.clock, 390, 540, 'smooth');
            return { az: (78 + 40 * k) * Math.PI / 180, elev: (24 + 20 * k) * Math.PI / 180 };
        },
        // In the side view, an arc from the hilltop down to the river, with a
        // label at each end, laid over the town at the points it joins.
        slope() {
            const t = this.time, shot = this.shot;
            const top = onScreen(shot, -2, heightAt(-2, -8) + 1.2, -8);
            const low = onScreen(shot, -2, heightAt(-2, 6) + 1.2, 6);
            const mx = (top.x + low.x) / 2, my = Math.min(top.y, low.y) - 60;
            return {
                d: 'M ' + fx(top.x) + ' ' + fx(top.y) + ' Q ' + fx(mx) + ' ' + fx(my) + ' ' + fx(low.x) + ' ' + fx(low.y),
                show: shown(t, [[21.8, 26]]), draw: ramp(t, 22.1, 23.1, 'power2.inOut'),
                top, low, angle: Math.atan2(low.y - my, low.x - mx) * 180 / Math.PI,
            };
        },
        slopePath() { return this.slope.d; },
        slopeLine() { return { strokeDashoffset: fx(1 - this.slope.draw, 4), opacity: fx(this.slope.show, 3) }; },
        slopeHead() {
            const s = this.slope;
            return styleAt(this.time, { translateX: s.low.x, translateY: s.low.y, rotate: s.angle, opacity: s.show * ramp(this.time, 22.95, 23.1) });
        },
        hillSpot() { const s = this.slope; return { transform: 'translate(' + px(s.top.x) + ', ' + px(s.top.y - 14) + ') translate(-50%, -100%)', opacity: fx(s.show * ramp(this.time, 22, 22.4), 3) }; },
        townSpot() { const s = this.slope; return { transform: 'translate(' + px(s.low.x) + ', ' + px(s.low.y + 22) + ') translate(-50%, 0)', opacity: fx(s.show * ramp(this.time, 23, 23.4), 3) }; },
        clockText() { return clockText(this.clock); },
        day() { return this.van ? 'Same morning, with the van' : 'Typical weekday'; },
        pins() {
            const t = this.time, shot = this.shot, rows = [];
            for (const [key, spans] of Object.entries(PIN_TIMES)) {
                const d = DOCK[key], show = shown(t, spans);
                const dock = this.docks[d.index];
                const at = onScreen(shot, d.x, heightAt(d.x, d.z) + 0.2, d.z);
                rows.push(Object.assign({
                    key, name: d.name,
                    count: dock.state === 'full' ? 'full, ' + dock.n + ' bikes' : dock.n + (dock.n === 1 ? ' bike' : ' bikes'),
                }, pinStyle(t, at, show, 46 + 10 * (d.index % 2), dock.state === 'empty' ? '#e2583e' : '#1d2433')));
            }
            // The van's own pin while it climbs.
            const v = this.vanPose, vanShow = v ? shown(t, [[28.1, 30.6]]) : 0;
            const at = v ? onScreen(shot, v.at.x, v.at.y + 0.4, v.at.z) : { x: 0, y: 0, front: false };
            // Its stem is long enough to stand its card above a dock's pin
            // while it stops there.
            rows.push(Object.assign({ key: 'van', name: 'Van', count: VAN_LOAD + ' bikes for the hill' }, pinStyle(t, at, vanShow, 130, '#e9a53a')));
            return rows;
        },
    },
    init() {
        const canvas = this.element.querySelector('canvas');
        const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
        renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
        renderer.setSize(1280, 720, false);
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFShadowMap;
        renderer.toneMapping = THREE.NeutralToneMapping;
        renderer.toneMappingExposure = 1.05;

        // A bright, soft morning: most of the light from the sky, a warm sun
        // for the shadows, and shadows kept light so the town stays airy.
        const world = new THREE.Scene();
        world.background = new THREE.Color(0xefe8d9);
        world.fog = new THREE.Fog(0xefe8d9, 34, 70);
        world.add(new THREE.HemisphereLight(0xffffff, 0xcfc5b2, 1.9));
        const sun = new THREE.DirectionalLight(0xfff3e2, 2.3);
        sun.castShadow = true;
        sun.shadow.mapSize.set(4096, 4096);
        sun.shadow.intensity = 0.7;
        Object.assign(sun.shadow.camera, { left: -24, right: 24, top: 20, bottom: -20, near: 1, far: 100 });
        sun.shadow.bias = -0.0004;
        sun.shadow.normalBias = 0.02;
        world.add(sun, sun.target);

        world.add(groundMesh(), houses(), trees());

        // The docks: a white pad, a ring, and a disc whose area is the dock's
        // bikes as a share of its capacity, each lying on the slope under it.
        const flat = (geo) => geo.rotateX(-Math.PI / 2);
        const up = new THREE.Vector3(0, 1, 0);
        const dockMeshes = DOCKS.map((d) => {
            const dock = new THREE.Group();
            dock.position.set(d.x, heightAt(d.x, d.z) + 0.04, d.z);
            dock.quaternion.setFromUnitVectors(up, normalAt(d.x, d.z));
            const pad = new THREE.Mesh(flat(new THREE.CircleGeometry(0.86, 48)), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.9 }));
            const ring = new THREE.Mesh(flat(new THREE.RingGeometry(0.68, 0.84, 48)), new THREE.MeshStandardMaterial({ color: TEAL, roughness: 0.6 }));
            const fill = new THREE.Mesh(flat(new THREE.CircleGeometry(0.58, 48)), new THREE.MeshStandardMaterial({ color: TEAL, roughness: 0.6 }));
            ring.position.y = 0.01;
            fill.position.y = 0.02;
            pad.receiveShadow = true;
            dock.add(pad, ring, fill);
            world.add(dock);
            return { ring, fill };
        });

        // The riders, one sphere each, written from the riders list. The
        // sphere is cel shaded in three tones, and its white border is a
        // larger sphere drawn from the inside, which shows only round the
        // ball's edge from any side.
        const tones = new THREE.DataTexture(new Uint8Array([110, 190, 255]), 3, 1, THREE.RedFormat);
        tones.magFilter = tones.minFilter = THREE.NearestFilter;
        tones.needsUpdate = true;
        const riders = new THREE.InstancedMesh(new THREE.SphereGeometry(BALL, 24, 16), new THREE.MeshToonMaterial({ color: 0x1a6f8a, gradientMap: tones }), 120);
        const rims = new THREE.InstancedMesh(new THREE.SphereGeometry(BALL + RIM, 24, 16), new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide }), 120);
        riders.castShadow = true;
        world.add(riders, rims);

        // Each rider's tail: a flat ribbon behind it at the height of its
        // centre, narrower than the ball where it leaves it, narrowing to a
        // point and fading out. A ribbon is a strip of TAIL_STEPS pairs of
        // points along the route, rewritten for its rider on every seek.
        const tailColour = new THREE.Color(0x1f7d99);
        const tails = Array.from({ length: TAILS }, () => {
            const geo = new THREE.BufferGeometry();
            geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(TAIL_STEPS * 6), 3));
            geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(TAIL_STEPS * 8), 4));
            const index = [];
            for (let k = 0; k < TAIL_STEPS - 1; k++) index.push(2 * k, 2 * k + 1, 2 * k + 2, 2 * k + 1, 2 * k + 3, 2 * k + 2);
            geo.setIndex(index);
            const mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, depthWrite: false, side: THREE.DoubleSide }));
            mesh.frustumCulled = false;
            world.add(mesh);
            return mesh;
        });
        const writeTail = (mesh, rider) => {
            const pos = mesh.geometry.attributes.position, col = mesh.geometry.attributes.color;
            for (let k = 0; k < TAIL_STEPS; k++) {
                const s = k / (TAIL_STEPS - 1);   // 0 at the rider, 1 at the tail's end
                const u = Math.max(0, rider.u - s * TAIL / rider.route.length);
                const p = rider.route.curve.getPointAt(u), dir = rider.route.curve.getTangentAt(u);
                const across = Math.hypot(dir.x, dir.z) || 1, w = TAIL_WIDTH * Math.pow(1 - s, 1.3);
                const sx = dir.z / across * w, sz = -dir.x / across * w, y = p.y + LIFT;
                pos.setXYZ(2 * k, p.x + sx, y, p.z + sz);
                pos.setXYZ(2 * k + 1, p.x - sx, y, p.z - sz);
                const a = 0.95 * Math.pow(1 - s, 1.5);
                col.setXYZW(2 * k, tailColour.r, tailColour.g, tailColour.b, a);
                col.setXYZW(2 * k + 1, tailColour.r, tailColour.g, tailColour.b, a);
            }
            pos.needsUpdate = true;
            col.needsUpdate = true;
        };

        // The van: a body and a cab, amber.
        const van = new THREE.Group();
        const amber = new THREE.MeshStandardMaterial({ color: AMBER, roughness: 0.6 });
        const body = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.5, 1.0), amber);
        body.position.set(0, 0.32, -0.12);
        const cab = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.38, 0.4), amber);
        cab.position.set(0, 0.26, 0.52);
        for (const m of [body, cab]) { m.castShadow = true; van.add(m); }
        world.add(van);

        const camera = new THREE.PerspectiveCamera(FOV, 1280 / 720, 0.1, 200);
        const ctx = this;
        const view = wildflower.three.view('docks-town-view', {
            renderer, scene: world, camera, render: 'change',
            before() {
                aim(camera, ctx.shot);
                // The sun follows the clock, shining on the town's centre.
                const s = ctx.sun;
                sun.position.set(30 * Math.cos(s.elev) * Math.sin(s.az), 30 * Math.sin(s.elev), -30 * Math.cos(s.elev) * Math.cos(s.az));
                ctx.docks.forEach((d, i) => {
                    const r = Math.sqrt(d.fill);
                    dockMeshes[i].fill.scale.set(Math.max(r, 1e-3), 1, Math.max(r, 1e-3));
                    dockMeshes[i].fill.visible = d.n > 0;
                    dockMeshes[i].ring.material.color.setHex(d.state === 'empty' ? CORAL : d.state === 'full' ? AMBER : TEAL);
                    dockMeshes[i].fill.material.color.setHex(d.state === 'full' ? AMBER : TEAL);
                });
                const onRoad = ctx.riders;
                tails.forEach((mesh, i) => {
                    mesh.visible = i < onRoad.length;
                    if (mesh.visible) writeTail(mesh, onRoad[i]);
                });
                const v = ctx.vanPose;
                van.visible = !!v;
                if (v) {
                    van.position.copy(v.at);
                    van.rotation.y = Math.atan2(v.heading.x, v.heading.z);
                }
            },
        });
        view.instanced(() => ctx.riders, riders, { position: ['x', 'y', 'z'], pickable: false });
        view.instanced(() => ctx.riders, rims, { position: ['x', 'y', 'z'], pickable: false });
        townView = view;
        // Shaders compile now, and one render builds the shadow map's own,
        // so the first seek that draws does not pay for them.
        aim(camera, ctx.shot);
        renderer.compile(world, camera);
        renderer.render(world, camera);
    },
});

// The ground: a mesh of the hill, painted with the streets, the blocks, a
// park, the river and faint contour lines. It runs well past the town, so
// no shot sees its edge.
const GW = 80, GD = 60;                            // world units, the ground's size
function groundMesh() {
    const PX = 32;                                 // texture pixels a world unit
    const c = document.createElement('canvas');
    c.width = GW * PX; c.height = GD * PX;
    const g = c.getContext('2d');
    const X = (x) => (x + GW / 2) * PX, Z = (z) => (z + GD / 2) * PX;
    // Beyond the town, open ground; the town's streets are a shade lighter.
    g.fillStyle = '#e4dccb';
    g.fillRect(0, 0, c.width, c.height);
    g.fillStyle = '#e6dece';
    g.fillRect(X(STREETS_X[0] - 1), Z(STREETS_Z[0] - 1), (STREETS_X[STREETS_X.length - 1] - STREETS_X[0] + 2) * PX, (RIVER[0] - STREETS_Z[0] + 1) * PX);
    // The blocks, a shade lighter than the streets, with rounded corners.
    g.fillStyle = '#efe9dc';
    for (let i = 0; i < STREETS_X.length - 1; i++) {
        for (let j = 0; j < STREETS_Z.length - 1; j++) {
            const x0 = STREETS_X[i] + STREET, x1 = STREETS_X[i + 1] - STREET, z0 = STREETS_Z[j] + STREET, z1 = STREETS_Z[j + 1] - STREET;
            g.beginPath();
            g.roundRect(X(x0), Z(z0), (x1 - x0) * PX, (z1 - z0) * PX, 0.5 * PX);
            g.fill();
        }
    }
    // The park downtown and the river along the south edge.
    g.fillStyle = '#d6e3c3';
    g.beginPath(); g.roundRect(X(4 + STREET), Z(4 + STREET), (4 - 2 * STREET) * PX, (4 - 2 * STREET) * PX, 0.5 * PX); g.fill();
    g.fillStyle = '#bcd6dc';
    g.fillRect(0, Z(RIVER[0]), c.width, (RIVER[1] - RIVER[0]) * PX);
    // Contour lines on the hill, every 0.3 units of height.
    g.strokeStyle = 'rgba(120, 104, 80, 0.14)';
    g.lineWidth = 1.5;
    for (let level = 0.3; level < 3.8; level += 0.3) {
        g.beginPath();
        for (let x = -GW / 2; x <= GW / 2; x += 0.25) {
            // Solve heightAt(x, z) = level for z by halving, on the slope.
            let lo = -GD / 2, hi = 1.5;
            for (let k = 0; k < 24; k++) { const mid = (lo + hi) / 2; if (heightAt(x, mid) > level) lo = mid; else hi = mid; }
            if (x === -GW / 2) g.moveTo(X(x), Z(lo)); else g.lineTo(X(x), Z(lo));
        }
        g.stroke();
    }
    // Pale centre lines on the streets.
    g.strokeStyle = 'rgba(255, 255, 255, 0.55)';
    g.lineWidth = 2;
    g.setLineDash([0.4 * PX, 0.4 * PX]);
    const west = STREETS_X[0], east = STREETS_X[STREETS_X.length - 1], north = STREETS_Z[0];
    for (const x of STREETS_X) { g.beginPath(); g.moveTo(X(x), Z(north)); g.lineTo(X(x), Z(RIVER[0] - 0.2)); g.stroke(); }
    for (const z of STREETS_Z) { g.beginPath(); g.moveTo(X(west), Z(z)); g.lineTo(X(east), Z(z)); g.stroke(); }

    const texture = new THREE.CanvasTexture(c);
    texture.colorSpace = THREE.SRGBColorSpace;
    texture.anisotropy = 8;
    const geo = new THREE.PlaneGeometry(GW, GD, GW * 3, GD * 3).rotateX(-Math.PI / 2);
    const pos = geo.attributes.position;
    for (let i = 0; i < pos.count; i++) pos.setY(i, heightAt(pos.getX(i), pos.getZ(i)));
    geo.computeVertexNormals();
    const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ map: texture, roughness: 1 }));
    mesh.receiveShadow = true;
    return mesh;
}

// The lots: up to five houses a block on the hill, up to four buildings of
// one to three storeys a block downtown, none in the park, the river or a
// dock's plaza.
const LOTS = [];
{
    const rnd = random(5);
    for (let i = 0; i < STREETS_X.length - 1; i++) {
        for (let j = 0; j < STREETS_Z.length - 1; j++) {
            const x0 = STREETS_X[i], z0 = STREETS_Z[j];
            if (x0 === 4 && z0 === 4) continue;   // the park
            const downtown = z0 >= 0;
            const spots = downtown ? [[1.2, 1.2], [2.8, 2.8], [1.2, 2.8], [2.8, 1.2]] : [[1, 1], [3, 1], [1, 3], [3, 3], [2, 2]];
            for (const [sx, sz] of spots) {
                const x = x0 + sx + (rnd() - 0.5) * 0.5, z = z0 + sz + (rnd() - 0.5) * 0.5;
                if (z > RIVER[0] - 0.6) continue;
                if (DOCKS.some((d) => Math.hypot(d.x - x, d.z - z) < 1.5)) continue;
                if (rnd() < (downtown ? 0.15 : 0.12)) continue;
                LOTS.push({ x, z, downtown, turn: rnd() < 0.5 ? 0 : Math.PI / 2, size: 0.85 + rnd() * 0.3, storeys: downtown ? 1 + Math.floor(rnd() * 3) : 1, tint: rnd() });
            }
        }
    }
}

// Houses with pitched roofs on the hill, flat-roofed buildings downtown,
// all one instanced mesh each, placed once.
function houses() {
    const group = new THREE.Group();
    const shape = new THREE.Shape([new THREE.Vector2(-0.5, 0), new THREE.Vector2(0.5, 0), new THREE.Vector2(0.5, 0.55), new THREE.Vector2(0, 0.9), new THREE.Vector2(-0.5, 0.55)]);
    const house = new THREE.ExtrudeGeometry(shape, { depth: 0.8, bevelEnabled: false }).translate(0, 0, -0.4);
    const block = new THREE.BoxGeometry(1.3, 1, 1.1).translate(0, 0.5, 0);
    const material = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.95 });
    const hill = LOTS.filter((l) => !l.downtown), town = LOTS.filter((l) => l.downtown);
    const matrix = new THREE.Matrix4(), q = new THREE.Quaternion(), up = new THREE.Vector3(0, 1, 0), colour = new THREE.Color();
    for (const [geo, lots, tall] of [[house, hill, false], [block, town, true]]) {
        const mesh = new THREE.InstancedMesh(geo, material, lots.length);
        lots.forEach((l, i) => {
            q.setFromAxisAngle(up, l.turn);
            matrix.compose(new THREE.Vector3(l.x, heightAt(l.x, l.z) - 0.02, l.z), q, new THREE.Vector3(l.size, tall ? 0.55 * l.storeys : l.size, l.size));
            mesh.setMatrixAt(i, matrix);
            mesh.setColorAt(i, colour.setHSL(0.09, tall ? 0.15 : 0.35, 0.95 + 0.04 * l.tint));
        });
        mesh.castShadow = true;
        mesh.receiveShadow = true;
        group.add(mesh);
    }
    return group;
}

// Trees: a trunk and a rounded crown. A few in the town's gardens, a grove
// in the park, and woods on the open ground round the town, which frame it.
function trees() {
    const rnd = random(9);
    const spots = [];
    const west = STREETS_X[0] - 0.6, east = STREETS_X[STREETS_X.length - 1] + 0.6, north = STREETS_Z[0] - 0.6;
    // The woods stop short of the side view's camera, which stands west of
    // the town.
    for (let tries = 0; tries < 20000 && spots.length < 620; tries++) {
        const x = (rnd() - 0.5) * 48, z = -22 + rnd() * 42;
        if (z > RIVER[0] - 0.3 && z < RIVER[1] + 0.3) continue;
        const inTown = x > west && x < east && z > north && z < RIVER[0];
        const inPark = x > 4.4 && x < 7.6 && z > 4.4 && z < 7.6;
        if (inTown && !inPark) {
            if (rnd() < 0.8) continue;
            if (STREETS_X.some((s) => Math.abs(s - x) < STREET + 0.25) || STREETS_Z.some((s) => Math.abs(s - z) < STREET + 0.25)) continue;
            if (LOTS.some((l) => Math.hypot(l.x - x, l.z - z) < 0.75 * l.size + 0.35)) continue;
            if (DOCKS.some((d) => Math.hypot(d.x - x, d.z - z) < 1.7)) continue;
        } else if (!inPark && rnd() < 0.45) continue;
        spots.push({ x, z, s: 0.65 + rnd() * 0.5, tint: rnd() });
    }
    const crowns = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(0.32, 1).translate(0, 0.55, 0), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1, flatShading: true }), spots.length);
    const trunks = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.045, 0.06, 0.32).translate(0, 0.16, 0), new THREE.MeshStandardMaterial({ color: 0x9b8468, roughness: 1 }), spots.length);
    const matrix = new THREE.Matrix4(), colour = new THREE.Color();
    spots.forEach((p, i) => {
        matrix.makeScale(p.s, p.s, p.s).setPosition(p.x, heightAt(p.x, p.z), p.z);
        crowns.setMatrixAt(i, matrix);
        trunks.setMatrixAt(i, matrix);
        crowns.setColorAt(i, colour.setHSL(0.27 + 0.05 * p.tint, 0.28, 0.55 + 0.08 * p.tint));
    });
    for (const m of [crowns, trunks]) { m.castShadow = true; m.receiveShadow = true; }
    const group = new THREE.Group();
    group.add(crowns, trunks);
    return group;
}

// ---- The title -----------------------------------------------------------------

const RISE = {
    opacity: { at: [0, 0.25], to: [0, 1] },
    translateY: { at: [0, 1], to: [44, 0], ease: 'spring' },
};

scene('docks-title', {
    keyframes: {
        sub: { opacity: { at: [0.9, 1.5], to: [0, 1] }, translateY: { at: [0.9, 1.5], to: [12, 0], ease: 'power3.out' } },
    },
    computed: {
        letters() {
            const rows = split('Empty by 8:15');
            return rows.map((r) => ({ key: r.key, text: r.text, style: r.space ? {} : styleAt(this.local - 0.3 - stagger(r.i, rows.length, { each: 0.035 }), RISE) }));
        },
    },
});

// ---- The chart -----------------------------------------------------------------

// Fernhill's count from 6:30 to 9:00 as a step line, 344 by 150 px, drawn up
// to the clock. The second chart, on the clip with data-prop-van, draws the
// first morning whole and the van's morning up to the clock.
const CHART = { w: 344, top: 10, bottom: 140, from: 390, to: 540, most: 28 };
const cx = (m) => (m - CHART.from) / (CHART.to - CHART.from) * CHART.w;
const cy = (n) => CHART.bottom - n / CHART.most * (CHART.bottom - CHART.top);
function stepLine(morning, until) {
    const end = Math.min(until, CHART.to);
    if (end <= CHART.from) return '';
    const i = DOCK.fernhill.index;
    let d = 'M 0 ' + fx(cy(bikesAt(morning, i, CHART.from)));
    for (const [at, value] of morning.changes[i]) {
        if (at > end) break;
        if (at > CHART.from) d += ' H ' + fx(cx(at)) + ' V ' + fx(cy(value));
    }
    return d + ' H ' + fx(cx(end));
}

scene('docks-chart', {
    props: { van: { type: Boolean, default: false } },
    computed: {
        clock() { return interpolate(this.time, CLOCK); },
        subtitle() {
            const stop = VAN_MORNING.stops[1];
            return this.props.van ? 'The same morning, with ' + stop.delivered + ' bikes brought up at ' + clockText(stop.arrives) : '6:30 to 9:00 on a typical weekday';
        },
        grid() { return [0, 10, 20].map((n) => 'M 0 ' + fx(cy(n)) + ' H ' + CHART.w).join(' '); },
        before() { return stepLine(MORNING, this.props.van ? CHART.to : this.clock); },
        after() { return this.props.van ? stepLine(VAN_MORNING, this.clock) : ''; },
        now() { const x = fx(cx(Math.min(CHART.to, this.clock))); return 'M ' + x + ' ' + CHART.top + ' V ' + CHART.bottom; },
        vanKey() { return { opacity: this.props.van ? '1' : '0.35' }; },
    },
});

// ---- Why -----------------------------------------------------------------------

scene('docks-why', {
    keyframes: {
        counted: { at: [0.3, 1.5], to: [0, 1], ease: 'power2.out' },
    },
    computed: {
        down() { return format(Math.round(DOWN * this.counted)); },
        up() { return format(Math.round(UP * this.counted)); },
        downBar() { return { width: fx(100 * this.counted) + '%', background: '#1f6f8b' }; },
        upBar() { return { width: fx(100 * UP / DOWN * this.counted) + '%', background: '#e2583e' }; },
    },
});

// ---- The end -------------------------------------------------------------------

scene('docks-end', {
    keyframes: {
        rise: { opacity: { at: [0.2, 0.7], to: [0, 1] }, translateY: { at: [0.2, 1.2], to: [24, 0], ease: 'spring' } },
        riseLate: { opacity: { at: [0.7, 1.2], to: [0, 1] }, translateY: { at: [0.7, 1.4], to: [12, 0], ease: 'power3.out' } },
    },
    computed: {
        // The riders who found their dock empty, on each simulated morning.
        line() { return MORNING.turnedAway.length + ' riders found an empty dock. With the van, ' + VAN_MORNING.turnedAway.length + '.'; },
    },
});