Files
ElyOffice/site/src/rhythm.ts
T

285 lines
11 KiB
TypeScript

// A desk speaker; the buildings dance to its music.
import "./rhythm.css";
import { block, order, project, UNIT, type Block } from "./iso.ts";
import { ACCENT, model } from "./model.ts";
import { NIGHT } from "./night.ts";
import { slab } from "./tour.ts";
import { TEXT, type Key } from "./text.ts";
type Box = Required<Block>;
const SVG = "http://www.w3.org/2000/svg";
const BEAT = 60 / 112;
const STEP = BEAT / 4;
/** Takeoff on the beat, landing on the eighth. */
const HOP = 2 * STEP;
const COS = Math.cos(Math.PI / 6);
const SIN = Math.sin(Math.PI / 6);
const DESK = model[0].blocks[0];
const TOP = DESK.z + DESK.h;
const APPS = model.slice(1, -1).map((p) => p.name);
const ROADS = model[model.length - 1].blocks;
/** Every box the stage draws: the model and the screens. */
const DRAWN = [...model.flatMap((p) => p.blocks), ...APPS.flatMap(slab)];
/** Cabinet with two cones on its front-left face. */
const CABINET: Box = { x: 13, y: 8, z: TOP, w: 1.4, d: 1.2, h: 1.9, tone: "#3a3a3c" };
const CONE = "#e6e6e2";
const cone = (z: number, s: number, out = 0): Box => ({
x: CABINET.x + (CABINET.w - s) / 2, y: CABINET.y + CABINET.d + out, z: TOP + z, w: s, d: 0.14, h: s, tone: CONE,
});
const WOOFER = cone(0.25, 0.9);
const CAP = cone(0.5, 0.4, 0.14);
const TWEETER = cone(1.35, 0.4);
/** How far the woofer moves at full level, in cells. */
const THROW = 0.2;
/** Cuts a road into `n` cells along one axis. */
function cut(road: Box, n: number, axis: "x" | "y"): Box[] {
const long = axis === "x" ? road.w : road.d;
return Array.from({ length: n }, (_, i) => {
const at = road[axis] + (long * i) / n;
return axis === "x" ? { ...road, x: at, w: long / n } : { ...road, y: at, d: long / n };
});
}
/** Light runs along these cells: to ElyPDF, and to ElyTree. */
const ROUTES = [cut(ROADS[3], 11, "x"), [...cut(ROADS[4], 2, "y"), ...cut(ROADS[5], 3, "x")]];
/** Footprints a desk cell must not touch. */
const TAKEN = [...DRAWN.filter((b) => b !== DESK), CABINET, WOOFER, CAP, TWEETER];
/** Adds the speaker; the desk dances to its music. */
export function rhythm(host: HTMLElement, svg: SVGSVGElement): void {
const specs = locate(svg);
const buildings = APPS.map((name) => {
const blocks = [...svg.querySelectorAll<SVGGElement>(`.block[data-part="${name}"]`)];
if (!blocks.length) throw new Error(`${name} has no blocks; mount after touch()`);
for (const b of blocks) b.classList.add("dance");
return { blocks, lift: 0 };
});
const roads = ROUTES.map((cells) =>
cells.map((cell) => ({ cell, g: owner(specs, ROADS.find((r) => inside(cell, r))!) })),
);
const speaker = [CABINET, WOOFER, CAP, TWEETER].map((spec) => {
const g = block(spec);
paint(g, spec.tone);
g.classList.add("speaker");
insert(svg, specs, g, spec);
return g;
});
const woofer = speaker.slice(1, 3);
for (const g of woofer) g.classList.add("woofer");
const audio = document.createElement("audio");
audio.preload = "none";
for (const [src, type] of [["/music/ely.ogg", "audio/ogg; codecs=opus"], ["/music/ely.m4a", "audio/mp4"]]) {
const source = document.createElement("source");
source.src = src;
source.type = type;
audio.append(source);
}
const button = document.createElement("button");
button.type = "button";
button.className = "speaker-button";
const name = document.createElement("span");
button.append(name);
// The button rides the stage's camera, next to it.
const world = svg.parentElement;
if (!world || getComputedStyle(world).position === "static") throw new Error("stage needs a positioned parent");
host.append(audio);
world.append(button);
const label = (key: Key) => {
name.dataset.t = key;
name.textContent = TEXT[document.documentElement.lang === "zh-CN" ? "zh" : "en"][key];
};
label("play");
const view = svg.viewBox.baseVal;
const corners = [CABINET, CAP].flatMap((b) =>
[0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => project(b.x + i * b.w, b.y + j * b.d, b.z + k * b.h)))),
);
const [xs, ys] = [corners.map((c) => c[0]), corners.map((c) => c[1])];
button.style.left = `${Math.min(...xs) - view.x}px`;
button.style.top = `${Math.min(...ys) - view.y}px`;
button.style.width = `${Math.max(...xs) - Math.min(...xs)}px`;
button.style.height = `${Math.max(...ys) - Math.min(...ys)}px`;
let level: { analyser: AnalyserNode; data: Float32Array<ArrayBuffer>; peak: number } | null = null;
let context: AudioContext | null = null;
button.addEventListener("click", async () => {
if (!audio.paused) return audio.pause();
if (!context) {
context = new AudioContext();
const analyser = context.createAnalyser();
analyser.fftSize = 512;
context.createMediaElementSource(audio).connect(analyser).connect(context.destination);
level = { analyser, data: new Float32Array(analyser.fftSize), peak: 0 };
}
await context.resume();
await audio.play();
});
const still = matchMedia("(prefers-reduced-motion: reduce)");
let frame = 0;
let clock = { media: -1, wall: 0 };
const pulses = ROUTES.map(() => {
const p = document.createElementNS(SVG, "polygon");
p.setAttribute("class", "pulse");
return p;
});
const tick = (now: number) => {
frame = requestAnimationFrame(tick);
// currentTime updates coarsely; extrapolate between updates.
if (audio.currentTime !== clock.media) clock = { media: audio.currentTime, wall: now };
const t = clock.media + (now - clock.wall) / 1000;
const n = Math.floor(t / BEAT);
const into = (t - n * BEAT) / HOP;
const lift = into < 1 ? 4 * into * (1 - into) : 0;
const bar = Math.floor(n / 4);
const who = n % 4 === 0 ? -1 : (bar * 3 + (n % 4) - 1) % APPS.length;
buildings.forEach((b, i) => hop(b, who < 0 || who === i ? lift : 0));
const cell = Math.floor((t - bar * 4 * BEAT) / STEP);
roads.forEach((route, i) => pulse(pulses[i], route[cell] ?? null));
if (!level) throw new Error("playing without an analyser");
level.analyser.getFloatTimeDomainData(level.data);
const peak = level.data.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
level.peak = Math.max(peak, level.peak * 0.8);
for (const g of woofer) push(g, Math.min(1, level.peak * 1.5) * THROW);
};
const run = () => {
const go = !audio.paused && !still.matches && document.visibilityState === "visible";
if (go && !frame) frame = requestAnimationFrame(tick);
if (go) return;
cancelAnimationFrame(frame);
frame = 0;
for (const b of buildings) hop(b, 0);
for (const p of pulses) p.remove();
for (const g of woofer) g.style.removeProperty("translate");
};
const playing = (on: boolean) => {
host.classList.toggle("playing", on);
label(on ? "pause" : "play");
for (const g of [speaker[1], speaker[3]]) paint(g, on ? ACCENT : CONE);
if (on) lay(null);
run();
};
audio.addEventListener("play", () => playing(true));
audio.addEventListener("pause", () => playing(false));
audio.addEventListener("ended", () => playing(false));
still.addEventListener("change", run);
document.addEventListener("visibilitychange", run);
let tile: { at: string; g: SVGGElement | null } = { at: "", g: null };
const lay = (cell: [number, number] | null) => {
const at = cell ? cell.join() : "";
if (at === tile.at) return;
tile.g?.remove();
if (tile.g) specs.delete(tile.g);
tile = { at, g: null };
if (!cell) return;
const spec: Box = { x: cell[0], y: cell[1], z: TOP, w: 1, d: 1, h: 0.12, tone: "#f4f4f2" };
if (spec.x < 0 || spec.y < 0 || spec.x >= DESK.w || spec.y >= DESK.d) return;
if (TAKEN.some((b) => b.x < spec.x + 1 && spec.x < b.x + b.w && b.y < spec.y + 1 && spec.y < b.y + b.d)) return;
tile.g = block(spec);
paint(tile.g, spec.tone);
tile.g.classList.add("tile");
insert(svg, specs, tile.g, spec);
};
svg.addEventListener("pointermove", (e) => {
const ground = e.target instanceof SVGElement && e.target.dataset.part === "";
if (e.pointerType !== "mouse" || !audio.paused || !ground) return lay(null);
const ctm = svg.getScreenCTM();
if (!ctm) throw new Error("stage is not rendered");
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
// Invert the projection on the desk's top plane.
const a = p.x / (COS * UNIT);
const b = (p.y / UNIT + TOP) / SIN;
lay([Math.floor((a + b) / 2), Math.floor((b - a) / 2)]);
});
svg.addEventListener("pointerleave", () => lay(null));
}
/** Sets a block's tone and its night tone. */
function paint(g: SVGGElement, tone: string) {
const dark = NIGHT[tone];
if (!dark) throw new Error(`no night tone for ${tone}`);
g.style.setProperty("--tone", tone);
g.style.setProperty("--tone-dark", dark);
}
/** Lifts a building by `lift` cells. */
function hop(b: { blocks: SVGGElement[]; lift: number }, lift: number) {
if (lift === b.lift) return;
b.lift = lift;
for (const g of b.blocks) {
if (lift) g.style.setProperty("--hop", `${(-lift * UNIT).toFixed(1)}px`);
else g.style.removeProperty("--hop");
}
}
/** Lights one road cell, or none. */
function pulse(p: SVGPolygonElement, at: { cell: Box; g: SVGGElement } | null) {
if (!at) return p.remove();
const { x, y, w, d } = at.cell;
const z = at.cell.z + at.cell.h;
const points = [project(x, y, z), project(x + w, y, z), project(x + w, y + d, z), project(x, y + d, z)];
p.setAttribute("points", points.map(([px, py]) => `${px.toFixed(2)},${py.toFixed(2)}`).join(" "));
if (p.parentNode !== at.g) at.g.append(p);
}
/** Pushes the woofer out along its face normal. */
function push(g: SVGGElement, cells: number) {
g.style.translate = `${(-COS * UNIT * cells).toFixed(2)}px ${(SIN * UNIT * cells).toFixed(2)}px`;
}
function inside(a: Box, b: Box): boolean {
return a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w + 1e-9 && a.y + a.d <= b.y + b.d + 1e-9;
}
/** Matches drawn blocks to model boxes by top face. */
function locate(svg: SVGSVGElement): Map<SVGGElement, Box> {
const near = (pt: DOMPoint, [x, y]: [number, number]) => Math.abs(pt.x - x) < 0.02 && Math.abs(pt.y - y) < 0.02;
const found = new Map<SVGGElement, Box>();
for (const g of svg.querySelectorAll<SVGGElement>(".block")) {
const top = g.querySelector<SVGPolygonElement>(".face.top");
if (!top) throw new Error("block has no top");
const spec = DRAWN.find(
(b) =>
near(top.points[0], project(b.x, b.y, b.z + b.h)) && near(top.points[2], project(b.x + b.w, b.y + b.d, b.z + b.h)),
);
if (!spec) throw new Error("stage has a block the model lacks");
found.set(g, spec);
}
return found;
}
function owner(specs: Map<SVGGElement, Box>, spec: Box): SVGGElement {
const g = [...specs].find(([, s]) => s === spec)?.[0];
if (!g) throw new Error("road is not drawn");
return g;
}
/** Inserts `g` after what it covers, before its coverers. */
function insert(svg: SVGSVGElement, specs: Map<SVGGElement, Box>, g: SVGGElement, spec: Box) {
const drawn = [...svg.querySelectorAll<SVGGElement>(".block")];
let last = -1;
let first = drawn.length;
drawn.forEach((b, i) => {
const s = specs.get(b);
if (!s) throw new Error("stage has an unknown block");
if (order([spec, s])[0] === 1) last = i;
else if (order([s, spec])[0] === 1) first = Math.min(first, i);
});
if (last >= first) throw new Error("no paint slot for the block");
if (last >= 0) drawn[last].after(g);
else drawn[first].before(g);
specs.set(g, spec);
}