| 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 | |