Files
ElyOffice/site/src/play.ts
T

459 lines
15 KiB
TypeScript

// 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);
}