/** * Koi for the side panels -- letters on a square cell grid, swimming. * * Rendering follows the reference: every cell is a filled tile in the koi's * colour with a letter punched into it in near-black ink. The tile carries * the colour and its brightness, the letter carries the texture, picked from * a thin-to-thick ramp by how dense that part of the body is. Drawing bright * letters on a dark page instead looks nothing like it. * * The grid is square. Using the natural text grid (cells ~.6 as wide as they * are tall) makes a fish's proportions depend on its heading: thickness is * perpendicular to travel, so swimming sideways comes out short and fat. * * The body is a follow-the-leader chain with a bend limit per joint, not a * spine projected from the head. A projected spine rotates rigidly the * instant the fish turns; a chain lags, and the bend limit stops it coiling * into a ball the way a raw position trail does. */ (function () { 'use strict'; var RAMP = ':;+*xzXZ%#&@'; /* thin -> thick */ var EYE = 'o'; var INK = '#0b0b0d'; /* the letter punched into each tile */ var GAP = 1; /* gutter between tiles, px */ var CREAM = [233, 229, 220], RED = [206, 68, 42], GOLD = [224, 169, 46], SUMI = [82, 83, 90]; var VARIETIES = [ { name: 'kohaku', base: CREAM, patches: [[0.12, 0.36, RED], [0.52, 0.72, RED]] }, { name: 'ogon', base: GOLD, patches: [[0.58, 0.82, [186, 136, 36]]] }, { name: 'sanke', base: CREAM, patches: [[0.14, 0.36, RED], [0.38, 0.47, SUMI], [0.56, 0.72, RED], [0.76, 0.88, SUMI]] }, { name: 'showa', base: SUMI, patches: [[0.12, 0.32, RED], [0.44, 0.60, CREAM], [0.68, 0.84, RED]] }, { name: 'tancho', base: CREAM, patches: [[0.03, 0.13, RED]] } ]; var LINKS = 24; /* joints in the body -- its length */ var SLIM = 0.115; /* max half-width as a fraction of length */ var BEND = 0.17; /* max radians between consecutive links */ var LEVELS = 5; /* quantised tile brightnesses */ /* Half-width as a fraction of the maximum, nose (0) to tail tip (1). */ function profile(u) { if (u < 0.05) return 0.44 + u * 3.4; if (u < 0.26) return 0.61 + (u - 0.05) * 1.9; if (u < 0.64) return 1.00 - (u - 0.26) * 1.95; if (u < 0.80) return 0.26 - (u - 0.64) * 1.1; return 0.08 + (u - 0.80) * 3.2; } /* stable per-cell jitter in body coordinates, so the texture travels with the fish instead of shimmering against the screen */ function hash(a, b) { var x = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453; return x - Math.floor(x); } function colourAt(v, t) { for (var i = 0; i < v.patches.length; i++) { var p = v.patches[i]; if (t >= p[0] && t <= p[1]) return p[2]; } return v.base; } /* rgb() strings for every colour at every brightness, built once -- rebuilding them per cell would churn thousands of strings a frame */ var SHADES = {}; function shadesFor(c) { var k = c[0] + '_' + c[1] + '_' + c[2]; if (SHADES[k]) return SHADES[k]; var out = []; for (var i = 0; i < LEVELS; i++) { var f = 0.34 + 0.66 * (i / (LEVELS - 1)); out.push('rgb(' + Math.round(c[0] * f) + ',' + Math.round(c[1] * f) + ',' + Math.round(c[2] * f) + ')'); } SHADES[k] = out; return out; } function Koi(panel, i, n, offset) { this.v = VARIETIES[(i + (offset || 0)) % VARIETIES.length]; this.panel = panel; this.a = Math.random() * Math.PI * 2; this.speed = 16 + Math.random() * 12; /* px per second -- koi drift */ this.phase = Math.random() * Math.PI * 2; this.beat = 1.7 + Math.random() * 0.9; this.sway = 0.20 + Math.random() * 0.16; this.girth = 0.9 + Math.random() * 0.26; this.w1 = 0.09 + Math.random() * 0.07; this.w2 = 0.19 + Math.random() * 0.11; this.p1 = Math.random() * 99; this.p2 = Math.random() * 99; this.x = panel.w * (0.15 + 0.7 * Math.random()); this.y = panel.h * ((i + 0.5) / n); /* born straight, laid out behind the head */ this.j = new Float64Array(LINKS * 2); var link = panel.cell; for (var k = 0; k < LINKS; k++) { this.j[k * 2] = this.x - Math.cos(this.a) * k * link; this.j[k * 2 + 1] = this.y - Math.sin(this.a) * k * link; } } Koi.prototype.step = function (dt, t, pointer) { var p = this.panel; /* wander: two slow sines, so the heading meanders without repeating */ this.a += (Math.sin(t * this.w1 + this.p1) + Math.sin(t * this.w2 + this.p2)) * dt * 0.22; /* Turn well before the edge: the margin has to clear the whole body. */ var body = LINKS * p.cell; var mx = Math.min(p.w * 0.32, body * 0.45); var my = Math.min(p.h * 0.20, body * 0.32); if (this.x < mx) this.a += turnTo(this.a, 0, dt * 0.8); if (this.x > p.w - mx) this.a += turnTo(this.a, Math.PI, dt * 0.8); if (this.y < my) this.a += turnTo(this.a, Math.PI / 2, dt * 0.8); if (this.y > p.h - my) this.a += turnTo(this.a, -Math.PI / 2, dt * 0.8); var speed = this.speed; /* drift away from the pointer -- a spook, not a panic */ if (pointer.in) { var dx = this.x - pointer.x, dy = this.y - pointer.y; var d = Math.hypot(dx, dy); if (d < 170 && d > 0.01) { this.a += turnTo(this.a, Math.atan2(dy, dx), dt * (1.1 * (1 - d / 170))); speed += 34 * (1 - d / 170); } } if (pointer.typing) speed += 14; this.phase += dt * this.beat; /* the head yaws with the tail beat; the chain turns that into a travelling wave down the body on its own */ var head = this.a + Math.sin(this.phase) * this.sway; this.x += Math.cos(head) * speed * dt; this.y += Math.sin(head) * speed * dt; this.x = Math.max(2, Math.min(p.w - 2, this.x)); this.y = Math.max(2, Math.min(p.h - 2, this.y)); /* follow the leader: each joint is pulled to a fixed distance behind the one ahead, and the turn between links is capped so it cannot coil */ var j = this.j, link = p.cell; j[0] = this.x; j[1] = this.y; var prevAng = head + Math.PI; for (var i = 1; i < LINKS; i++) { var ax = j[i * 2] - j[(i - 1) * 2]; var ay = j[i * 2 + 1] - j[(i - 1) * 2 + 1]; var ang = Math.atan2(ay, ax); var diff = Math.atan2(Math.sin(ang - prevAng), Math.cos(ang - prevAng)); if (diff > BEND) diff = BEND; else if (diff < -BEND) diff = -BEND; ang = prevAng + diff; j[i * 2] = j[(i - 1) * 2] + Math.cos(ang) * link; j[i * 2 + 1] = j[(i - 1) * 2 + 1] + Math.sin(ang) * link; prevAng = ang; } }; function turnTo(a, target, k) { var d = Math.atan2(Math.sin(target - a), Math.cos(target - a)); return d * Math.min(1, k * 2.6); } Koi.prototype.draw = function (ctx, p) { var cells = p.cells; cells.clear(); var cell = p.cell; var L = (LINKS - 1) * cell; var maxHW = L * SLIM * this.girth; var j = this.j; var steps = 2; /* sub-steps per link, so no cell is missed */ for (var i = 0; i < LINKS - 1; i++) { var ax = j[i * 2], ay = j[i * 2 + 1]; var bx = j[(i + 1) * 2], by = j[(i + 1) * 2 + 1]; var ang = Math.atan2(by - ay, bx - ax); var sin = Math.sin(ang), cos = Math.cos(ang); for (var s = 0; s < steps; s++) { var f = s / steps; var u = (i + f) / (LINKS - 1); var sx = ax + (bx - ax) * f; var sy = ay + (by - ay) * f; var hw = profile(u) * maxHW; var shades = shadesFor(colourAt(this.v, u)); for (var v = -hw - cell; v <= hw + cell; v += cell * 0.5) { var av = Math.abs(v); var cov = (hw - av) / cell + 0.55; /* fork the caudal fin: a notch opening toward the tip */ if (u > 0.86) { var notch = (u - 0.86) * 5.6 * maxHW; if (av < notch) cov -= (notch - av) / cell * 1.5; } if (cov <= 0.06) continue; var edge = hw > 0 ? 1 - av / hw : 0; if (edge < 0) edge = 0; var d = 0.26 + 0.6 * edge * Math.min(1, cov) + (hash(i * 2 + s, Math.round(v / cell * 2)) - 0.5) * 0.26; /* The tail dissolves instead of ending in a solid fan: drop an increasing share of its cells toward the tip and dim what is left, so it breaks into loose tiles the way the reference does. */ if (u > 0.74) { var sc = (u - 0.74) / 0.26; if (hash(i * 5 + s, Math.round(v / cell * 2) + 91) < sc * 0.66) continue; d *= 1 - sc * 0.4; } if (d < 0.06) continue; if (d > 0.99) d = 0.99; put(cells, p, sx - sin * v, sy + cos * v, d, shades, false); } /* pectoral fins, trailing back off the shoulder */ if (s === 0 && i === Math.round((LINKS - 1) * 0.27)) { for (var fin = 1; fin <= 4; fin++) { var reach = hw + fin * cell * 0.85, back = fin * cell * 0.72; if (hash(i, fin + 7) > 0.22) { put(cells, p, sx - sin * reach - cos * back, sy + cos * reach - sin * back, 0.34 - fin * 0.06, shades, false); } if (hash(i, fin + 31) > 0.22) { put(cells, p, sx + sin * reach - cos * back, sy - cos * reach - sin * back, 0.34 - fin * 0.06, shades, false); } } } /* eyes, just behind the nose */ if (s === 0 && i === Math.round((LINKS - 1) * 0.07)) { var ew = hw * 0.62; put(cells, p, sx - sin * ew, sy + cos * ew, 0.95, shades, true); put(cells, p, sx + sin * ew, sy - cos * ew, 0.95, shades, true); } } } var tile = cell - GAP, half = tile / 2; cells.forEach(function (c) { ctx.fillStyle = c.sh[Math.min(LEVELS - 1, Math.floor(c.d * LEVELS))]; ctx.fillRect(c.x, c.y, tile, tile); ctx.fillStyle = INK; ctx.fillText(c.eye ? EYE : RAMP.charAt(Math.min(RAMP.length - 1, Math.floor(c.d * RAMP.length))), c.x + half, c.y + half); }); }; /* takes pixels; the grid is the rasterisation target, not the coordinates */ function put(cells, p, x, y, d, sh, eye) { var gx = Math.round(x / p.cell), gy = Math.round(y / p.cell); var k = gx + ',' + gy; var prev = cells.get(k); if (prev && (prev.eye || (!eye && prev.d >= d))) return; cells.set(k, { x: gx * p.cell, y: gy * p.cell, d: d, sh: sh, eye: !!eye }); } function Panel(canvas, count, offset) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.count = count; this.offset = offset || 0; this.koi = []; this.cells = new Map(); this.w = 0; this.h = 0; this.cell = 9; } Panel.prototype.resize = function () { var r = this.canvas.getBoundingClientRect(); var dpr = window.devicePixelRatio || 1; this.w = r.width; this.h = r.height; this.canvas.width = Math.max(1, Math.round(r.width * dpr)); this.canvas.height = Math.max(1, Math.round(r.height * dpr)); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); var cs = getComputedStyle(this.canvas); this.cell = Math.max(5, parseFloat(cs.fontSize) || 9); /* square cells */ this.font = Math.round(this.cell * 0.92) + 'px ' + cs.fontFamily; this.applyFont(); if (!this.koi.length) { for (var i = 0; i < this.count; i++) this.koi.push(new Koi(this, i, this.count, this.offset)); } else { for (var k = 0; k < this.koi.length; k++) { this.koi[k].x = Math.min(this.koi[k].x, this.w - 4); this.koi[k].y = Math.min(this.koi[k].y, this.h - 4); } } }; Panel.prototype.applyFont = function () { this.ctx.font = this.font; this.ctx.textBaseline = 'middle'; this.ctx.textAlign = 'center'; }; Panel.prototype.frame = function (dt, t, pointer) { this.ctx.clearRect(0, 0, this.w, this.h); this.applyFont(); for (var i = 0; i < this.koi.length; i++) { this.koi[i].step(dt, t, pointer); this.koi[i].draw(this.ctx, this); } }; function start() { var left = document.querySelector('.koi-left'); var right = document.querySelector('.koi-right'); if (!left || !right) return; var wide = window.matchMedia('(min-width: 1281px)'); var still = window.matchMedia('(prefers-reduced-motion: reduce)'); var panels = [new Panel(left, 3, 0), new Panel(right, 2, 3)]; var pointer = { x: 0, y: 0, in: false, typing: false, until: 0 }; var running = false, last = 0, t = 0, raf = 0; function resize() { for (var i = 0; i < panels.length; i++) panels[i].resize(); } function once() { for (var i = 0; i < panels.length; i++) panels[i].frame(0, 0, pointer); } function loop(now) { if (!running) return; var dt = Math.min(0.05, (now - last) / 1000) || 0; last = now; t += dt; pointer.typing = now < pointer.until; for (var i = 0; i < panels.length; i++) panels[i].frame(dt, t, pointer); raf = requestAnimationFrame(loop); } function play() { if (running || !wide.matches || document.hidden) return; running = true; last = performance.now(); raf = requestAnimationFrame(loop); } function stop() { running = false; if (raf) cancelAnimationFrame(raf); } resize(); if (still.matches) { once(); } else { play(); document.addEventListener('visibilitychange', function () { if (document.hidden) stop(); else play(); }); wide.addEventListener('change', function () { if (wide.matches) { resize(); play(); } else stop(); }); } var rt; window.addEventListener('resize', function () { clearTimeout(rt); rt = setTimeout(function () { resize(); if (still.matches) once(); }, 150); }); window.addEventListener('pointermove', function (e) { for (var i = 0; i < panels.length; i++) { var r = panels[i].canvas.getBoundingClientRect(); if (e.clientX >= r.left && e.clientX <= r.right) { pointer.in = true; pointer.x = e.clientX - r.left; pointer.y = e.clientY - r.top; return; } } pointer.in = false; }, { passive: true }); document.addEventListener('keydown', function (e) { var el = e.target; if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) { pointer.until = performance.now() + 700; } }, { passive: true }); } if (document.fonts && document.fonts.ready) { document.fonts.ready.then(start); } else if (document.readyState !== 'loading') { start(); } else { document.addEventListener('DOMContentLoaded', start); } })();