NEMAWASHI LAB

Signal

Canvas

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

Controls
Block size16
Speed1.0
Text
Ink
Accent
Background

Source

Signal.tsx169 lines
"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>
  );
}
sbfont.ts48 lines
// 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": [".#####.", "###.###", "###.###", "###.###", "###.###", ".######", "....###", "....###", "....###", "....###", ".#####.", ".......", "......."],
  "?": [".#####.", "###.###", "....###", "...###.", "..###..", "..###..", "..###..", ".......", ".......", "..###..", "..###..", ".......", "......."],
  "!": ["..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", "..###..", ".......", ".......", "..###..", ".......", "......."],
  " ": [".......", ".......", ".......", ".......", ".......", ".......", ".......", ".......", ".......", ".......", ".......", ".......", "......."],
};

NEMAWASHI — Kotaro Abe