A canvas-rendered departure-board animation: text is rasterised into a 7x13 bitmap font, then each pixel row slides in from off-screen on an easeInOutQuad curve with staggered timing (0.4 s between rows, 1.2 s reveal duration). Rows hold for 3 seconds, then exit-slide in reverse order. Each filled horizontal run is drawn as a coloured accent rectangle with narrow dark-ink edge strips on both sides, giving every lit block a recessed channel look. Speed, block size, ink colour, and accent colour are independently tuneable.
Signal
bitmap · animating
Source
"use client";
import { useEffect, useRef, useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelToggle, PanelText } from "../_kansei/controls";
import { SB_FONT, SB_GLYPH_W, SB_GLYPH_H, SB_ADV_X } from "./sbfont";
const MAX_DISPLAY_W = 760;
export default function Signal() {
const [text, setText] = useState("HARU");
const [blockSize, setBlockSize] = useState(16);
const [speed, setSpeed] = useState(1.0);
const [animate, setAnimate] = useState(true);
const [inkColor, setInkColor] = useState("#1a1a1a");
const [accentColor, setAccentColor] = useState("#e38953");
const [bgColor, setBgColor] = useState("#f4efe6");
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const REVEAL_STAGGER = 0.4;
const REVEAL_DUR = 1.2;
const HOLD_DUR = 3.0;
const EXIT_STAGGER = 0.4;
const EXIT_DUR = 1.0;
const GAP = 1.0;
const easeInOutQuad = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
function draw(rowOffset: number) {
const ctx = canvas!.getContext("2d");
if (!ctx) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const bs = blockSize;
const chars = text.toUpperCase().split("");
const textUnitsW = Math.max(1, chars.length * SB_ADV_X - (SB_ADV_X - SB_GLYPH_W));
const textUnitsH = SB_GLYPH_H;
const occ = new Uint8Array(textUnitsW * textUnitsH);
for (let ci = 0; ci < chars.length; ci++) {
const glyph = SB_FONT[chars[ci]] ?? SB_FONT["?"] ?? SB_FONT[" "];
const gx = ci * SB_ADV_X;
for (let gy = 0; gy < SB_GLYPH_H; gy++) {
const row = glyph[gy] ?? ".......";
for (let gxx = 0; gxx < SB_GLYPH_W; gxx++) {
if (row[gxx] !== "#") continue;
const x = gx + gxx;
if (x < 0 || x >= textUnitsW) continue;
occ[gy * textUnitsW + x] = 1;
}
}
}
const PAD = bs * 2;
const W = textUnitsW * bs + PAD * 2;
const H = textUnitsH * bs + PAD * 2;
canvas!.width = W * dpr;
canvas!.height = H * dpr;
const displayW = Math.min(W, MAX_DISPLAY_W);
const displayH = W > MAX_DISPLAY_W ? Math.round((H * MAX_DISPLAY_W) / W) : H;
canvas!.style.width = `${displayW}px`;
canvas!.style.height = `${displayH}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
const darkCoreW = Math.max(1, Math.floor(bs * 0.52));
const nRows = textUnitsH;
const holdStart = (nRows - 1) * REVEAL_STAGGER + REVEAL_DUR;
const holdEnd = holdStart + HOLD_DUR;
const totalCycle = holdEnd + (nRows - 1) * EXIT_STAGGER + EXIT_DUR + GAP;
const slideMax = W + PAD;
const phase = rowOffset % totalCycle;
for (let gy = 0; gy < nRows; gy++) {
const revealStart = gy * REVEAL_STAGGER;
const revealEnd = revealStart + REVEAL_DUR;
const exitStart = holdEnd + (nRows - 1 - gy) * EXIT_STAGGER;
const exitEnd = exitStart + EXIT_DUR;
let slideOffset = 0;
if (phase < revealStart || phase >= exitEnd) {
continue;
} else if (phase < revealEnd) {
slideOffset = Math.round(slideMax * (1 - easeInOutQuad((phase - revealStart) / REVEAL_DUR)));
} else if (phase < exitStart) {
slideOffset = 0;
} else {
slideOffset = -Math.round(slideMax * easeInOutQuad((phase - exitStart) / EXIT_DUR));
}
let x = 0;
while (x < textUnitsW) {
if (!occ[gy * textUnitsW + x]) {
x++;
continue;
}
const runStart = x;
while (x < textUnitsW && occ[gy * textUnitsW + x]) x++;
const runEnd = x - 1;
const rx = PAD + runStart * bs + slideOffset;
const ry = PAD + gy * bs;
const rw = (runEnd - runStart + 1) * bs;
ctx.fillStyle = accentColor;
ctx.fillRect(rx, ry, rw, bs);
ctx.fillStyle = inkColor;
ctx.fillRect(rx, ry, darkCoreW, bs);
ctx.fillRect(rx + rw - darkCoreW, ry, darkCoreW, bs);
}
}
}
let rafId = 0;
let startTime: number | null = null;
function frame(ts: number) {
if (startTime === null) startTime = ts;
draw(((ts - startTime) / 1000) * speed);
if (animate) rafId = requestAnimationFrame(frame);
}
if (animate) {
rafId = requestAnimationFrame(frame);
} else {
draw((SB_GLYPH_H - 1) * 0.4 + 1.2 + 3.0 * 0.5);
}
return () => cancelAnimationFrame(rafId);
}, [text, blockSize, speed, animate, inkColor, accentColor]);
return (
<div className="fixed inset-0 flex items-center justify-center select-none" style={{ background: bgColor }}>
<canvas ref={canvasRef} className="z-[1]" />
<div
className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
>
<p className="text-[12px] tracking-[0.08em] uppercase text-foreground/50">Signal</p>
<p className="text-[12px] tracking-[0.08em] mt-1 text-foreground/30">bitmap · {animate ? "animating" : "static"}</p>
</div>
<DraggablePanel
isLight={false}
controls={[
{ label: "Block size", value: blockSize, set: (v) => setBlockSize(Math.round(v)), min: 6, max: 32, step: 1 },
{ label: "Speed", value: speed, set: setSpeed, min: 0.2, max: 4, step: 0.1 },
]}
>
<PanelText label="Text" value={text} set={setText} placeholder="HARU" isLight={false} />
<PanelToggle label="Animate" value={animate} set={setAnimate} isLight={false} />
<PanelColor label="Ink" value={inkColor} set={setInkColor} isLight={false} />
<PanelColor label="Accent" value={accentColor} set={setAccentColor} isLight={false} />
<PanelColor label="Background" value={bgColor} set={setBgColor} isLight={false} />
</DraggablePanel>
<div className="fixed top-8 left-8 z-10">
<ScrambleLink
from="NEMAWASHI LAB"
to="← BACK"
href="/lab"
className="text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] text-foreground/60"
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}
// 7×13 bitmap font for the Signal effect. Ported from kansei (SB_FONT).
export const SB_GLYPH_W = 7;
export const SB_GLYPH_H = 13;
export const SB_ADV_X = 8;
export const SB_ADV_Y = 15;
export const SB_FONT: Record<string, string[]> = {
A: [".#####.", "###.###", "###.###", "###.###", "###.###", "###.###", "#######", "###.###", "###.###", "###.###", "###.###", ".......", "......."],
B: ["######.", "###.###", "###.###", "###.###", "######.", "######.", "###.###", "###.###", "###.###", "###.###", "######.", ".......", "......."],
C: [".######", "###.###", "###....", "###....", "###....", "###....", "###....", "###....", "###....", "###.###", ".######", ".......", "......."],
D: ["#####..", "###.##.", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.##.", "#####..", ".......", "......."],
E: ["#######", "###....", "###....", "###....", "######.", "###....", "###....", "###....", "###....", "###....", "#######", ".......", "......."],
F: ["#######", "###....", "###....", "###....", "######.", "###....", "###....", "###....", "###....", "###....", "###....", ".......", "......."],
G: [".#####.", "###.###", "###....", "###....", "###....", "###.###", "###.###", "###.###", "###.###", "###.###", ".#####.", ".......", "......."],
H: ["###.###", "###.###", "###.###", "###.###", "###.###", "#######", "###.###", "###.###", "###.###", "###.###", "###.###", ".......", "......."],
I: ["#######", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "#######", ".......", "......."],
J: ["..#####", "....###", "....###", "....###", "....###", "....###", "....###", "....###", "###.###", "###.###", ".#####.", ".......", "......."],
K: ["###.###", "###.###", "###.##.", "######.", "#####..", "#####..", "######.", "###.##.", "###.###", "###.###", "###.###", ".......", "......."],
L: ["###....", "###....", "###....", "###....", "###....", "###....", "###....", "###....", "###....", "###....", "#######", ".......", "......."],
M: ["###.###", "#######", "#######", "#######", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", ".......", "......."],
N: ["###.###", "####.##", "#######", "#######", "##.####", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", ".......", "......."],
O: [".#####.", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", ".#####.", ".......", "......."],
P: ["######.", "###.###", "###.###", "###.###", "###.###", "######.", "###....", "###....", "###....", "###....", "###....", ".......", "......."],
Q: [".#####.", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", ".######", "..#####", ".......", "......."],
R: ["######.", "###.###", "###.###", "###.###", "###.###", "######.", "######.", "###.##.", "###.###", "###.###", "###.###", ".......", "......."],
S: [".######", "###.###", "###....", "###....", ".#####.", "....###", "....###", "....###", "....###", "###.###", "######.", ".......", "......."],
T: ["#######", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", ".......", "......."],
U: ["###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", ".#####.", ".......", "......."],
V: ["###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", ".#####.", "..###..", "...#...", ".......", "......."],
W: ["###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "###.###", "#######", "#######", "#######", "###.###", ".......", "......."],
X: ["###.###", "###.###", ".##.##.", "..###..", "..###..", "..###..", "..###..", ".##.##.", "###.###", "###.###", "###.###", ".......", "......."],
Y: ["###.###", "###.###", "###.###", ".#####.", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", ".......", "......."],
Z: ["#######", "....###", "....###", "...###.", "..###..", "..###..", ".###...", "###....", "###....", "###....", "#######", ".......", "......."],
"0": [".#####.", "###.###", "###.###", "##..###", "##.#.##", "###..##", "###.###", "###.###", "###.###", "###.###", ".#####.", ".......", "......."],
"1": ["..###..", "..###..", ".####..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", ".......", "......."],
"2": [".#####.", "###.###", "....###", "....###", "...###.", "..###..", ".###...", "###....", "###....", "###....", "#######", ".......", "......."],
"3": ["######.", "....###", "....###", "....###", ".#####.", "....###", "....###", "....###", "....###", "....###", "######.", ".......", "......."],
"4": ["....###", "...####", "..#.###", ".#..###", "#...###", "#######", "....###", "....###", "....###", "....###", "....###", ".......", "......."],
"5": ["#######", "###....", "###....", "###....", "######.", "....###", "....###", "....###", "....###", "....###", "######.", ".......", "......."],
"6": [".#####.", "###....", "###....", "###....", "######.", "###.###", "###.###", "###.###", "###.###", "###.###", ".#####.", ".......", "......."],
"7": ["#######", "....###", "....###", "...###.", "...###.", "..###..", "..###..", ".###...", ".###...", ".###...", ".###...", ".......", "......."],
"8": [".#####.", "###.###", "###.###", "###.###", ".#####.", "###.###", "###.###", "###.###", "###.###", "###.###", ".#####.", ".......", "......."],
"9": [".#####.", "###.###", "###.###", "###.###", "###.###", ".######", "....###", "....###", "....###", "....###", ".#####.", ".......", "......."],
"?": [".#####.", "###.###", "....###", "...###.", "..###..", "..###..", "..###..", ".......", ".......", "..###..", "..###..", ".......", "......."],
"!": ["..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", ".......", ".......", "..###..", ".......", "......."],
" ": [".......", ".......", ".......", ".......", ".......", ".......", ".......", ".......", ".......", ".......", ".......", ".......", "......."],
};