Empty Docks
The code of the Empty Docks demo, 2 files.
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 ยท 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 + '.'; },
},
});