// 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 = { 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 = { 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; } interface Item { el: SVGGElement; box: () => Required; } /** A building moved home; accent is kept for files. */ function placed(name: string): Required[] { 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 => ({ 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) => { 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(); 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(); 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, roads: Map, 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(); 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): 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([[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); }