Five varieties (kohaku, ogon, sanke, showa, tancho) swim in the side margins, drawn the way the reference does it: every cell is a filled tile in the koi's colour with a letter punched into it in near-black, picked from a thin-to-thick ramp by how dense that part of the body is. The tile carries the colour, the letter carries the texture. The grid is square and the geometry is in pixels, snapping to cells only when one is written. Laying the body out in grid cells instead makes its proportions depend on heading -- thickness is perpendicular to travel, so on the natural text grid a fish swimming sideways came out short and fat while one swimming up came out slim. The body is a follow-the-leader chain with a bend limit per joint. A spine projected from the head rotates rigidly the moment the fish turns, and a raw position trail coils into a ball; the chain lags and the bend cap keeps it from curling. Swimming comes from the head yawing with the tail beat, which the chain propagates as a travelling wave. Tails dissolve into loose tiles toward the tip, and the panels carry a 1em cell grid so the motion reads against something. Drops the sakura wiring: no art.php parse, no sideart.php stylesheet, no <pre> layers, and the inlined sideart @font-face goes with them (-6.9 KB of base64 from style.css). art.php, sideart.php, sakura.ans and the woff2 are left on disk, now unreferenced.
This commit is contained in:
+18
-33
File diff suppressed because one or more lines are too long
+3
-2
@@ -10,7 +10,8 @@ $build = preg_match('/[0-9a-f]{7,40}/i', $build, $m) ? $m[0] : ($build ?: 'dev')
|
||||
</div>
|
||||
<small>© <?= date('Y') ?> · build: <a href="<?= htmlspecialchars(REPO_URL . ($build !== 'dev' ? '/src/commit/' . $build : '')) ?>"><?= htmlspecialchars($build) ?></a></small>
|
||||
</footer>
|
||||
<pre class="side side-left" aria-hidden="true"><?= str_repeat('<b></b>', $artLeft) ?></pre>
|
||||
<pre class="side side-right" aria-hidden="true"><?= str_repeat('<b></b>', $artRight) ?></pre>
|
||||
<canvas class="koi koi-left" aria-hidden="true"></canvas>
|
||||
<canvas class="koi koi-right" aria-hidden="true"></canvas>
|
||||
<script src="/js/koi.js?v=<?= filemtime(__DIR__ . '/../js/koi.js') ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/art.php';
|
||||
[$artCss, $artLeft, $artRight] = ansi_css_cached(__DIR__ . '/art/sakura.ans');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -12,7 +10,6 @@ require_once __DIR__ . '/art.php';
|
||||
<title><?= htmlspecialchars($page_title ?? SITE_TITLE) ?></title>
|
||||
<link rel="icon" href="/img/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/css/style.css?v=<?= filemtime(__DIR__ . '/../css/style.css') ?>">
|
||||
<link rel="stylesheet" href="/sideart.php?v=<?= filemtime(__DIR__ . '/art/sakura.ans') . '-' . filemtime(__DIR__ . '/art.php') ?>">
|
||||
<script src="/js/htmx.min.js" defer></script>
|
||||
<script src="/js/idiomorph-ext.min.js" defer></script>
|
||||
</head>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user