morse

[github]

a lamp keying morse, the marks on a tape, letters decoded

type 64×19 15 fps 4.9 kB

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="morse" options='{"text":"hello world"}'></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { morse } from "ascii.rest/pieces";

<Ascii piece={morse} options={{"text":"hello world"}} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="morse", to fetch the piece only when it mounts.

astro

---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---

<Ascii piece="morse" options={{"text":"hello world"}} />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { morse } from "ascii.rest/pieces";

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, morse, {"text":"hello world"});

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

morse.ts

[js] [github]
/*
 * morse: a signal lamp keys out a message. The marks run off along a tape from
 * the head, and each letter is written beneath its group once it is complete.
 */
import type { Frame, Meta } from "../types.ts";

export interface MorseOptions {
  [key: string]: unknown;
  text: string;
}

export const meta = {
  name: "morse",
  category: "type",
  note: "a lamp keying morse, the marks on a tape, letters decoded",
  cols: 64,
  rows: 19,
  fps: 15,
  options: { text: "hello world" },
} satisfies Meta<MorseOptions>;

const CODE: 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: "----.", ".": ".-.-.-",
  ",": "--..--", "?": "..--..", "!": "-.-.--", "'": ".----.", "/": "-..-.", "-": "-....-", "&": ".-...",
};
const UNIT = 0.12; // seconds per dot
const RAMP = " ·:░▒▓█";

export default function morse({ text = meta.options.text }: Partial<MorseOptions> = {}): Frame {
  const { cols, rows } = meta;
  // Lay the message out in dot units: when each mark is on, and where each letter sits.
  const on: number[] = [];
  const letters: [string, number, number][] = [];
  let at = 0;
  for (const ch of String(text).toUpperCase()) {
    const code = CODE[ch];
    if (!code) {
      at += 4; // a word gap is seven, three already follow the letter before
      continue;
    }
    const start = at;
    for (const m of code) {
      const len = m === "." ? 1 : 3;
      for (let i = 0; i < len; i++) on[at + i] = 1;
      at += len + 1;
    }
    letters.push([ch, start, at - 1]);
    at += 2;
  }
  const L = Math.max(at + 12, 20); // a long rest before it starts again
  const lit = (x: number) => on[((x % L) + L) % L] === 1;

  const cx = 31, cy = 6; // the lamp's centre
  const head = cols - 7; // where marks land on the tape
  const tape = 16;
  // Seconds of play already sent at frame 0, chosen so the lamp is lit and the tape full.
  let warm = Math.min(45, L - 1);
  while (!lit(warm) && warm < L * 2) warm++;
  const t0 = (warm + 0.5) * UNIT;

  const grid: string[][] = [];
  for (let r = 0; r < rows; r++) grid.push(new Array<string>(cols).fill(" "));
  const put = (r: number, c: number, s: string) => [...s].forEach((ch, i) => r >= 0 && r < rows && c + i >= 0 && c + i < cols && (grid[r][c + i] = ch));

  return (t, { paper = false } = {}) => {
    const now = (t + t0) / UNIT;
    const unit = Math.floor(now);
    // Full while keyed, with a brief afterglow as the shutter closes.
    const glow = lit(unit) ? 1 : lit(unit - 1) && now - unit < 0.5 ? 0.35 : 0;
    for (const row of grid) row.fill(" ");

    // The lamp face: a lens, a dark gap, then the housing ring.
    for (let r = 0; r < 12; r++) {
      for (let c = cx - 10; c <= cx + 10; c++) {
        const dx = (c - cx) / 2, dy = r - cy;
        const d = Math.hypot(dx, dy);
        if (d <= 2.75) {
          // Lit, the glass is brightest at the centre; dark, only a glint shows.
          const glint = dx < -0.3 && dy < -0.3 && Math.abs(d - 1.9) < 0.45 ? 0.34 : 0;
          const b = glow ? glow * (0.62 + 0.38 * (1 - (d / 2.75) ** 2)) : glint;
          const i = Math.round(b * (RAMP.length - 1));
          grid[r][c] = RAMP[paper ? RAMP.length - 1 - i : i];
        } else if (d > 3.2 && d <= 4.15) grid[r][c] = d > 3.8 ? "▓" : "█";
      }
    }
    // Rays while it shines.
    if (glow) {
      const n = glow === 1 ? 3 : 1;
      for (let k = 0; k < n; k++) {
        put(cy, cx - 13 - 2 * k, "─");
        put(cy, cx + 13 + 2 * k, "─");
        put(cy - 5 - k, cx, "│");
        put(cy - 4 - k, cx - 6 - k, "╲");
        put(cy - 4 - k, cx + 6 + k, "╱");
      }
    }
    // The yoke it swings in, a post and a foot.
    for (let r = cy; r < cy + 5; r++) put(r, cx - 10, "│"), put(r, cx + 10, "│");
    put(cy, cx - 10, "o");
    put(cy, cx + 10, "o");
    put(cy + 5, cx - 10, "╰" + "─".repeat(9) + "┬" + "─".repeat(9) + "╯");
    put(cy + 6, cx, "│");
    put(cy + 7, cx - 5, "▄▄▄▄█▀█▄▄▄▄");

    // The tape: marks behind the head, blank paper ahead of it.
    put(tape - 1, 0, "─".repeat(cols));
    put(tape + 1, 0, "─".repeat(cols));
    for (let c = 0; c <= head; c++) if (lit(unit - (head - c))) grid[tape][c] = "█";
    put(tape - 1, head, "┬");
    put(tape + 1, head, "┴");
    // Each letter under the middle of its group once its last mark is sent.
    const lap = Math.floor(unit / L);
    for (let k = lap - 1; k <= lap; k++) {
      for (const [ch, s, e] of letters) {
        const end = k * L + e;
        if (end > unit - 1) continue;
        put(tape + 2, head - (unit - Math.round(k * L + (s + e) / 2)), ch);
      }
    }
    return grid.map((row) => row.join("")).join("\n");
  };
}