Site: scroll tour with live clips, beat-reactive desk and speaker, road playground, night wave, film on an isometric screen
This commit is contained in:
46 files changed
+2708
-169
No files matched your search
@@ -0,0 +1,458 @@
|
||||
// Roads: drag a file from its app to its target.
|
||||
import "./play.css";
|
||||
import { block, order, project, stage, UNIT, type Block } from "./iso.ts";
|
||||
import { ACCENT, model } from "./model.ts";
|
||||
import { said, TEXT, type Key } from "./text.ts";
|
||||
|
||||
type Cell = [number, number];
|
||||
|
||||
const BEAT = 60 / 112;
|
||||
const STEP = BEAT / 4;
|
||||
const TOP = 0.6;
|
||||
const RISE = 0.1;
|
||||
const FILE = 0.56;
|
||||
const THICK = 0.36;
|
||||
const DESK = "#e6e6e2";
|
||||
const PAPER = "#f4f4f2";
|
||||
const ROAD = "#d3d3ce";
|
||||
const ROAD_NIGHT = "#474745";
|
||||
const REACH = 0.9;
|
||||
|
||||
/** Each building's back corner on the board. */
|
||||
const HOMES: Record<string, Cell> = {
|
||||
ElyDoc: [1, 1],
|
||||
ElyTable: [5, 1],
|
||||
ElyShow: [9, 1],
|
||||
ElyPDF: [14, 1],
|
||||
ElyMark: [1, 9],
|
||||
ElyTree: [13, 11],
|
||||
};
|
||||
|
||||
/** Cells along straight runs between corners. */
|
||||
function walk(...corners: Cell[]): Cell[] {
|
||||
const out: Cell[] = [corners[0]];
|
||||
for (const [x, y] of corners.slice(1)) {
|
||||
let [cx, cy] = out[out.length - 1];
|
||||
if (cx !== x && cy !== y) throw new Error("roads turn at right angles");
|
||||
while (cx !== x || cy !== y) {
|
||||
cx += Math.sign(x - cx);
|
||||
cy += Math.sign(y - cy);
|
||||
out.push([cx, cy]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
from: string;
|
||||
to: string;
|
||||
line: Key;
|
||||
path: Cell[];
|
||||
}
|
||||
|
||||
const LINKS: Link[] = [
|
||||
{ from: "ElyDoc", to: "ElyPDF", line: "toPdf", path: walk([2, 5], [2, 6], [15, 6], [15, 5]) },
|
||||
{ from: "ElyTable", to: "ElyPDF", line: "toPdf", path: walk([6, 5], [6, 6], [15, 6], [15, 5]) },
|
||||
{ from: "ElyShow", to: "ElyPDF", line: "toPdf", path: walk([10, 4], [10, 6], [15, 6], [15, 5]) },
|
||||
{ from: "ElyMark", to: "ElyPDF", line: "toPdf", path: walk([5, 10], [5, 6], [15, 6], [15, 5]) },
|
||||
{ from: "ElyMark", to: "ElyTree", line: "toTree", path: walk([5, 10], [5, 12], [14, 12]) },
|
||||
];
|
||||
|
||||
const SOURCES = ["ElyDoc", "ElyTable", "ElyShow", "ElyMark"] as const;
|
||||
const NAMES: Record<(typeof SOURCES)[number], Key> = {
|
||||
ElyDoc: "fileDoc",
|
||||
ElyTable: "fileTable",
|
||||
ElyShow: "fileShow",
|
||||
ElyMark: "fileMark",
|
||||
};
|
||||
|
||||
/** Arrows as screen directions; ties take this grid step. */
|
||||
const ARROWS: Record<string, [number, number, Cell]> = {
|
||||
ArrowRight: [1, 0, [1, 0]],
|
||||
ArrowLeft: [-1, 0, [-1, 0]],
|
||||
ArrowUp: [0, -1, [0, -1]],
|
||||
ArrowDown: [0, 1, [0, 1]],
|
||||
};
|
||||
|
||||
const id = (c: Cell) => `${c[0]},${c[1]}`;
|
||||
const same = (a: Cell, b: Cell) => a[0] === b[0] && a[1] === b[1];
|
||||
const still = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const lang = () => (document.documentElement.lang === "zh-CN" ? "zh" : "en");
|
||||
|
||||
interface File {
|
||||
name: string;
|
||||
start: Cell;
|
||||
cell: Cell;
|
||||
goal: Cell;
|
||||
level: number;
|
||||
done: boolean;
|
||||
wrap: SVGGElement;
|
||||
links: Link[];
|
||||
ways: Set<string>;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
el: SVGGElement;
|
||||
box: () => Required<Block>;
|
||||
}
|
||||
|
||||
/** A building moved home; accent is kept for files. */
|
||||
function placed(name: string): Required<Block>[] {
|
||||
const part = model.find((p) => p.name === name);
|
||||
if (!part) throw new Error(`no building ${name}`);
|
||||
const [x, y] = HOMES[name];
|
||||
const left = Math.min(...part.blocks.map((b) => b.x));
|
||||
const back = Math.min(...part.blocks.map((b) => b.y));
|
||||
const floor = Math.min(...part.blocks.map((b) => b.z));
|
||||
const snap = (v: number) => Math.round(v * 1000) / 1000;
|
||||
return part.blocks.map((b) => ({ ...b, tone: PAPER, x: snap(b.x - left + x), y: snap(b.y - back + y), z: snap(b.z - floor + TOP) }));
|
||||
}
|
||||
|
||||
const fileBox = (f: File): Required<Block> => ({
|
||||
x: f.cell[0] + (1 - FILE) / 2,
|
||||
y: f.cell[1] + (1 - FILE) / 2,
|
||||
z: TOP + RISE + f.level * THICK,
|
||||
w: FILE,
|
||||
d: FILE,
|
||||
h: THICK,
|
||||
tone: ACCENT,
|
||||
});
|
||||
|
||||
/** The roads section: board, files, and one line. */
|
||||
export function playground(): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.id = "roads";
|
||||
section.className = "play";
|
||||
const names = document.createElement("div");
|
||||
names.hidden = true;
|
||||
const hint = said("span", "moveHint");
|
||||
hint.id = "roads-hint";
|
||||
names.append(hint);
|
||||
|
||||
const items: Item[] = [];
|
||||
const add = (spec: Required<Block>) => {
|
||||
const g = block(spec);
|
||||
items.push({ el: g, box: () => spec });
|
||||
return g;
|
||||
};
|
||||
const desk = add({ x: 0, y: 0, z: 0, w: 17.5, d: 13.5, h: TOP, tone: DESK });
|
||||
const buildings = new Map(Object.keys(HOMES).map((n) => [n, placed(n).map(add)]));
|
||||
const roads = new Map<string, SVGGElement>();
|
||||
for (const cell of LINKS.flatMap((l) => l.path)) {
|
||||
if (roads.has(id(cell))) continue;
|
||||
const g = add({ x: cell[0] + 0.05, y: cell[1] + 0.05, z: TOP, w: 0.9, d: 0.9, h: RISE, tone: ROAD });
|
||||
paint(g, ROAD, ROAD_NIGHT);
|
||||
g.dataset.cell = id(cell);
|
||||
roads.set(id(cell), g);
|
||||
}
|
||||
|
||||
const files: File[] = SOURCES.map((name) => {
|
||||
const links = LINKS.filter((l) => l.from === name);
|
||||
const start = links[0].path[0];
|
||||
const wrap = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
wrap.setAttribute("class", "file");
|
||||
wrap.setAttribute("tabindex", "0");
|
||||
wrap.setAttribute("role", "button");
|
||||
const label = said("span", NAMES[name]);
|
||||
label.id = `roads-${name}`;
|
||||
names.append(label);
|
||||
wrap.setAttribute("aria-labelledby", label.id);
|
||||
wrap.setAttribute("aria-describedby", hint.id);
|
||||
const f: File = {
|
||||
name, start, cell: start, goal: start, level: 0, done: false, wrap, links,
|
||||
ways: new Set(links.flatMap((l) => l.path.map(id))),
|
||||
};
|
||||
items.push({ el: wrap, box: () => fileBox(f) });
|
||||
return f;
|
||||
});
|
||||
files.forEach(render);
|
||||
|
||||
const svg = stage(items.flatMap((i) => (i.el.classList.contains("file") ? [] : [i.el])));
|
||||
svg.classList.add("grow");
|
||||
svg.style.setProperty("--step", `${UNIT}px`);
|
||||
const total = timed(desk, buildings, roads, files);
|
||||
|
||||
const board = document.createElement("div");
|
||||
board.className = "board";
|
||||
board.append(svg, ...captions(svg, buildings));
|
||||
|
||||
const news = document.createElement("p");
|
||||
news.className = "news";
|
||||
news.setAttribute("aria-live", "polite");
|
||||
const again = said("button", "putBack");
|
||||
again.className = "again";
|
||||
again.type = "button";
|
||||
again.hidden = true;
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "foot";
|
||||
foot.append(news, again);
|
||||
section.append(said("h2", "roads"), board, foot, names);
|
||||
|
||||
let ready = false;
|
||||
const timers = new Set<number>();
|
||||
const later = (ms: number, run: () => void) => {
|
||||
const t = window.setTimeout(() => {
|
||||
timers.delete(t);
|
||||
run();
|
||||
}, ms);
|
||||
timers.add(t);
|
||||
};
|
||||
const grown = () => {
|
||||
svg.classList.remove("grow");
|
||||
ready = true;
|
||||
};
|
||||
if (still()) grown();
|
||||
const seen = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((e) => e.isIntersecting)) return;
|
||||
seen.disconnect();
|
||||
svg.classList.add("seen");
|
||||
if (!ready) window.setTimeout(grown, total * 1000 + 250);
|
||||
}, { rootMargin: "0px 0px -20% 0px" });
|
||||
seen.observe(section);
|
||||
|
||||
/** Painter order; `fixed` keeps its DOM place and focus. */
|
||||
const arrange = (fixed: SVGGElement | null) => {
|
||||
const els = order(items.map((i) => i.box())).map((i) => items[i].el);
|
||||
const at = fixed ? els.indexOf(fixed) : -1;
|
||||
if (!fixed || at < 0) return svg.append(...els);
|
||||
for (const el of els.slice(0, at)) svg.insertBefore(el, fixed);
|
||||
svg.append(...els.slice(at + 1));
|
||||
};
|
||||
arrange(null);
|
||||
|
||||
let walking: SVGGElement | null = null;
|
||||
const step = (f: File, next: Cell) => {
|
||||
for (const o of files) {
|
||||
if (o === f || !same(o.cell, f.cell) || o.level < f.level) continue;
|
||||
o.level -= 1;
|
||||
render(o);
|
||||
}
|
||||
f.level = files.filter((o) => o !== f && same(o.cell, next)).length;
|
||||
f.cell = next;
|
||||
render(f);
|
||||
arrange(f.wrap);
|
||||
again.hidden = false;
|
||||
if (!still()) {
|
||||
f.wrap.animate([{ translate: `0 -${UNIT}px` }, { translate: "0 0" }], {
|
||||
duration: STEP * 1000,
|
||||
easing: "cubic-bezier(0.2, 0.8, 0.3, 1)",
|
||||
});
|
||||
click();
|
||||
}
|
||||
const link = f.links.find((l) => same(l.path[l.path.length - 1], next));
|
||||
if (link) arrive(f, link);
|
||||
};
|
||||
|
||||
const tick = (f: File) => {
|
||||
walking = null;
|
||||
while (!f.done && !same(f.cell, f.goal)) {
|
||||
step(f, route(f, f.goal)[1]);
|
||||
if (!still() && !f.done && !same(f.cell, f.goal)) {
|
||||
walking = f.wrap;
|
||||
return later(STEP * 1000, () => tick(f));
|
||||
}
|
||||
}
|
||||
};
|
||||
const go = (f: File, goal: Cell) => {
|
||||
if (f.done || same(goal, f.goal)) return;
|
||||
f.goal = goal;
|
||||
if (walking !== f.wrap) tick(f);
|
||||
};
|
||||
|
||||
const arrive = (f: File, link: Link) => {
|
||||
f.done = true;
|
||||
f.goal = f.cell;
|
||||
news.dataset.t = link.line;
|
||||
news.textContent = TEXT[lang()][link.line];
|
||||
link.path.forEach((cell, i) => {
|
||||
const g = roads.get(id(cell))!;
|
||||
const light = () => paint(g, ACCENT, ACCENT);
|
||||
if (still()) light();
|
||||
else later((i + 1) * STEP * 1000, light);
|
||||
});
|
||||
if (still()) return;
|
||||
const house = buildings.get(link.to)!;
|
||||
for (const b of house) b.classList.add("lifted");
|
||||
later(BEAT * 1000, () => house.forEach((b) => b.classList.remove("lifted")));
|
||||
};
|
||||
|
||||
again.addEventListener("click", () => {
|
||||
for (const t of timers) clearTimeout(t);
|
||||
timers.clear();
|
||||
walking = null;
|
||||
for (const f of files) Object.assign(f, { cell: f.start, goal: f.start, level: 0, done: false });
|
||||
files.forEach(render);
|
||||
for (const g of roads.values()) paint(g, ROAD, ROAD_NIGHT);
|
||||
for (const b of buildings.values()) b.forEach((g) => g.classList.remove("lifted"));
|
||||
delete news.dataset.t;
|
||||
news.textContent = "";
|
||||
arrange(null);
|
||||
files[files.length - 1].wrap.focus();
|
||||
again.hidden = true;
|
||||
});
|
||||
|
||||
let drag: { f: File; pointer: number; dx: number; dy: number } | null = null;
|
||||
for (const f of files) {
|
||||
f.wrap.addEventListener("pointerdown", (e) => {
|
||||
if (!ready || f.done || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
f.wrap.focus({ focusVisible: false });
|
||||
wake();
|
||||
svg.setPointerCapture(e.pointerId);
|
||||
const [gx, gy] = grid(svg, e);
|
||||
drag = { f, pointer: e.pointerId, dx: gx - f.cell[0] - 0.5, dy: gy - f.cell[1] - 0.5 };
|
||||
});
|
||||
f.wrap.addEventListener("keydown", (e) => {
|
||||
const arrow = ARROWS[e.key];
|
||||
if (!arrow) return;
|
||||
e.preventDefault();
|
||||
if (!ready || f.done) return;
|
||||
wake();
|
||||
const next = toward(f, f.goal, arrow);
|
||||
if (next) go(f, next);
|
||||
});
|
||||
}
|
||||
svg.addEventListener("pointermove", (e) => {
|
||||
if (!drag || e.pointerId !== drag.pointer) return;
|
||||
const [gx, gy] = grid(svg, e);
|
||||
const near = nearest(drag.f, gx - drag.dx, gy - drag.dy);
|
||||
if (near) go(drag.f, near);
|
||||
});
|
||||
const drop = (e: PointerEvent) => {
|
||||
if (drag && e.pointerId === drag.pointer) drag = null;
|
||||
};
|
||||
svg.addEventListener("pointerup", drop);
|
||||
svg.addEventListener("pointercancel", drop);
|
||||
return section;
|
||||
}
|
||||
|
||||
/** Sets a road cell's day and night tones. */
|
||||
function paint(g: SVGGElement, day: string, night: string): void {
|
||||
g.style.setProperty("--tone", day);
|
||||
g.style.setProperty("--tone-dark", night);
|
||||
}
|
||||
|
||||
/** Rebuilds the file's block at its cell and level. */
|
||||
function render(f: File): void {
|
||||
f.wrap.replaceChildren(block(fileBox(f)));
|
||||
f.wrap.dataset.cell = id(f.cell);
|
||||
}
|
||||
|
||||
/** Landing times: desk, buildings, roads in waves, then files. */
|
||||
function timed(desk: SVGGElement, buildings: Map<string, SVGGElement[]>, roads: Map<string, SVGGElement>, files: File[]): number {
|
||||
let at = BEAT;
|
||||
const land = (gs: SVGGElement[]) => {
|
||||
for (const g of gs) g.style.setProperty("--land", `${at.toFixed(3)}s`);
|
||||
at += STEP;
|
||||
};
|
||||
land([desk]);
|
||||
for (const house of buildings.values()) land(house);
|
||||
const waves = new Map<number, SVGGElement[]>();
|
||||
for (const [key, g] of roads) {
|
||||
const sum = key.split(",").map(Number).reduce((a, b) => a + b);
|
||||
waves.set(sum, [...(waves.get(sum) ?? []), g]);
|
||||
}
|
||||
for (const sum of [...waves.keys()].sort((a, b) => a - b)) land(waves.get(sum)!);
|
||||
land(files.map((f) => f.wrap));
|
||||
return at;
|
||||
}
|
||||
|
||||
/** Building names above their tops, placed in viewBox percent. */
|
||||
function captions(svg: SVGSVGElement, buildings: Map<string, SVGGElement[]>): HTMLElement[] {
|
||||
const view = svg.viewBox.baseVal;
|
||||
return [...buildings.keys()].map((name) => {
|
||||
const blocks = placed(name);
|
||||
const xs = blocks.flatMap((b) => [b.x, b.x + b.w]);
|
||||
const ys = blocks.flatMap((b) => [b.y, b.y + b.d]);
|
||||
const tops = blocks.flatMap((b) => [project(b.x, b.y, b.z + b.h)[1], project(b.x + b.w, b.y, b.z + b.h)[1]]);
|
||||
const [cx] = project((Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2, 0);
|
||||
const p = document.createElement("p");
|
||||
p.className = "caption";
|
||||
p.textContent = name;
|
||||
p.setAttribute("aria-hidden", "true");
|
||||
p.style.left = `${((cx - view.x) / view.width) * 100}%`;
|
||||
p.style.top = `${((Math.min(...tops) - UNIT * 0.5 - view.y) / view.height) * 100}%`;
|
||||
return p;
|
||||
});
|
||||
}
|
||||
|
||||
/** The pointer on the road plane, in grid units. */
|
||||
function grid(svg: SVGSVGElement, e: PointerEvent): [number, number] {
|
||||
const m = svg.getScreenCTM();
|
||||
if (!m) throw new Error("board is not rendered");
|
||||
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse());
|
||||
const [cx, sy] = project(1, 0, 0);
|
||||
const a = p.x / cx;
|
||||
const b = (p.y + (TOP + RISE) * UNIT) / sy;
|
||||
return [(a + b) / 2, (b - a) / 2];
|
||||
}
|
||||
|
||||
/** Nearest road cell to a grid point, if close. */
|
||||
function nearest(f: File, x: number, y: number): Cell | null {
|
||||
let best: Cell | null = null;
|
||||
let far = REACH;
|
||||
for (const key of f.ways) {
|
||||
const cell = key.split(",").map(Number) as Cell;
|
||||
const d = Math.hypot(cell[0] + 0.5 - x, cell[1] + 0.5 - y);
|
||||
if (d < far) [best, far] = [cell, d];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Shortest road from the file to `goal`, ends included. */
|
||||
function route(f: File, goal: Cell): Cell[] {
|
||||
const back = new Map<string, Cell | null>([[id(f.cell), null]]);
|
||||
const queue: Cell[] = [f.cell];
|
||||
while (queue.length) {
|
||||
const at = queue.shift()!;
|
||||
if (same(at, goal)) {
|
||||
const path: Cell[] = [];
|
||||
for (let c: Cell | null = at; c; c = back.get(id(c))!) path.unshift(c);
|
||||
return path;
|
||||
}
|
||||
for (const n of around(f, at)) {
|
||||
if (back.has(id(n))) continue;
|
||||
back.set(id(n), at);
|
||||
queue.push(n);
|
||||
}
|
||||
}
|
||||
throw new Error(`no road from ${id(f.cell)} to ${id(goal)}`);
|
||||
}
|
||||
|
||||
const around = (f: File, [x, y]: Cell): Cell[] =>
|
||||
([[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]] as Cell[]).filter((c) => f.ways.has(id(c)));
|
||||
|
||||
/** Road cell beside `from` that best follows an arrow. */
|
||||
function toward(f: File, from: Cell, [ax, ay, prefer]: [number, number, Cell]): Cell | null {
|
||||
let best: Cell | null = null;
|
||||
let score = 0.1;
|
||||
for (const n of around(f, from)) {
|
||||
const step: Cell = [n[0] - from[0], n[1] - from[1]];
|
||||
const [sx, sy] = project(step[0], step[1], 0);
|
||||
const s = (sx * ax + sy * ay) / Math.hypot(sx, sy) + (same(step, prefer) ? 0.01 : 0);
|
||||
if (s > score) [best, score] = [n, s];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
let audio: AudioContext | null = null;
|
||||
|
||||
/** Starts sound only after the reader touches a file. */
|
||||
function wake(): void {
|
||||
if (!audio && !still()) audio = new AudioContext();
|
||||
}
|
||||
|
||||
/** A short tick for each landing. */
|
||||
function click(): void {
|
||||
if (!audio) return;
|
||||
const t = audio.currentTime;
|
||||
const tone = audio.createOscillator();
|
||||
const gain = audio.createGain();
|
||||
tone.type = "triangle";
|
||||
tone.frequency.value = 1320;
|
||||
gain.gain.setValueAtTime(0.04, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.04);
|
||||
tone.connect(gain).connect(audio.destination);
|
||||
tone.start(t);
|
||||
tone.stop(t + 0.05);
|
||||
}
|
||||
Reference in new issue
Block a user