diff --git a/css/style.css b/css/style.css index 8f75498..fac8581 100644 --- a/css/style.css +++ b/css/style.css @@ -187,43 +187,28 @@ button { color: var(--muted); } -/* ── side art ─────────────────────────────────────────────────────────── - Full-height sakura in both margins: ANSI art (inc/art/sakura.ans, `cat` - it), turned by inc/art.php into one text layer per color, served as - sideart.php's ::before content on the empty s and mirrored for the right - side so the branches hang in from the outer corners. Hidden when there's - no margin. */ - -/* The art is drawn for .6em-wide characters; system monospaces range from - .5em up, which squashes it. So it gets its own: JetBrains Mono's printable - ASCII (OFL), 4.6 KB, inlined so it arrives with this file -- as a separate - download the art stayed invisible until it landed, then popped in. - Source: css/sideart.woff2. */ -@font-face { - font-family: sideart; - src: url(data:font/woff2;base64,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) format("woff2"); -} - -.side { +/* The koi own the margins now. Cell size comes straight from this font-size, + and the body is 16 cells long, so this is the dial for how big they are. + The column shadow (body::before, z-index -1) fades them toward the text. */ +.koi { position: fixed; top: 0; z-index: -2; - margin: 0; - max-width: calc(50% - 360px); - overflow: hidden; - /* 88 rows at a 1.2 line fill the height */ - font: calc(100vh / 105.6) / 1.2 sideart, monospace; - opacity: .85; + width: calc(50% - 360px); + height: 100vh; pointer-events: none; user-select: none; + /* font-size IS the square cell size in px -- the one size dial */ + font: clamp(7px, 0.95vh, 10px) / 1 ui-monospace, "JetBrains Mono", Menlo, monospace; + /* the pond's cell grid. 1em == one cell, so it stays locked to the tiles + the canvas draws however the font-size scales. */ + background-image: + linear-gradient(to right, rgba(228, 230, 238, .05) 1px, transparent 1px), + linear-gradient(to bottom, rgba(228, 230, 238, .05) 1px, transparent 1px); + background-size: 1em 1em; } -/* the art comes as one layer per color, stacked */ -.side { display: grid; } -.side > b { grid-area: 1 / 1; font-weight: normal; direction: ltr; unicode-bidi: isolate; } -.side-left { left: 0; } -/* rtl only so the right panel clips on its inner edge; the layers stay ltr - (above), or the bidi algorithm reorders runs of punctuation */ -.side-right { right: 0; direction: rtl; } +.koi-left { left: 0; } +.koi-right { right: 0; } /* the column's shadow: the art fades out under it instead of stopping dead beside the text. Eased falloff, as wide as about a third of the margin. */ @@ -249,11 +234,11 @@ body::before { } @media (max-width: 1280px) { - .side, body::before { display: none; } + .koi, body::before { display: none; } } @media print, (forced-colors: active) { - .side, body::before { display: none; } + .koi, body::before { display: none; } } /* guestbook sign button */ diff --git a/inc/footer.php b/inc/footer.php index b33411e..740a0ae 100644 --- a/inc/footer.php +++ b/inc/footer.php @@ -10,7 +10,8 @@ $build = preg_match('/[0-9a-f]{7,40}/i', $build, $m) ? $m[0] : ($build ?: 'dev') © · build: - - + + + diff --git a/inc/header.php b/inc/header.php index 7c04493..2d9adf8 100644 --- a/inc/header.php +++ b/inc/header.php @@ -1,7 +1,5 @@ @@ -12,7 +10,6 @@ require_once __DIR__ . '/art.php'; <?= htmlspecialchars($page_title ?? SITE_TITLE) ?> - diff --git a/js/koi.js b/js/koi.js new file mode 100644 index 0000000..014cee5 --- /dev/null +++ b/js/koi.js @@ -0,0 +1,406 @@ +/** + * 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); + } +})();