/* bunting.js: the landing page's bunting, hung on a canvas over the still SVG in the markup. A Verlet rope in swags of six pennants, taut between tacks, moved by a breeze, by the air the page moves through when it scrolls, by a jolt when the page is shaken, by puffs from the pointer, and by a hand brushing the cloth; shaded by tilt; one animation frame loop that sleeps when the row is off screen, the tab is hidden, or nothing moves. Each pennant is a triangle of cloth: a triangular lattice of points narrowing to one at the tip. Triangles are rigid in their plane, so the cloth does not stretch, but fold freely out of it; only weak bending constraints hold the drape. It hangs below the rope by two ties at its top corners, each a rope segment that swings; the top edge between them is a sewn tape, stiffer than the cloth. The tip has a quicker flutter of its own. The textures carry a plain weave. The canvas runs only while (prefers-reduced-motion: reduce) is false: when it turns on the canvas goes and the SVG shows; when it turns off again the canvas comes back. The pennants' colours are the SVG's own, read from its markup, and the rope's is a Fiesta token, so the textures are painted again when the scheme changes. */ (function () { "use strict"; var row = document.getElementById("bunting"), svg = row && row.querySelector("svg"); if (!row || !svg || !window.matchMedia) return; var reduce = matchMedia("(prefers-reduced-motion: reduce)"); var canvas = document.createElement("canvas"); canvas.setAttribute("aria-hidden", "true"); var ctx = canvas.getContext("2d"); if (!ctx) return; // World units are the SVG's viewBox units, one unit a pixel at scale // 1. Pennants are PITCH apart and FW wide at the top, FH to the tip, // with NY rows of points; PER of them hang between every two tacks. // H is the viewBox height, TS the texture pixels per unit. 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; var DX = FW / (NY - 1), DY = FH / (NY - 1), DD = Math.hypot(DX / 2, DY); // Cloth is a little heavier than tissue paper and bends far more easily. 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; var R = 58, KC_X = 11, KC_Y = 5, KC_Z = 5.5, DRAG = 0.12, CONTACT_PAD = 12, SP_FULL = 2400, touch = null; var D0 = 200, CUT = 600, KW = 1.15, TAU = 0.07, RIPPLE = 0.0007, PUFF_MS = 160, MAX_PUFFS = 6, docTop = 0, docLeft = 0; var IDLE = 8000, CALM = 1500; // Scrolling, with the page as the frame the bunting hangs in. The // air: AIR_TAU is how long, in seconds, the air the page moved // through keeps moving against it, short enough that a flick has // settled in a few seconds and long enough to outlast a scroll back // up to the row. AIR_MAX and SCROLL_MAX (screen pixels a second) and // LEAP (pixels an event) bound what one flick or one jump can count // for. AIR_REST (pixels a second) is the air too weak to feel, which // lets the loop sleep. The cloth feels the air through AIR_SAT (world // units a second, where its push saturates), AIR_Y (its push along // the page, per unit a second) and AIR_Z (its billow and flutter, as // the breeze gives, full at about AIR_SOFT). Rising air billows the // cloth and falling air AIR_DOWN as much: a pennant hanging into the // air is unstable and lifts, one hanging with it just trails. // // The shake: FRAME_W is the stiffness (per second) of a critically // damped follower of scrollY whose acceleration jolts every free // point, scaled by JOLT and bounded by JOLT_MAX (world units a // second squared, under G and a quarter). A jolt under JOLT_DEAD is // taken up by the cloth's own weight; beyond it the cloth goes slack // and buckles out of the page (JOLT_Z) and along the row (JOLT_X), // and the jolts pile up into jig, a flutter of JIG_FLUT that dies // away over JIG_TAU seconds. 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; 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; var air = 0, airT = performance.now(), frameY = 0, frameV = 0, jig = 0, scrollV = 0, scrollT = -1e9; var lastInput = performance.now(), breeze = 1, motion = 1; // Two hooks for the tests, read once, before the first frame. // siteBuntingWindless: the breeze never blows, so the cloth hangs at // rest until something else moves it and a drive sees that alone. // siteBuntingProbe: replaced by a function that reports how far the // cloth has strayed since the page opened, for holding a violent // shake to the row; its hung() is the pose the last rebuild hung // before its first frame, for holding that to the rest pose. 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; if (windless) breeze = 0; var colours = Array.prototype.map.call(svg.querySelectorAll(".site-flag"), function (g) { return +g.getAttribute("data-c"); }); var ncol = 6, tex = [], stringColour = "currentColor"; var X, Y, Z, PX, PY, PZ, INV, DEP, COL, U, V, cons, flags = [], ropeN = 0, scale = 1, offset = 0, dpr = 1; var pending = null, puffs = [], lastPuff = 0, PAX = 0, PAY = 0, PAZ = 0, t0 = performance.now(); // One texture per colour: the pennant painted from SVG (triangle, // hem tape, a stitch line), then a plain weave laid over it: fine // warp and weft threads at low opacity, with a faint grain so no two // inches match. The colours are the SVG's own pennants'. function textures(done) { var span = svg.querySelector(".site-span"); stringColour = (span && getComputedStyle(span).stroke) || stringColour; var out = [], left = ncol, W = (FW + 4) * TS, Hh = (FH + 4) * TS; for (var k = 0; k < ncol; k++) (function (k) { var cloth = svg.querySelector("#site-p" + k + " polygon"); var fill = cloth ? getComputedStyle(cloth).fill : "currentColor"; var src = '' + '' + '' + ''; var img = new Image(); img.onload = function () { var c = document.createElement("canvas"); c.width = W; c.height = Hh; var g = c.getContext("2d"); g.drawImage(img, 0, 0); weave(g, W, Hh, k); out[k] = c; if (--left === 0) { tex = out; done(); } }; img.src = "data:image/svg+xml," + encodeURIComponent(src); })(k); } function weave(g, W, Hh, k) { g.globalCompositeOperation = "source-atop"; var P = 3, x, y; // Weft: one thread a row, lit on top and shaded under. for (y = 0; y < Hh; y += P) { g.fillStyle = "rgba(255,255,255,.055)"; g.fillRect(0, y, W, 1); g.fillStyle = "rgba(0,0,0,.045)"; g.fillRect(0, y + 1, W, 1); } // Warp: the same across, offset by half a thread every other // column so the crossings read as over-and-under rather than a grid. for (x = 0; x < W; x += P) { var o = ((x / P) & 1) ? 1 : 0; g.fillStyle = "rgba(255,255,255,.045)"; g.fillRect(x, o, 1, Hh); g.fillStyle = "rgba(0,0,0,.04)"; g.fillRect(x + 1, o, 1, Hh); } // Grain: a few soft slubs, seeded so each colour's cloth is its own. var seed = 7919 + k * 104729; function rnd() { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; } for (var i = 0; i < 90; i++) { var sx = rnd() * W, sy = rnd() * Hh, sw = 2 + rnd() * 10, sh = 1 + rnd() * 2; g.fillStyle = rnd() < 0.5 ? "rgba(255,255,255,.05)" : "rgba(0,0,0,.04)"; g.fillRect(sx, sy, sw, sh); } g.globalCompositeOperation = "source-over"; } function layout() { var r = svg.getBoundingClientRect(); docTop = r.top + scrollY; docLeft = r.left; scale = Math.max(r.width / ROW, r.height / H); offset = (ROW * scale - r.width) / 2; dpr = Math.min(window.devicePixelRatio || 1, 2); var cr = canvas.getBoundingClientRect(); canvas.width = Math.round(cr.width * dpr); canvas.height = Math.round(cr.height * dpr); // Simulate only what is on screen, out to the nearest tack each side. var w0 = offset / scale - 40, w1 = (offset + r.width) / scale + 40; build(Math.max(0, Math.floor(w0 / SPAN) * SPAN), Math.min(ROW, Math.ceil(w1 / SPAN) * SPAN)); } function build(a0, a1) { // The rope, span by span: a tack, then for each pennant a node over // each top corner and one between them, a node in each gap, and // the closing tack. ties[k] = [left rope node, right rope node]. var rx = [], ties = {}, k, i, j, p; for (var s = a0 / SPAN; s < a1 / SPAN; s++) { rx.push(s * SPAN, s * SPAN + END / 2); for (p = 0; p < PER; p++) { k = s * PER + p; var cx = s * SPAN + END + PITCH * p + PITCH / 2; ties[k] = [rx.length, rx.length + 2]; rx.push(cx - FW / 2, cx, cx + FW / 2); rx.push(p < PER - 1 ? cx + PITCH / 2 : s * SPAN + SPAN - END / 2); } } rx.push(a1); ropeN = rx.length; var list = Object.keys(ties).map(Number); var per = NY * (NY + 1) / 2; var n = ropeN + list.length * per; X = new Float32Array(n); Y = new Float32Array(n); Z = new Float32Array(n); PX = new Float32Array(n); PY = new Float32Array(n); PZ = new Float32Array(n); INV = new Float32Array(n); DEP = new Float32Array(n); COL = new Float32Array(n); U = new Float32Array(n); V = new Float32Array(n); var c = []; for (i = 0; i < ropeN; i++) { X[i] = PX[i] = rx[i]; Y[i] = PY[i] = TOPY; INV[i] = rx[i] % SPAN === 0 ? 0 : ROPE_INV; if (i) c.push(i - 1, i, (rx[i] - rx[i - 1]) * SLACK, 1); } var idx = ropeN; flags = list.map(function (k) { var cx = END + (k % PER) * PITCH + PITCH / 2 + Math.floor(k / PER) * SPAN, ids = [], rows = [], tris = []; // Points: row j has NY - j of them, DX apart, centred. for (j = 0; j < NY; j++) { var nj = NY - j, w = FW - DX * j, r = []; for (i = 0; i < nj; i++) { var lx = -w / 2 + DX * i, ly = DY * j; X[idx] = PX[idx] = cx + lx; Y[idx] = PY[idx] = TOPY + TIE + ly; INV[idx] = 1; DEP[idx] = j / (NY - 1); COL[idx] = i; U[idx] = (lx + 20) * TS; V[idx] = (ly + 2) * TS; r.push(idx); ids.push(idx++); } rows.push(r); } // Ties: the top corners to the rope. The sewn tape along the top // is stiffer than the cloth below, so the top edge stays nearly // straight and the pennant does not buckle when its ties swing // inward. var t = ties[k]; 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); for (j = 0; j < NY; j++) { var rj = rows[j], nj2 = rj.length; for (i = 0; i < nj2; i++) { var a = rj[i]; if (i < nj2 - 1) c.push(a, rj[i + 1], DX, 1); if (j < NY - 1) { // Each point hangs on the two below it; one triangle for // each pair of neighbours and the point between them // underneath. var rb = rows[j + 1]; if (i > 0) c.push(a, rb[i - 1], DD, 1); if (i < rb.length) c.push(a, rb[i], DD, 1); if (i < nj2 - 1) tris.push(a, rj[i + 1], rb[i]); if (i > 0 && i < nj2 - 1) tris.push(rb[i - 1], rb[i], a); } // Bending stiffness, and not much of it: cloth, not paper. if (j < NY - 2 && i > 0 && i - 1 < rows[j + 2].length) c.push(a, rows[j + 2][i - 1], 2 * DY, BEND); if (i < nj2 - 2) c.push(a, rj[i + 2], 2 * DX, j ? BEND : HEM); } } // side and lean are which way the pennant buckles when a jolt // slackens it, out of the page and along the row: a hash of k, so // neighbours differ and the same pennant always goes the same way. 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, 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 }; }); cons = new Float32Array(c); // Hang the row before the first frame, so the page never opens on // a drop: 120 calm steps, gravity alone, the same hang the still // SVG in the markup is drawn from (internal/www computes it with // the same constants and passes), so the first frame is the still // picture, and the wind takes it from there. for (i = 0; i < 120; i++) step(i * DT, true); if (probe) { probe.hung = [].concat(Array.from(X), Array.from(Y), Array.from(Z)); probe.builds++; } } // airAt is the air the page moved through, as it is at now: screen // pixels a second, down positive, relative to the page. It decays in // real time whether or not the loop is running, so a row that comes // back on screen feels what is left of it, never a replay. function airAt(now) { return air * Math.exp(-(now - airT) / 1000 / AIR_TAU); } // step advances the cloth one DT. calm is the hang before the first // frame: no breeze, no air, no jolt, no flutter left from a shake, no // puff, no hand on the cloth and no sway on the rope, so it is the // same hang every time, a rebuild after a shake included, and the // still SVG drawn from it is the rest pose. function step(t, calm) { var dt2 = DT * DT, now = performance.now(), i, f, q; var tn = now / 1000, airY = 0, airB = 0, jy = 0, snap = 0; if (!calm) { if (pending && (!pending.hold || now - lastPuff >= PUFF_MS)) { pending.t = tn; lastPuff = now; puffs.push(pending); pending = null; if (puffs.length > MAX_PUFFS) puffs.shift(); } while (puffs.length && tn - puffs[0].t > 1.5) puffs.shift(); breeze = windless ? 0 : breeze + ((now - lastInput < IDLE ? 1 : 0) - breeze) * 0.02; // The current: the air moving against the page, saturating so a // flick lifts the cloth without throwing it over the rope (AIR_Y // times AIR_SAT stays under G). 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); airY = AIR_Y * us; airB = AIR_Z * Math.tanh((us < 0 ? -us : AIR_DOWN * us) / AIR_SOFT); // The shake: the frame follows the page on a critically damped // spring, so a wheel's jumps and a trackpad's stream give the // same smooth motion, and its acceleration is an inertial force // on every free point, the opposite way to the page's. scrollY // rising moves the page up, so the force is along the follower's // own acceleration. var fa = FRAME_W * FRAME_W * (scrollY - frameY) - 2 * FRAME_W * frameV; frameV += fa * DT; frameY += frameV * DT; jy = Math.max(-JOLT_MAX, Math.min(JOLT_MAX, JOLT * fa / scale)); // snap is what the cloth's weight cannot take up: jitter in the // scroll events and the start of a slow scroll stay under it. jig // is the flutter the snaps leave: each adds to it and it dies // away, so repeated reversals build a dance. snap = Math.max(0, Math.abs(jy) - JOLT_DEAD); jig = Math.min(JIG_MAX, jig * Math.exp(-DT / JIG_TAU) + JIG_GAIN * DT * snap / JOLT_MAX); } var p = !calm && touch && now - touch.t < 90 ? touch : null; for (f = 0; f < flags.length; f++) { var F = flags[f], cx = F.cx; // The breeze: two slow waves travelling different ways along the // row, so neighbours move together but never in lockstep. 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; var wz = 200 * b, wx = 50 * b * Math.sin(t * 0.23 + cx * 0.001); if (calm) PAX = PAY = PAZ = 0; else puffAt(cx, tn); for (q = 0; q < F.ids.length; q++) { var id = F.ids[q], depth = DEP[id], dep = 0.35 + 0.65 * depth, d2 = depth * depth; // The breeze ripples down the cloth; the tip, free on three // sides, adds a quicker flutter of its own. var flut = Math.sin(t * 7.3 + COL[id] * 0.9 + depth * 3.6 + F.seed) * d2 + 0.7 * Math.sin(t * 11.4 + COL[id] * 0.5 + F.seed * 2.3) * d2 * d2; integrate(id, (wx * (0.4 + 0.6 * depth) + PAX * dep) / MASS + JOLT_X * snap * F.lean * d2, (PAY + airY) * dep / MASS + jy, (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); if (p) contact(id, p); } } // The rope feels the current more weakly than the cloth, and the // jolt in full: it is inertial. 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); for (var it = 0; it < ITER; it++) { for (var k = 0; k < cons.length; k += 4) { var a = cons[k], c = cons[k + 1], wa = INV[a], wc = INV[c], ws = wa + wc; if (!ws) continue; var ex = X[c] - X[a], ey = Y[c] - Y[a], ez = Z[c] - Z[a]; var d = Math.sqrt(ex * ex + ey * ey + ez * ez) || 1e-6; var s = (d - cons[k + 2]) / d * cons[k + 3] / ws; X[a] += ex * s * wa; Y[a] += ey * s * wa; Z[a] += ez * s * wa; X[c] -= ex * s * wc; Y[c] -= ey * s * wc; Z[c] -= ez * s * wc; } // The rope is a taut tape, not elastic: extra passes over its own // links (the first ropeN - 1 constraints) so the flags' weight // cannot stretch it into a deep swag. for (var r = 0; r < ROPE_PASSES; r++) for (k = 0; k < (ropeN - 1) * 4; k += 4) { a = cons[k]; c = cons[k + 1]; wa = INV[a]; wc = INV[c]; ws = wa + wc; if (!ws) continue; ex = X[c] - X[a]; ey = Y[c] - Y[a]; ez = Z[c] - Z[a]; d = Math.sqrt(ex * ex + ey * ey + ez * ez) || 1e-6; s = (d - cons[k + 2]) / d / ws; X[a] += ex * s * wa; Y[a] += ey * s * wa; Z[a] += ez * s * wa; X[c] -= ex * s * wc; Y[c] -= ey * s * wc; Z[c] -= ez * s * wc; } } } // How much of a puff reaches a point: soft falloff with distance (in // screen pixels), eased to zero at the cut so nothing pops. function reach(x, y, p) { var d = Math.hypot(x - p.x, y - p.y) * scale; if (d >= CUT) return 0; var u = Math.max(0, (d - 0.75 * CUT) / (0.25 * CUT)), e = 1 - u * u * (3 - 2 * u); return e / (1 + (d / D0) * (d / D0)); } // The pointer's air on one pennant, summed over recent puffs into // PAX/PAY/PAZ; it arrives later the further along the row the pennant // is, so a ripple runs down the string. function puffAt(cx, tn) { PAX = PAY = PAZ = 0; var sum = 0, cy = TOPY + TIE + FH / 2; for (var i = 0; i < puffs.length; i++) { var P = puffs[i], w = reach(cx, cy, P); if (!w) continue; var age = tn - P.t - Math.abs(cx - P.x) * scale * RIPPLE; if (age < 0) continue; var e = w * Math.exp(-age / TAU); if (e < 0.005) continue; var ex = cx - P.x, ey = cy - P.y, dl = Math.hypot(ex, ey) || 1, m = P.sp * KW * e; PAX += ex / dl * 0.6 * m + P.vx * 0.5 * KW * e; PAY += ey / dl * 0.6 * m + P.vy * 0.25 * KW * e; PAZ += 0.25 * m * P.side; sum += e; } // A pointer that never stops makes a riffle, never a steady blow. if (sum > 1) { PAX /= sum; PAY /= sum; PAZ /= sum; } } // Contact: a hand brushing the cloth. A push on the point's velocity // plus a short drag for points right under the pointer. function contact(id, p) { var ex = X[id] - p.x, ey = Y[id] - p.y, d2 = ex * ex + ey * ey; if (d2 >= R * R) return; var f = 1 - Math.sqrt(d2) / R, dt2 = DT * DT; f *= f * Math.min(1, p.sp / SP_FULL); X[id] += (p.vx * KC_X * f) * dt2; Y[id] += (p.vy * KC_Y * f) * dt2; Z[id] += (p.sp * KC_Z * f * p.side) * dt2; if (f > 0.4) { X[id] += p.vx * DT * DRAG * f; Y[id] += p.vy * DT * DRAG * f * 0.5; } } function integrate(id, ax, ay, az, dt2) { var vx = (X[id] - PX[id]) * DAMP, vy = (Y[id] - PY[id]) * DAMP, vz = (Z[id] - PZ[id]) * DAMP; var v2 = vx * vx + vy * vy + vz * vz; if (v2 > VMAX * VMAX) { var k = VMAX / Math.sqrt(v2); vx *= k; vy *= k; vz *= k; } if (v2 > motion) motion = v2; PX[id] = X[id]; PY[id] = Y[id]; PZ[id] = Z[id]; X[id] += vx + ax * dt2; Y[id] += vy + (ay + G) * dt2; Z[id] += vz + az * dt2; } // Light from the upper left, a little in front. var LX = -0.35, LY = -0.55, LZ = 0.76, grow = 1.1; function draw() { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); var m = dpr * scale; grow = 1 + 1 / (4 * m); ctx.setTransform(m, 0, 0, m, -offset * dpr, 0); ctx.strokeStyle = stringColour; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(X[0], Y[0]); for (var i = 1; i < ropeN; i++) ctx.lineTo(X[i], Y[i]); ctx.lineWidth = 1.3; ctx.stroke(); ctx.beginPath(); for (var f = 0; f < flags.length; f++) { var t = flags[f].tie; ctx.moveTo(X[t[0]], Y[t[0]]); ctx.lineTo(X[t[1]], Y[t[1]]); ctx.moveTo(X[t[2]], Y[t[2]]); ctx.lineTo(X[t[3]], Y[t[3]]); } ctx.lineWidth = 1; ctx.stroke(); for (f = 0; f < flags.length; f++) drawFlag(flags[f]); } function drawFlag(F) { var tr = F.tris, img = tex[F.c]; for (var k = 0; k < tr.length; k += 3) { var a = tr[k], b = tr[k + 1], c = tr[k + 2]; // Shade by how far the triangle has turned from facing the light // as it does at rest. var e1x = X[b] - X[a], e1y = Y[b] - Y[a], e1z = Z[b] - Z[a]; var e2x = X[c] - X[a], e2y = Y[c] - Y[a], e2z = Z[c] - Z[a]; var nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x; var nl = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; tri(img, a, b, c, (Math.abs(nx * LX + ny * LY + nz * LZ) / nl) - LZ); } } // One triangle of cloth: clipped a device pixel outside itself (or // the antialiased edges leave seams), textured by the affine map that // takes its three texture corners to its three points. function tri(img, p0, p1, p2, shade) { var u0 = U[p0], v0 = V[p0], u1 = U[p1], v1 = V[p1], u2 = U[p2], v2 = V[p2]; var x0 = X[p0], y0 = Y[p0], x1 = X[p1], y1 = Y[p1], x2 = X[p2], y2 = Y[p2]; var du1 = u1 - u0, dv1 = v1 - v0, du2 = u2 - u0, dv2 = v2 - v0, det = du1 * dv2 - du2 * dv1; var A = ((x1 - x0) * dv2 - (x2 - x0) * dv1) / det, B = ((y1 - y0) * dv2 - (y2 - y0) * dv1) / det; var C = ((x2 - x0) * du1 - (x1 - x0) * du2) / det, D = ((y2 - y0) * du1 - (y1 - y0) * du2) / det; var gx = (x0 + x1 + x2) / 3, gy = (y0 + y1 + y2) / 3, g = grow; ctx.save(); ctx.beginPath(); ctx.moveTo(gx + (x0 - gx) * g, gy + (y0 - gy) * g); ctx.lineTo(gx + (x1 - gx) * g, gy + (y1 - gy) * g); ctx.lineTo(gx + (x2 - gx) * g, gy + (y2 - gy) * g); ctx.clip(); ctx.transform(A, B, C, D, x0 - A * u0 - C * v0, y0 - B * u0 - D * v0); var sx = Math.max(0, Math.min(u0, u1, u2) - 3), sy = Math.max(0, Math.min(v0, v1, v2) - 3); 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); ctx.drawImage(img, sx, sy, sw, sh, sx, sy, sw, sh); if (shade < -0.02 || shade > 0.02) { ctx.globalCompositeOperation = "source-atop"; 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) + ")"; ctx.fillRect(sx, sy, sw, sh); } ctx.restore(); } // live is whether the canvas is on the page: false under reduced // motion. running is whether the frame loop is going. var live = false, running = false, onScreen = true, acc = 0, lastT = 0, drawn = false, raf = 0; function frame(now) { if (!running || !live) return; if (!onScreen || document.hidden) { running = false; return; } acc += Math.min((now - lastT) / 1000, 0.1); lastT = now; var t = (now - t0) / 1000, n = 0; motion = 0; while (acc >= DT && n < 3) { step(t); acc -= DT; n++; } if (probe && n) record(); // Still cloth, no breeze, no current and the page at rest: draw // this last frame and sleep. var still = n > 0 && !pending && !(touch && now - touch.t < 90) && breeze < 0.01 && Math.abs(airAt(now)) < AIR_REST && jig < 0.01 && Math.abs(frameV) < 1 && Math.abs(scrollY - frameY) < 0.5 && motion < 1e-4 && !(puffs.length && now / 1000 - puffs[puffs.length - 1].t < 0.6); if (n && (still || now - lastInput < CALM || (drawn = !drawn))) draw(); if (still) { running = false; return; } raf = requestAnimationFrame(frame); } // record widens the probe's extremes to this frame's cloth: every // point's place, and the worst stretch of any link over its length. function record() { for (var i = 0; i < X.length; i++) { if (!isFinite(X[i] + Y[i] + Z[i])) probe.finite = false; probe.minY = Math.min(probe.minY, Y[i]); probe.maxY = Math.max(probe.maxY, Y[i]); probe.minX = Math.min(probe.minX, X[i]); probe.maxX = Math.max(probe.maxX, X[i]); probe.maxZ = Math.max(probe.maxZ, Math.abs(Z[i])); } for (var k = 0; k < cons.length; k += 4) { var a = cons[k], c = cons[k + 1]; probe.stretch = Math.max(probe.stretch, Math.hypot(X[c] - X[a], Y[c] - Y[a], Z[c] - Z[a]) / cons[k + 2]); } } if (probe) window.siteBuntingProbe = function () { return JSON.stringify({ MinY: probe.minY, MaxY: probe.maxY, MinX: probe.minX, MaxX: probe.maxX, MaxZ: probe.maxZ, Stretch: probe.stretch, Finite: probe.finite, Height: canvas.getBoundingClientRect().height / scale, From: X ? X[0] : 0, To: X ? X[ropeN - 1] : 0, Running: running, Builds: probe.builds }); }; if (probe) window.siteBuntingProbe.hung = function () { return JSON.stringify(probe.hung); }; // window.siteBuntingStill is a hook for the tests: set before this // runs, the canvas is hung at rest and never moves, so a drive can // hold the still SVG to the first frame. function start() { if (running || !live || !onScreen || document.hidden || !tex.length || window.siteBuntingStill) return; running = true; lastT = performance.now(); acc = 0; // The frame picks up the page where it is, moving as it was, so // waking mid-scroll is not a jolt. frameY = scrollY; frameV = lastT - scrollT < 100 ? scrollV : 0; jig = 0; raf = requestAnimationFrame(frame); } // A hidden tab's queued frame may be suspended rather than run, which // would leave running set, so start() on the way back would return // early and the loop would pick up the follower and jig where they // were, a stale shake. Cancelling it and clearing running on the way // out makes the way back a fresh start(). function pause() { cancelAnimationFrame(raf); running = false; } var lx = null, ly = 0, lt = 0; // Any pointer move on the page sends a puff; over the row it also // touches the cloth. At most one puff leaves every PUFF_MS. addEventListener("pointermove", function (e) { if (!live) return; var wx = (e.clientX - docLeft + offset) / scale, wy = (e.clientY + scrollY - docTop) / scale, now = performance.now(); if (lx !== null && now > lt) { var dt = (now - lt) / 1000, vx = (wx - lx) / dt, vy = (wy - ly) / dt, sp = Math.hypot(vx, vy); if (sp > 2500) { vx *= 2500 / sp; vy *= 2500 / sp; sp = 2500; } if (!pending || sp > pending.sp) pending = { x: wx, y: wy, vx: vx, vy: vy, sp: sp, side: vx < 0 ? -1 : 1, t: now / 1000 }; if (now - lastPuff < PUFF_MS) pending.hold = true; else { pending.hold = false; lastPuff = now; } lastInput = now; start(); var cr = canvas.getBoundingClientRect(); if (e.clientY >= cr.top - CONTACT_PAD && e.clientY <= cr.top + H * scale + CONTACT_PAD) { touch = { x: wx, y: wy, vx: vx, vy: vy, sp: sp, side: vx < 0 ? -1 : 1, t: now }; } } lx = wx; ly = wy; lt = now; }, { passive: true }); // A scroll moves the air against the page: each event adds its // distance over AIR_TAU, so a steady scroll's air comes to its speed // and the sum does not depend on how many events carry it. A jump (a // link to an anchor, the End key) is not a hand moving the page, so // no more than LEAP, and no faster than SCROLL_MAX, counts. var sy = scrollY, st = performance.now(); addEventListener("scroll", function () { var now = performance.now(), dt = Math.max(now - st, 8), lim = Math.min(LEAP, SCROLL_MAX * dt / 1000); var d = Math.max(-lim, Math.min(lim, scrollY - sy)), v = d / dt * 1000; sy = scrollY; st = now; air = Math.max(-AIR_MAX, Math.min(AIR_MAX, airAt(now) + d / AIR_TAU)); airT = now; scrollV = now - scrollT < 100 ? (scrollV + v) / 2 : v; scrollT = now; // Wake on the air as well as the speed: v is averaged since the // last event, so one wheel turn after a long idle reads as slow // while the air it moved is not. if (Math.abs(v) > 50 || Math.abs(air) > AIR_REST) { lastInput = now; start(); } }, { passive: true }); new IntersectionObserver(function (es) { onScreen = es[0].isIntersecting; if (onScreen) start(); }).observe(row); document.addEventListener("visibilitychange", function () { if (document.hidden) pause(); else start(); }); addEventListener("pagehide", pause); addEventListener("pageshow", start); var rz; addEventListener("resize", function () { clearTimeout(rz); rz = setTimeout(function () { if (live) { layout(); draw(); } }, 120); }); // The rope's colour is a Fiesta token, which each theme and each // scheme sets differently, so the textures are painted again when // either changes: the scheme from the page's own menu (rst-scheme, // which fiesta.js dispatches) or from the OS, and the theme when the // stylesheet site.js swapped into #site-theme has loaded. function repaint() { if (live) textures(function () { if (live) draw(); }); } document.addEventListener("rst-scheme", repaint); matchMedia("(prefers-color-scheme: dark)").addEventListener("change", repaint); var themeLink = document.getElementById("site-theme"); if (themeLink) themeLink.addEventListener("load", repaint); // The canvas is hung only while motion is allowed. When the // preference turns on mid-visit the canvas goes and the SVG shows; // when it turns off again the canvas is hung afresh. var hanging = false; function hang() { if (live || hanging || reduce.matches) return; hanging = true; textures(function () { hanging = false; if (live || reduce.matches) return; live = true; row.appendChild(canvas); layout(); draw(); row.classList.add("site-bunting-live"); lastInput = performance.now(); breeze = windless ? 0 : 1; start(); }); } function takeDown() { if (!live) return; live = false; running = false; row.classList.remove("site-bunting-live"); canvas.remove(); } reduce.addEventListener("change", function () { if (reduce.matches) takeDown(); else hang(); }); hang(); })();