rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1/* bunting.js: the landing page's bunting, hung on a canvas over the
2 still SVG in the markup. A Verlet rope in swags of six pennants,
3 taut between tacks, moved by a breeze, by the air the page moves
4 through when it scrolls, by a jolt when the page is shaken, by
5 puffs from the pointer, and by a hand brushing the cloth; shaded by
6 tilt; one animation frame loop that sleeps when the row is off
7 screen, the tab is hidden, or nothing moves.
8
9 Each pennant is a triangle of cloth: a triangular lattice of points
10 narrowing to one at the tip. Triangles are rigid in their plane, so
11 the cloth does not stretch, but fold freely out of it; only weak
12 bending constraints hold the drape. It hangs below the rope by two
13 ties at its top corners, each a rope segment that swings; the top
14 edge between them is a sewn tape, stiffer than the cloth. The tip
15 has a quicker flutter of its own. The textures carry a plain weave.
16
17 The canvas runs only while (prefers-reduced-motion: reduce) is
18 false: when it turns on the canvas goes and the SVG shows; when it
19 turns off again the canvas comes back. The pennants' colours are the
20 SVG's own, read from its markup, and the rope's is a Fiesta token,
21 so the textures are painted again when the scheme changes. */
22(function () {
23 "use strict";
24 var row = document.getElementById("bunting"), svg = row && row.querySelector("svg");
25 if (!row || !svg || !window.matchMedia) return;
26 var reduce = matchMedia("(prefers-reduced-motion: reduce)");
27 var canvas = document.createElement("canvas");
28 canvas.setAttribute("aria-hidden", "true");
29 var ctx = canvas.getContext("2d");
30 if (!ctx) return;
31
32 // World units are the SVG's viewBox units, one unit a pixel at scale
33 // 1. Pennants are PITCH apart and FW wide at the top, FH to the tip,
34 // with NY rows of points; PER of them hang between every two tacks.
35 // H is the viewBox height, TS the texture pixels per unit.
36 var NY = 7, FW = 36, FH = 44, TOPY = 4, TIE = 7, PITCH = 48, PER = 6, END = 34, SPAN = PITCH * PER + 2 * END, ROW = SPAN * 8, H = 96, TS = 4;
37 var DX = FW / (NY - 1), DY = FH / (NY - 1), DD = Math.hypot(DX / 2, DY);
38 // Cloth is a little heavier than tissue paper and bends far more easily.
39 var G = 650, DAMP = 0.955, MASS = 1.15, ITER = 5, DT = 1 / 60, VMAX = 6, ROPE_INV = 0.25, BEND = 0.1, HEM = 0.7, SLACK = 1.005, ROPE_PASSES = 6;
40 var R = 58, KC_X = 11, KC_Y = 5, KC_Z = 5.5, DRAG = 0.12, CONTACT_PAD = 12, SP_FULL = 2400, touch = null;
41 var D0 = 200, CUT = 600, KW = 1.15, TAU = 0.07, RIPPLE = 0.0007, PUFF_MS = 160, MAX_PUFFS = 6, docTop = 0, docLeft = 0;
42 var IDLE = 8000, CALM = 1500;
43 // Scrolling, with the page as the frame the bunting hangs in. The
44 // air: AIR_TAU is how long, in seconds, the air the page moved
45 // through keeps moving against it, short enough that a flick has
46 // settled in a few seconds and long enough to outlast a scroll back
47 // up to the row. AIR_MAX and SCROLL_MAX (screen pixels a second) and
48 // LEAP (pixels an event) bound what one flick or one jump can count
49 // for. AIR_REST (pixels a second) is the air too weak to feel, which
50 // lets the loop sleep. The cloth feels the air through AIR_SAT (world
51 // units a second, where its push saturates), AIR_Y (its push along
52 // the page, per unit a second) and AIR_Z (its billow and flutter, as
53 // the breeze gives, full at about AIR_SOFT). Rising air billows the
54 // cloth and falling air AIR_DOWN as much: a pennant hanging into the
55 // air is unstable and lifts, one hanging with it just trails.
56 //
57 // The shake: FRAME_W is the stiffness (per second) of a critically
58 // damped follower of scrollY whose acceleration jolts every free
59 // point, scaled by JOLT and bounded by JOLT_MAX (world units a
60 // second squared, under G and a quarter). A jolt under JOLT_DEAD is
61 // taken up by the cloth's own weight; beyond it the cloth goes slack
62 // and buckles out of the page (JOLT_Z) and along the row (JOLT_X),
63 // and the jolts pile up into jig, a flutter of JIG_FLUT that dies
64 // away over JIG_TAU seconds.
65 var AIR_TAU = 0.8, AIR_MAX = 2500, SCROLL_MAX = 4000, LEAP = 400, AIR_SAT = 900, AIR_Y = 0.65, AIR_Z = 1.6, AIR_SOFT = 300, AIR_DOWN = 0.25, AIR_REST = 20;
66 var FRAME_W = 24, JOLT = 0.08, JOLT_MAX = 800, JOLT_DEAD = 200, JOLT_Z = 1.5, JOLT_X = 0.6, JIG_TAU = 0.6, JIG_GAIN = 8, JIG_MAX = 1.5, JIG_FLUT = 700;
67 var air = 0, airT = performance.now(), frameY = 0, frameV = 0, jig = 0, scrollV = 0, scrollT = -1e9;
68 var lastInput = performance.now(), breeze = 1, motion = 1;
69 // Two hooks for the tests, read once, before the first frame.
70 // siteBuntingWindless: the breeze never blows, so the cloth hangs at
71 // rest until something else moves it and a drive sees that alone.
72 // siteBuntingProbe: replaced by a function that reports how far the
73 // cloth has strayed since the page opened, for holding a violent
74 // shake to the row; its hung() is the pose the last rebuild hung
75 // before its first frame, for holding that to the rest pose.
76 var windless = !!window.siteBuntingWindless, probe = window.siteBuntingProbe ? { minY: Infinity, maxY: -Infinity, minX: Infinity, maxX: -Infinity, maxZ: 0, stretch: 0, finite: true, hung: [], builds: 0 } : null;
77 if (windless) breeze = 0;
78 var colours = Array.prototype.map.call(svg.querySelectorAll(".site-flag"), function (g) { return +g.getAttribute("data-c"); });
79 var ncol = 6, tex = [], stringColour = "currentColor";
80 var X, Y, Z, PX, PY, PZ, INV, DEP, COL, U, V, cons, flags = [], ropeN = 0, scale = 1, offset = 0, dpr = 1;
81 var pending = null, puffs = [], lastPuff = 0, PAX = 0, PAY = 0, PAZ = 0, t0 = performance.now();
82
83 // One texture per colour: the pennant painted from SVG (triangle,
84 // hem tape, a stitch line), then a plain weave laid over it: fine
85 // warp and weft threads at low opacity, with a faint grain so no two
86 // inches match. The colours are the SVG's own pennants'.
87 function textures(done) {
88 var span = svg.querySelector(".site-span");
89 stringColour = (span && getComputedStyle(span).stroke) || stringColour;
90 var out = [], left = ncol, W = (FW + 4) * TS, Hh = (FH + 4) * TS;
91 for (var k = 0; k < ncol; k++) (function (k) {
92 var cloth = svg.querySelector("#site-p" + k + " polygon");
93 var fill = cloth ? getComputedStyle(cloth).fill : "currentColor";
94 var src = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-20 -2 40 48" width="' + W + '" height="' + Hh + '">' +
95 '<polygon points="-18,0 18,0 0,' + FH + '" fill="' + fill + '"/>' +
96 '<polygon points="-18,0 18,0 16.7,3.2 -16.7,3.2" fill="#000" opacity=".16"/>' +
97 '<path d="M-16.9 3.6H16.9" stroke="#fff" stroke-width=".35" stroke-dasharray=".9 .7" opacity=".45"/></svg>';
98 var img = new Image();
99 img.onload = function () {
100 var c = document.createElement("canvas");
101 c.width = W; c.height = Hh;
102 var g = c.getContext("2d");
103 g.drawImage(img, 0, 0);
104 weave(g, W, Hh, k);
105 out[k] = c;
106 if (--left === 0) { tex = out; done(); }
107 };
108 img.src = "data:image/svg+xml," + encodeURIComponent(src);
109 })(k);
110 }
111 function weave(g, W, Hh, k) {
112 g.globalCompositeOperation = "source-atop";
113 var P = 3, x, y;
114 // Weft: one thread a row, lit on top and shaded under.
115 for (y = 0; y < Hh; y += P) {
116 g.fillStyle = "rgba(255,255,255,.055)"; g.fillRect(0, y, W, 1);
117 g.fillStyle = "rgba(0,0,0,.045)"; g.fillRect(0, y + 1, W, 1);
118 }
119 // Warp: the same across, offset by half a thread every other
120 // column so the crossings read as over-and-under rather than a grid.
121 for (x = 0; x < W; x += P) {
122 var o = ((x / P) & 1) ? 1 : 0;
123 g.fillStyle = "rgba(255,255,255,.045)"; g.fillRect(x, o, 1, Hh);
124 g.fillStyle = "rgba(0,0,0,.04)"; g.fillRect(x + 1, o, 1, Hh);
125 }
126 // Grain: a few soft slubs, seeded so each colour's cloth is its own.
127 var seed = 7919 + k * 104729;
128 function rnd() { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }
129 for (var i = 0; i < 90; i++) {
130 var sx = rnd() * W, sy = rnd() * Hh, sw = 2 + rnd() * 10, sh = 1 + rnd() * 2;
131 g.fillStyle = rnd() < 0.5 ? "rgba(255,255,255,.05)" : "rgba(0,0,0,.04)";
132 g.fillRect(sx, sy, sw, sh);
133 }
134 g.globalCompositeOperation = "source-over";
135 }
136
137 function layout() {
138 var r = svg.getBoundingClientRect();
139 docTop = r.top + scrollY; docLeft = r.left;
140 scale = Math.max(r.width / ROW, r.height / H);
141 offset = (ROW * scale - r.width) / 2;
142 dpr = Math.min(window.devicePixelRatio || 1, 2);
143 var cr = canvas.getBoundingClientRect();
144 canvas.width = Math.round(cr.width * dpr);
145 canvas.height = Math.round(cr.height * dpr);
146 // Simulate only what is on screen, out to the nearest tack each side.
147 var w0 = offset / scale - 40, w1 = (offset + r.width) / scale + 40;
148 build(Math.max(0, Math.floor(w0 / SPAN) * SPAN), Math.min(ROW, Math.ceil(w1 / SPAN) * SPAN));
149 }
150
151 function build(a0, a1) {
152 // The rope, span by span: a tack, then for each pennant a node over
153 // each top corner and one between them, a node in each gap, and
154 // the closing tack. ties[k] = [left rope node, right rope node].
155 var rx = [], ties = {}, k, i, j, p;
156 for (var s = a0 / SPAN; s < a1 / SPAN; s++) {
157 rx.push(s * SPAN, s * SPAN + END / 2);
158 for (p = 0; p < PER; p++) {
159 k = s * PER + p;
160 var cx = s * SPAN + END + PITCH * p + PITCH / 2;
161 ties[k] = [rx.length, rx.length + 2];
162 rx.push(cx - FW / 2, cx, cx + FW / 2);
163 rx.push(p < PER - 1 ? cx + PITCH / 2 : s * SPAN + SPAN - END / 2);
164 }
165 }
166 rx.push(a1);
167 ropeN = rx.length;
168 var list = Object.keys(ties).map(Number);
169 var per = NY * (NY + 1) / 2;
170 var n = ropeN + list.length * per;
171 X = new Float32Array(n); Y = new Float32Array(n); Z = new Float32Array(n);
172 PX = new Float32Array(n); PY = new Float32Array(n); PZ = new Float32Array(n); INV = new Float32Array(n);
173 DEP = new Float32Array(n); COL = new Float32Array(n); U = new Float32Array(n); V = new Float32Array(n);
174 var c = [];
175 for (i = 0; i < ropeN; i++) {
176 X[i] = PX[i] = rx[i]; Y[i] = PY[i] = TOPY; INV[i] = rx[i] % SPAN === 0 ? 0 : ROPE_INV;
177 if (i) c.push(i - 1, i, (rx[i] - rx[i - 1]) * SLACK, 1);
178 }
179 var idx = ropeN;
180 flags = list.map(function (k) {
181 var cx = END + (k % PER) * PITCH + PITCH / 2 + Math.floor(k / PER) * SPAN, ids = [], rows = [], tris = [];
182 // Points: row j has NY - j of them, DX apart, centred.
183 for (j = 0; j < NY; j++) {
184 var nj = NY - j, w = FW - DX * j, r = [];
185 for (i = 0; i < nj; i++) {
186 var lx = -w / 2 + DX * i, ly = DY * j;
187 X[idx] = PX[idx] = cx + lx; Y[idx] = PY[idx] = TOPY + TIE + ly; INV[idx] = 1;
188 DEP[idx] = j / (NY - 1); COL[idx] = i;
189 U[idx] = (lx + 20) * TS; V[idx] = (ly + 2) * TS;
190 r.push(idx); ids.push(idx++);
191 }
192 rows.push(r);
193 }
194 // Ties: the top corners to the rope. The sewn tape along the top
195 // is stiffer than the cloth below, so the top edge stays nearly
196 // straight and the pennant does not buckle when its ties swing
197 // inward.
198 var t = ties[k];
199 c.push(t[0], rows[0][0], TIE, 1, t[1], rows[0][NY - 1], TIE, 1, rows[0][0], rows[0][NY - 1], FW, HEM);
200 for (j = 0; j < NY; j++) {
201 var rj = rows[j], nj2 = rj.length;
202 for (i = 0; i < nj2; i++) {
203 var a = rj[i];
204 if (i < nj2 - 1) c.push(a, rj[i + 1], DX, 1);
205 if (j < NY - 1) {
206 // Each point hangs on the two below it; one triangle for
207 // each pair of neighbours and the point between them
208 // underneath.
209 var rb = rows[j + 1];
210 if (i > 0) c.push(a, rb[i - 1], DD, 1);
211 if (i < rb.length) c.push(a, rb[i], DD, 1);
212 if (i < nj2 - 1) tris.push(a, rj[i + 1], rb[i]);
213 if (i > 0 && i < nj2 - 1) tris.push(rb[i - 1], rb[i], a);
214 }
215 // Bending stiffness, and not much of it: cloth, not paper.
216 if (j < NY - 2 && i > 0 && i - 1 < rows[j + 2].length) c.push(a, rows[j + 2][i - 1], 2 * DY, BEND);
217 if (i < nj2 - 2) c.push(a, rj[i + 2], 2 * DX, j ? BEND : HEM);
218 }
219 }
220 // side and lean are which way the pennant buckles when a jolt
221 // slackens it, out of the page and along the row: a hash of k, so
222 // neighbours differ and the same pennant always goes the same way.
223 return { cx: cx, ids: ids, tris: tris, tie: [t[0], rows[0][0], t[1], rows[0][NY - 1]], c: colours[k] % ncol, seed: k * 1.7,
224 side: Math.abs(Math.sin(k * 78.233) * 43758.5453) % 1 < 0.5 ? -1 : 1, lean: Math.abs(Math.sin(k * 12.9898) * 43758.5453) % 1 < 0.5 ? -1 : 1 };
225 });
226 cons = new Float32Array(c);
227 // Hang the row before the first frame, so the page never opens on
228 // a drop: 120 calm steps, gravity alone, the same hang the still
229 // SVG in the markup is drawn from (internal/www computes it with
230 // the same constants and passes), so the first frame is the still
231 // picture, and the wind takes it from there.
232 for (i = 0; i < 120; i++) step(i * DT, true);
233 if (probe) { probe.hung = [].concat(Array.from(X), Array.from(Y), Array.from(Z)); probe.builds++; }
234 }
235
236 // airAt is the air the page moved through, as it is at now: screen
237 // pixels a second, down positive, relative to the page. It decays in
238 // real time whether or not the loop is running, so a row that comes
239 // back on screen feels what is left of it, never a replay.
240 function airAt(now) {
241 return air * Math.exp(-(now - airT) / 1000 / AIR_TAU);
242 }
243
244 // step advances the cloth one DT. calm is the hang before the first
245 // frame: no breeze, no air, no jolt, no flutter left from a shake, no
246 // puff, no hand on the cloth and no sway on the rope, so it is the
247 // same hang every time, a rebuild after a shake included, and the
248 // still SVG drawn from it is the rest pose.
249 function step(t, calm) {
250 var dt2 = DT * DT, now = performance.now(), i, f, q;
251 var tn = now / 1000, airY = 0, airB = 0, jy = 0, snap = 0;
252 if (!calm) {
253 if (pending && (!pending.hold || now - lastPuff >= PUFF_MS)) {
254 pending.t = tn; lastPuff = now; puffs.push(pending); pending = null;
255 if (puffs.length > MAX_PUFFS) puffs.shift();
256 }
257 while (puffs.length && tn - puffs[0].t > 1.5) puffs.shift();
258 breeze = windless ? 0 : breeze + ((now - lastInput < IDLE ? 1 : 0) - breeze) * 0.02;
259 // The current: the air moving against the page, saturating so a
260 // flick lifts the cloth without throwing it over the rope (AIR_Y
261 // times AIR_SAT stays under G).
262 var aw = airAt(now), u = Math.sign(aw) * Math.max(0, Math.abs(aw) - AIR_REST) / scale, us = AIR_SAT * Math.tanh(u / AIR_SAT);
263 airY = AIR_Y * us; airB = AIR_Z * Math.tanh((us < 0 ? -us : AIR_DOWN * us) / AIR_SOFT);
264 // The shake: the frame follows the page on a critically damped
265 // spring, so a wheel's jumps and a trackpad's stream give the
266 // same smooth motion, and its acceleration is an inertial force
267 // on every free point, the opposite way to the page's. scrollY
268 // rising moves the page up, so the force is along the follower's
269 // own acceleration.
270 var fa = FRAME_W * FRAME_W * (scrollY - frameY) - 2 * FRAME_W * frameV;
271 frameV += fa * DT; frameY += frameV * DT;
272 jy = Math.max(-JOLT_MAX, Math.min(JOLT_MAX, JOLT * fa / scale));
273 // snap is what the cloth's weight cannot take up: jitter in the
274 // scroll events and the start of a slow scroll stay under it. jig
275 // is the flutter the snaps leave: each adds to it and it dies
276 // away, so repeated reversals build a dance.
277 snap = Math.max(0, Math.abs(jy) - JOLT_DEAD);
278 jig = Math.min(JIG_MAX, jig * Math.exp(-DT / JIG_TAU) + JIG_GAIN * DT * snap / JOLT_MAX);
279 }
280 var p = !calm && touch && now - touch.t < 90 ? touch : null;
281 for (f = 0; f < flags.length; f++) {
282 var F = flags[f], cx = F.cx;
283 // The breeze: two slow waves travelling different ways along the
284 // row, so neighbours move together but never in lockstep.
285 var b = calm ? 0 : (0.5 + 0.5 * Math.sin(t * 0.37 + cx * 0.0021) * Math.sin(t * 0.71 - cx * 0.0037 + 1.3)) * breeze + airB;
286 var wz = 200 * b, wx = 50 * b * Math.sin(t * 0.23 + cx * 0.001);
287 if (calm) PAX = PAY = PAZ = 0; else puffAt(cx, tn);
288 for (q = 0; q < F.ids.length; q++) {
289 var id = F.ids[q], depth = DEP[id], dep = 0.35 + 0.65 * depth, d2 = depth * depth;
290 // The breeze ripples down the cloth; the tip, free on three
291 // sides, adds a quicker flutter of its own.
292 var flut = Math.sin(t * 7.3 + COL[id] * 0.9 + depth * 3.6 + F.seed) * d2
293 + 0.7 * Math.sin(t * 11.4 + COL[id] * 0.5 + F.seed * 2.3) * d2 * d2;
294 integrate(id, (wx * (0.4 + 0.6 * depth) + PAX * dep) / MASS + JOLT_X * snap * F.lean * d2, (PAY + airY) * dep / MASS + jy,
295 (wz * (0.3 + 0.7 * depth) + (130 * b + (calm ? 0 : JIG_FLUT * jig)) * flut + PAZ * dep) / MASS + JOLT_Z * snap * F.side * (0.2 + 0.8 * d2), dt2);
296 if (p) contact(id, p);
297 }
298 }
299 // The rope feels the current more weakly than the cloth, and the
300 // jolt in full: it is inertial.
301 for (i = 0; i < ropeN; i++) if (INV[i]) integrate(i, 0, 0.4 * airY / MASS + jy, calm ? 0 : 30 * Math.sin(t * 0.5 + i * 0.05) / MASS, dt2);
302 for (var it = 0; it < ITER; it++) {
303 for (var k = 0; k < cons.length; k += 4) {
304 var a = cons[k], c = cons[k + 1], wa = INV[a], wc = INV[c], ws = wa + wc;
305 if (!ws) continue;
306 var ex = X[c] - X[a], ey = Y[c] - Y[a], ez = Z[c] - Z[a];
307 var d = Math.sqrt(ex * ex + ey * ey + ez * ez) || 1e-6;
308 var s = (d - cons[k + 2]) / d * cons[k + 3] / ws;
309 X[a] += ex * s * wa; Y[a] += ey * s * wa; Z[a] += ez * s * wa;
310 X[c] -= ex * s * wc; Y[c] -= ey * s * wc; Z[c] -= ez * s * wc;
311 }
312 // The rope is a taut tape, not elastic: extra passes over its own
313 // links (the first ropeN - 1 constraints) so the flags' weight
314 // cannot stretch it into a deep swag.
315 for (var r = 0; r < ROPE_PASSES; r++) for (k = 0; k < (ropeN - 1) * 4; k += 4) {
316 a = cons[k]; c = cons[k + 1]; wa = INV[a]; wc = INV[c]; ws = wa + wc;
317 if (!ws) continue;
318 ex = X[c] - X[a]; ey = Y[c] - Y[a]; ez = Z[c] - Z[a];
319 d = Math.sqrt(ex * ex + ey * ey + ez * ez) || 1e-6;
320 s = (d - cons[k + 2]) / d / ws;
321 X[a] += ex * s * wa; Y[a] += ey * s * wa; Z[a] += ez * s * wa;
322 X[c] -= ex * s * wc; Y[c] -= ey * s * wc; Z[c] -= ez * s * wc;
323 }
324 }
325 }
326
327 // How much of a puff reaches a point: soft falloff with distance (in
328 // screen pixels), eased to zero at the cut so nothing pops.
329 function reach(x, y, p) {
330 var d = Math.hypot(x - p.x, y - p.y) * scale;
331 if (d >= CUT) return 0;
332 var u = Math.max(0, (d - 0.75 * CUT) / (0.25 * CUT)), e = 1 - u * u * (3 - 2 * u);
333 return e / (1 + (d / D0) * (d / D0));
334 }
335 // The pointer's air on one pennant, summed over recent puffs into
336 // PAX/PAY/PAZ; it arrives later the further along the row the pennant
337 // is, so a ripple runs down the string.
338 function puffAt(cx, tn) {
339 PAX = PAY = PAZ = 0;
340 var sum = 0, cy = TOPY + TIE + FH / 2;
341 for (var i = 0; i < puffs.length; i++) {
342 var P = puffs[i], w = reach(cx, cy, P);
343 if (!w) continue;
344 var age = tn - P.t - Math.abs(cx - P.x) * scale * RIPPLE;
345 if (age < 0) continue;
346 var e = w * Math.exp(-age / TAU);
347 if (e < 0.005) continue;
348 var ex = cx - P.x, ey = cy - P.y, dl = Math.hypot(ex, ey) || 1, m = P.sp * KW * e;
349 PAX += ex / dl * 0.6 * m + P.vx * 0.5 * KW * e;
350 PAY += ey / dl * 0.6 * m + P.vy * 0.25 * KW * e;
351 PAZ += 0.25 * m * P.side;
352 sum += e;
353 }
354 // A pointer that never stops makes a riffle, never a steady blow.
355 if (sum > 1) { PAX /= sum; PAY /= sum; PAZ /= sum; }
356 }
357 // Contact: a hand brushing the cloth. A push on the point's velocity
358 // plus a short drag for points right under the pointer.
359 function contact(id, p) {
360 var ex = X[id] - p.x, ey = Y[id] - p.y, d2 = ex * ex + ey * ey;
361 if (d2 >= R * R) return;
362 var f = 1 - Math.sqrt(d2) / R, dt2 = DT * DT; f *= f * Math.min(1, p.sp / SP_FULL);
363 X[id] += (p.vx * KC_X * f) * dt2; Y[id] += (p.vy * KC_Y * f) * dt2;
364 Z[id] += (p.sp * KC_Z * f * p.side) * dt2;
365 if (f > 0.4) { X[id] += p.vx * DT * DRAG * f; Y[id] += p.vy * DT * DRAG * f * 0.5; }
366 }
367 function integrate(id, ax, ay, az, dt2) {
368 var vx = (X[id] - PX[id]) * DAMP, vy = (Y[id] - PY[id]) * DAMP, vz = (Z[id] - PZ[id]) * DAMP;
369 var v2 = vx * vx + vy * vy + vz * vz;
370 if (v2 > VMAX * VMAX) { var k = VMAX / Math.sqrt(v2); vx *= k; vy *= k; vz *= k; }
371 if (v2 > motion) motion = v2;
372 PX[id] = X[id]; PY[id] = Y[id]; PZ[id] = Z[id];
373 X[id] += vx + ax * dt2; Y[id] += vy + (ay + G) * dt2; Z[id] += vz + az * dt2;
374 }
375
376 // Light from the upper left, a little in front.
377 var LX = -0.35, LY = -0.55, LZ = 0.76, grow = 1.1;
378 function draw() {
379 ctx.setTransform(1, 0, 0, 1, 0, 0);
380 ctx.clearRect(0, 0, canvas.width, canvas.height);
381 var m = dpr * scale;
382 grow = 1 + 1 / (4 * m);
383 ctx.setTransform(m, 0, 0, m, -offset * dpr, 0);
384 ctx.strokeStyle = stringColour; ctx.lineCap = "round";
385 ctx.beginPath(); ctx.moveTo(X[0], Y[0]);
386 for (var i = 1; i < ropeN; i++) ctx.lineTo(X[i], Y[i]);
387 ctx.lineWidth = 1.3; ctx.stroke();
388 ctx.beginPath();
389 for (var f = 0; f < flags.length; f++) {
390 var t = flags[f].tie;
391 ctx.moveTo(X[t[0]], Y[t[0]]); ctx.lineTo(X[t[1]], Y[t[1]]);
392 ctx.moveTo(X[t[2]], Y[t[2]]); ctx.lineTo(X[t[3]], Y[t[3]]);
393 }
394 ctx.lineWidth = 1; ctx.stroke();
395 for (f = 0; f < flags.length; f++) drawFlag(flags[f]);
396 }
397 function drawFlag(F) {
398 var tr = F.tris, img = tex[F.c];
399 for (var k = 0; k < tr.length; k += 3) {
400 var a = tr[k], b = tr[k + 1], c = tr[k + 2];
401 // Shade by how far the triangle has turned from facing the light
402 // as it does at rest.
403 var e1x = X[b] - X[a], e1y = Y[b] - Y[a], e1z = Z[b] - Z[a];
404 var e2x = X[c] - X[a], e2y = Y[c] - Y[a], e2z = Z[c] - Z[a];
405 var nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
406 var nl = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
407 tri(img, a, b, c, (Math.abs(nx * LX + ny * LY + nz * LZ) / nl) - LZ);
408 }
409 }
410 // One triangle of cloth: clipped a device pixel outside itself (or
411 // the antialiased edges leave seams), textured by the affine map that
412 // takes its three texture corners to its three points.
413 function tri(img, p0, p1, p2, shade) {
414 var u0 = U[p0], v0 = V[p0], u1 = U[p1], v1 = V[p1], u2 = U[p2], v2 = V[p2];
415 var x0 = X[p0], y0 = Y[p0], x1 = X[p1], y1 = Y[p1], x2 = X[p2], y2 = Y[p2];
416 var du1 = u1 - u0, dv1 = v1 - v0, du2 = u2 - u0, dv2 = v2 - v0, det = du1 * dv2 - du2 * dv1;
417 var A = ((x1 - x0) * dv2 - (x2 - x0) * dv1) / det, B = ((y1 - y0) * dv2 - (y2 - y0) * dv1) / det;
418 var C = ((x2 - x0) * du1 - (x1 - x0) * du2) / det, D = ((y2 - y0) * du1 - (y1 - y0) * du2) / det;
419 var gx = (x0 + x1 + x2) / 3, gy = (y0 + y1 + y2) / 3, g = grow;
420 ctx.save();
421 ctx.beginPath();
422 ctx.moveTo(gx + (x0 - gx) * g, gy + (y0 - gy) * g);
423 ctx.lineTo(gx + (x1 - gx) * g, gy + (y1 - gy) * g);
424 ctx.lineTo(gx + (x2 - gx) * g, gy + (y2 - gy) * g);
425 ctx.clip();
426 ctx.transform(A, B, C, D, x0 - A * u0 - C * v0, y0 - B * u0 - D * v0);
427 var sx = Math.max(0, Math.min(u0, u1, u2) - 3), sy = Math.max(0, Math.min(v0, v1, v2) - 3);
428 var sw = Math.min(img.width - sx, Math.max(u0, u1, u2) + 3 - sx), sh = Math.min(img.height - sy, Math.max(v0, v1, v2) + 3 - sy);
429 ctx.drawImage(img, sx, sy, sw, sh, sx, sy, sw, sh);
430 if (shade < -0.02 || shade > 0.02) {
431 ctx.globalCompositeOperation = "source-atop";
432 ctx.fillStyle = shade < 0 ? "rgba(0,0,0," + Math.min(0.42, -shade * 0.9).toFixed(3) + ")" : "rgba(255,255,255," + Math.min(0.28, shade * 0.8).toFixed(3) + ")";
433 ctx.fillRect(sx, sy, sw, sh);
434 }
435 ctx.restore();
436 }
437
438 // live is whether the canvas is on the page: false under reduced
439 // motion. running is whether the frame loop is going.
440 var live = false, running = false, onScreen = true, acc = 0, lastT = 0, drawn = false, raf = 0;
441 function frame(now) {
442 if (!running || !live) return;
443 if (!onScreen || document.hidden) { running = false; return; }
444 acc += Math.min((now - lastT) / 1000, 0.1); lastT = now;
445 var t = (now - t0) / 1000, n = 0;
446 motion = 0;
447 while (acc >= DT && n < 3) { step(t); acc -= DT; n++; }
448 if (probe && n) record();
449 // Still cloth, no breeze, no current and the page at rest: draw
450 // this last frame and sleep.
451 var still = n > 0 && !pending && !(touch && now - touch.t < 90) && breeze < 0.01 && Math.abs(airAt(now)) < AIR_REST && jig < 0.01 &&
452 Math.abs(frameV) < 1 && Math.abs(scrollY - frameY) < 0.5 && motion < 1e-4 && !(puffs.length && now / 1000 - puffs[puffs.length - 1].t < 0.6);
453 if (n && (still || now - lastInput < CALM || (drawn = !drawn))) draw();
454 if (still) { running = false; return; }
455 raf = requestAnimationFrame(frame);
456 }
457 // record widens the probe's extremes to this frame's cloth: every
458 // point's place, and the worst stretch of any link over its length.
459 function record() {
460 for (var i = 0; i < X.length; i++) {
461 if (!isFinite(X[i] + Y[i] + Z[i])) probe.finite = false;
462 probe.minY = Math.min(probe.minY, Y[i]); probe.maxY = Math.max(probe.maxY, Y[i]);
463 probe.minX = Math.min(probe.minX, X[i]); probe.maxX = Math.max(probe.maxX, X[i]);
464 probe.maxZ = Math.max(probe.maxZ, Math.abs(Z[i]));
465 }
466 for (var k = 0; k < cons.length; k += 4) {
467 var a = cons[k], c = cons[k + 1];
468 probe.stretch = Math.max(probe.stretch, Math.hypot(X[c] - X[a], Y[c] - Y[a], Z[c] - Z[a]) / cons[k + 2]);
469 }
470 }
471 if (probe) window.siteBuntingProbe = function () {
472 return JSON.stringify({ MinY: probe.minY, MaxY: probe.maxY, MinX: probe.minX, MaxX: probe.maxX, MaxZ: probe.maxZ, Stretch: probe.stretch, Finite: probe.finite,
473 Height: canvas.getBoundingClientRect().height / scale, From: X ? X[0] : 0, To: X ? X[ropeN - 1] : 0, Running: running, Builds: probe.builds });
474 };
475 if (probe) window.siteBuntingProbe.hung = function () { return JSON.stringify(probe.hung); };
476
477 // window.siteBuntingStill is a hook for the tests: set before this
478 // runs, the canvas is hung at rest and never moves, so a drive can
479 // hold the still SVG to the first frame.
480 function start() {
481 if (running || !live || !onScreen || document.hidden || !tex.length || window.siteBuntingStill) return;
482 running = true; lastT = performance.now(); acc = 0;
483 // The frame picks up the page where it is, moving as it was, so
484 // waking mid-scroll is not a jolt.
485 frameY = scrollY; frameV = lastT - scrollT < 100 ? scrollV : 0; jig = 0;
486 raf = requestAnimationFrame(frame);
487 }
488 // A hidden tab's queued frame may be suspended rather than run, which
489 // would leave running set, so start() on the way back would return
490 // early and the loop would pick up the follower and jig where they
491 // were, a stale shake. Cancelling it and clearing running on the way
492 // out makes the way back a fresh start().
493 function pause() {
494 cancelAnimationFrame(raf);
495 running = false;
496 }
497
498 var lx = null, ly = 0, lt = 0;
499 // Any pointer move on the page sends a puff; over the row it also
500 // touches the cloth. At most one puff leaves every PUFF_MS.
501 addEventListener("pointermove", function (e) {
502 if (!live) return;
503 var wx = (e.clientX - docLeft + offset) / scale, wy = (e.clientY + scrollY - docTop) / scale, now = performance.now();
504 if (lx !== null && now > lt) {
505 var dt = (now - lt) / 1000, vx = (wx - lx) / dt, vy = (wy - ly) / dt, sp = Math.hypot(vx, vy);
506 if (sp > 2500) { vx *= 2500 / sp; vy *= 2500 / sp; sp = 2500; }
507 if (!pending || sp > pending.sp) pending = { x: wx, y: wy, vx: vx, vy: vy, sp: sp, side: vx < 0 ? -1 : 1, t: now / 1000 };
508 if (now - lastPuff < PUFF_MS) pending.hold = true; else { pending.hold = false; lastPuff = now; }
509 lastInput = now;
510 start();
511 var cr = canvas.getBoundingClientRect();
512 if (e.clientY >= cr.top - CONTACT_PAD && e.clientY <= cr.top + H * scale + CONTACT_PAD) {
513 touch = { x: wx, y: wy, vx: vx, vy: vy, sp: sp, side: vx < 0 ? -1 : 1, t: now };
514 }
515 }
516 lx = wx; ly = wy; lt = now;
517 }, { passive: true });
518 // A scroll moves the air against the page: each event adds its
519 // distance over AIR_TAU, so a steady scroll's air comes to its speed
520 // and the sum does not depend on how many events carry it. A jump (a
521 // link to an anchor, the End key) is not a hand moving the page, so
522 // no more than LEAP, and no faster than SCROLL_MAX, counts.
523 var sy = scrollY, st = performance.now();
524 addEventListener("scroll", function () {
525 var now = performance.now(), dt = Math.max(now - st, 8), lim = Math.min(LEAP, SCROLL_MAX * dt / 1000);
526 var d = Math.max(-lim, Math.min(lim, scrollY - sy)), v = d / dt * 1000;
527 sy = scrollY; st = now;
528 air = Math.max(-AIR_MAX, Math.min(AIR_MAX, airAt(now) + d / AIR_TAU)); airT = now;
529 scrollV = now - scrollT < 100 ? (scrollV + v) / 2 : v; scrollT = now;
530 // Wake on the air as well as the speed: v is averaged since the
531 // last event, so one wheel turn after a long idle reads as slow
532 // while the air it moved is not.
533 if (Math.abs(v) > 50 || Math.abs(air) > AIR_REST) { lastInput = now; start(); }
534 }, { passive: true });
535
536 new IntersectionObserver(function (es) { onScreen = es[0].isIntersecting; if (onScreen) start(); }).observe(row);
537 document.addEventListener("visibilitychange", function () { if (document.hidden) pause(); else start(); });
538 addEventListener("pagehide", pause);
539 addEventListener("pageshow", start);
540 var rz;
541 addEventListener("resize", function () {
542 clearTimeout(rz);
543 rz = setTimeout(function () { if (live) { layout(); draw(); } }, 120);
544 });
545
546 // The rope's colour is a Fiesta token, which each theme and each
547 // scheme sets differently, so the textures are painted again when
548 // either changes: the scheme from the page's own menu (rst-scheme,
549 // which fiesta.js dispatches) or from the OS, and the theme when the
550 // stylesheet site.js swapped into #site-theme has loaded.
551 function repaint() {
552 if (live) textures(function () { if (live) draw(); });
553 }
554 document.addEventListener("rst-scheme", repaint);
555 matchMedia("(prefers-color-scheme: dark)").addEventListener("change", repaint);
556 var themeLink = document.getElementById("site-theme");
557 if (themeLink) themeLink.addEventListener("load", repaint);
558
559 // The canvas is hung only while motion is allowed. When the
560 // preference turns on mid-visit the canvas goes and the SVG shows;
561 // when it turns off again the canvas is hung afresh.
562 var hanging = false;
563 function hang() {
564 if (live || hanging || reduce.matches) return;
565 hanging = true;
566 textures(function () {
567 hanging = false;
568 if (live || reduce.matches) return;
569 live = true;
570 row.appendChild(canvas);
571 layout();
572 draw();
573 row.classList.add("site-bunting-live");
574 lastInput = performance.now();
575 breeze = windless ? 0 : 1;
576 start();
577 });
578 }
579 function takeDown() {
580 if (!live) return;
581 live = false;
582 running = false;
583 row.classList.remove("site-bunting-live");
584 canvas.remove();
585 }
586 reduce.addEventListener("change", function () {
587 if (reduce.matches) takeDown(); else hang();
588 });
589 hang();
590})();
591