split-flap
[github]a departures board whose flaps riffle to each new line
type 61×18 15 fps 6.7 kB
╭───────────────────────────────────────────────────────────╮ │ D E P A R T U R E S 1 4 : 0 3 │ │ ▔ ▔ ▔ ▔ │ ├───────────────────────────────────────────────────────────┤ │ time destination gate remarks │ │ 1 4 : 0 5 L I S B O N A 4 L A S T C A L L │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ │ 1 4 : 1 5 O S L O B 1 2 B O A R D I N G │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ │ 1 4 : 2 0 N A I R O B I C 7 D E L A Y E D │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ │ 1 4 : 3 5 L I M A A 9 O N T I M E │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ │ 1 4 : 4 0 R E Y K J A V I K D 2 1 O N T I M E │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ │ 1 4 : 5 0 S E O U L B 3 O N T I M E │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ ╰───────────────────────────────────────────────────────────╯╭───────────────────────────────────────────────────────────╮ │ D E P A R T U R E S 1 4 : 0 3 │ │ ▔ ▔ ▔ ▔ │ ├───────────────────────────────────────────────────────────┤ │ time destination gate remarks │ │ 1 4 : 0 5 L I S B O N A 4 L A S T C A L L │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ │ 1 4 : 1 5 O S L O B 1 2 B O A R D I N G │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ │ 1 4 : 2 0 N A I R O B I C 7 D E L A Y E D │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ │ 1 4 : 3 5 L I M A A 9 O N T I M E │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ │ 1 4 : 4 0 R E Y K J A V I K D 2 1 O N T I M E │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ │ 1 4 : 5 0 S E O U L B 3 O N T I M E │ │ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ ▔ │ ╰───────────────────────────────────────────────────────────╯
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script>
<ascii-art piece="split-flap" options='{"title":"departures","start":"14:03","flights":[["14:05","lisbon","a4","on time"],["14:15","oslo","b12","on time"],["14:20","nairobi","c7","delayed"],["14:35","lima","a9","on time"],["14:40","reykjavik","d21","on time"],["14:50","seoul","b3","on time"],["15:00","marrakesh","c14","cancelled"],["15:05","helsinki","a2","on time"],["15:20","auckland","d8","on time"],["15:25","kyoto","b16","delayed"],["15:35","cape town","c11","on time"],["15:45","montreal","a6","on time"]]}'></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 { splitFlap } from "ascii.rest/pieces";
<Ascii piece={splitFlap} options={{"title":"departures","start":"14:03","flights":[["14:05","lisbon","a4","on time"],["14:15","oslo","b12","on time"],["14:20","nairobi","c7","delayed"],["14:35","lima","a9","on time"],["14:40","reykjavik","d21","on time"],["14:50","seoul","b3","on time"],["15:00","marrakesh","c14","cancelled"],["15:05","helsinki","a2","on time"],["15:20","auckland","d8","on time"],["15:25","kyoto","b16","delayed"],["15:35","cape town","c11","on time"],["15:45","montreal","a6","on time"]]}} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="split-flap", to fetch the piece only when it mounts.
astro
---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---
<Ascii piece="split-flap" options={{"title":"departures","start":"14:03","flights":[["14:05","lisbon","a4","on time"],["14:15","oslo","b12","on time"],["14:20","nairobi","c7","delayed"],["14:35","lima","a9","on time"],["14:40","reykjavik","d21","on time"],["14:50","seoul","b3","on time"],["15:00","marrakesh","c14","cancelled"],["15:05","helsinki","a2","on time"],["15:20","auckland","d8","on time"],["15:25","kyoto","b16","delayed"],["15:35","cape town","c11","on time"],["15:45","montreal","a6","on time"]]}} /> 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 { splitFlap } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, splitFlap, {"title":"departures","start":"14:03","flights":[["14:05","lisbon","a4","on time"],["14:15","oslo","b12","on time"],["14:20","nairobi","c7","delayed"],["14:35","lima","a9","on time"],["14:40","reykjavik","d21","on time"],["14:50","seoul","b3","on time"],["15:00","marrakesh","c14","cancelled"],["15:05","helsinki","a2","on time"],["15:20","auckland","d8","on time"],["15:25","kyoto","b16","delayed"],["15:35","cape town","c11","on time"],["15:45","montreal","a6","on time"]]}); 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.
/*
* split-flap: an airport departures board. When a flight leaves, every row moves
* up and each flap riffles through its drum, one card at a time, to land.
*/
import type { Frame, Meta } from "../types.ts";
export interface SplitFlapOptions {
[key: string]: unknown;
title: string;
start: string;
flights: [time: string, destination: string, gate: string, remark: string][];
}
export const meta = {
name: "split-flap",
category: "type",
note: "a departures board whose flaps riffle to each new line",
cols: 61,
rows: 18,
fps: 15,
options: {
title: "departures",
start: "14:03", // the clock on the first board
// time, destination, gate, remark; "on time" turns to boarding as the time nears
flights: [
["14:05", "lisbon", "a4", "on time"],
["14:15", "oslo", "b12", "on time"],
["14:20", "nairobi", "c7", "delayed"],
["14:35", "lima", "a9", "on time"],
["14:40", "reykjavik", "d21", "on time"],
["14:50", "seoul", "b3", "on time"],
["15:00", "marrakesh", "c14", "cancelled"],
["15:05", "helsinki", "a2", "on time"],
["15:20", "auckland", "d8", "on time"],
["15:25", "kyoto", "b16", "delayed"],
["15:35", "cape town", "c11", "on time"],
["15:45", "montreal", "a6", "on time"],
],
},
} satisfies Meta<SplitFlapOptions>;
// The cards on each kind of flap's drum, in the order they turn up.
const TEXT = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-/'&";
const DIGITS = " 0123456789";
const STEP = 3; // seconds per board: a departure, then a clock and remarks update
const LEAD = 0.8; // the first flip, after a settled first frame
const RATE = 30; // cards per second
const SHOWN = 6;
const hash = (a: number, b: number, c: number): number => {
let h = Math.imul(a, 374761393) + Math.imul(b, 668265263) + Math.imul(c, 2246822519);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};
const minutes = (s: string): number => {
const m = /^(\d{1,2}):(\d{2})$/.exec(String(s).trim());
return m ? Number(m[1]) * 60 + Number(m[2]) : NaN;
};
const hhmm = (m: number): string => {
m = ((m % 1440) + 1440) % 1440;
return String(Math.floor(m / 60)).padStart(2, "0") + String(m % 60).padStart(2, "0");
};
export default function splitFlap({ title = meta.options.title, start = meta.options.start, flights = meta.options.flights }: Partial<SplitFlapOptions> = {}): Frame {
const { cols, rows } = meta;
let list = (Array.isArray(flights) ? flights : []).filter((f) => Array.isArray(f) && !isNaN(minutes(f[0])));
if (!list.length) list = meta.options.flights;
const n = list.length;
// Times run forward through the list, past midnight if they wrap, and the
// list comes round again one lap later.
const at: number[] = [];
for (const f of list) {
let m = minutes(f[0]);
while (at.length && m < at[at.length - 1]) m += 1440;
at.push(m);
}
const lap = at[n - 1] - at[0] + (n > 1 ? Math.max(5, Math.round((at[n - 1] - at[0]) / (n - 1))) : 30);
const minute = (j: number): number => at[((j % n) + n) % n] + Math.floor(j / n) * lap;
const flight = (j: number) => list[((j % n) + n) % n];
const up = (s: string): string => String(s ?? "").toUpperCase();
// Board s lists flights from s / 2. On even boards one has just left; on odd
// ones the clock has moved on to just before the next goes.
const first = (s: number): number => Math.floor(s / 2);
const opening = minutes(start);
const clock = (s: number): number => (s === 1 && !isNaN(opening) ? opening : s % 2 ? minute(first(s)) - 2 : minute(first(s) - 1) + 1);
// Every run of flaps: [row, first column, tiles, drum, text on board s].
const fields: [row: number, col: number, tiles: number, drum: string, text: (s: number) => string][] = [];
const time = (r: number, c: number, m: (s: number) => number) => fields.push([r, c, 2, DIGITS, (s) => hhmm(m(s)).slice(0, 2)], [r, c + 6, 2, DIGITS, (s) => hhmm(m(s)).slice(2)]);
time(1, cols - 11, clock);
for (let i = 0; i < SHOWN; i++) {
const r = 5 + i * 2;
const live = () => i < n;
const f = (s: number) => flight(first(s) + i);
const remark = (s: number): string => {
const said = up(f(s)[3]).trim();
if (said && said !== "ON TIME") return said;
const wait = minute(first(s) + i) - clock(s);
return wait <= 3 ? "LAST CALL" : wait <= 12 ? "BOARDING" : wait <= 25 ? "GATE OPEN" : "ON TIME";
};
fields.push(
[r, 2, 2, DIGITS, (s) => (live() ? hhmm(minute(first(s) + i)).slice(0, 2) : "")],
[r, 8, 2, DIGITS, (s) => (live() ? hhmm(minute(first(s) + i)).slice(2) : "")],
[r, 14, 9, TEXT, (s) => (live() ? up(f(s)[1]) : "")],
[r, 34, 3, TEXT, (s) => (live() ? up(f(s)[2]) : "")],
[r, 42, 9, TEXT, (s) => (live() ? remark(s) : "")],
);
}
// The fixed parts of the board: frame, labels, colons and the split beneath each tile.
const base: string[][] = [];
for (let r = 0; r < rows; r++) base.push(("│" + " ".repeat(cols - 2) + "│").split(""));
base[0] = ("╭" + "─".repeat(cols - 2) + "╮").split("");
base[3] = ("├" + "─".repeat(cols - 2) + "┤").split("");
base[rows - 1] = ("╰" + "─".repeat(cols - 2) + "╯").split("");
const put = (r: number, c: number, s: string) => [...s].forEach((ch, i) => c + i < cols - 1 && (base[r][c + i] = ch));
put(1, 2, [...up(title).slice(0, 18)].join(" "));
put(4, 2, "time");
put(4, 14, "destination");
put(4, 34, "gate");
put(4, 42, "remarks");
for (const [r, c, len] of fields) for (let i = 0; i < len; i++) base[r + 1][c + 2 * i] = "▔";
for (let i = -1; i < SHOWN; i++) if (i < n) put(i < 0 ? 1 : 5 + i * 2, i < 0 ? cols - 7 : 6, ":");
return (t) => {
const v = t - LEAD + STEP;
const k = Math.floor(v / STEP);
const since = v - k * STEP;
const s = 1 + k;
const grid = base.map((row) => row.slice());
for (const [r, c, len, drum, text] of fields) {
const to = text(s).padEnd(len).slice(0, len);
const from = text(s - 1).padEnd(len).slice(0, len);
for (let i = 0; i < len; i++) {
const a = Math.max(0, drum.indexOf(from[i])), b = Math.max(0, drum.indexOf(to[i]));
let ch = drum[b];
if (a !== b) {
// Each flap starts a touch after its neighbour and turns forward only;
// while it turns, the falling card hangs over the split.
const begin = r * 0.02 + (c + 2 * i) * 0.004 + hash(r, c + i, s) * 0.12;
const flips = Math.floor(Math.max(0, since - begin) * RATE);
const need = (b - a + drum.length) % drum.length;
if (flips < need) {
ch = drum[(a + flips) % drum.length];
if (since >= begin) grid[r + 1][c + 2 * i] = "▀";
}
}
grid[r][c + 2 * i] = ch;
}
}
return grid.map((row) => row.join("")).join("\n");
};
}