350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
// The camera glides from the desk into each app.
|
|
import { block, project, stage, type Block } from "./iso.ts";
|
|
import { grow } from "./beat.ts";
|
|
import { model } from "./model.ts";
|
|
import { touch } from "./touch.ts";
|
|
import { said, TEXT, type Key } from "./text.ts";
|
|
import { RELEASES } from "./release.ts";
|
|
|
|
const SCREEN = "#3a3a3c";
|
|
const DESK = "#e6e6e2";
|
|
const STEP = 60 / 112 / 4;
|
|
const ROWS = 4;
|
|
/** Share of each span the camera holds still, per side. */
|
|
const HOLD = 0.35;
|
|
const CLIP = [320, 180];
|
|
const NARROW = 760;
|
|
const TOP = model[0].blocks[0].h;
|
|
|
|
const apps = model.slice(1, -1);
|
|
const last = apps.length + 1;
|
|
|
|
/** Each app's screen: corner x, y, post height, width. */
|
|
const SPOTS: Record<string, [number, number, number, number]> = {
|
|
ElyDoc: [0.1, 0, 0.5, 4],
|
|
ElyTable: [4.4, 0, 1.6, 4],
|
|
ElyShow: [10.8, 0.5, 2.2, 4],
|
|
ElyPDF: [12, 2.3, 0.3, 4],
|
|
ElyTree: [5.9, 5.6, 0, 4],
|
|
ElyMark: [0.7, 4.3, 0.1, 4],
|
|
};
|
|
|
|
/** An app's post, then its screen rows, bottom up. */
|
|
export function slab(name: string): Required<Block>[] {
|
|
const spot = SPOTS[name];
|
|
if (!spot) throw new Error(`no screen for ${name}`);
|
|
const [x, y, post, w] = spot;
|
|
const d = 0.2;
|
|
const row = (w * 9) / 16 / ROWS;
|
|
const out: Required<Block>[] = [];
|
|
if (post > 0) out.push({ x: x + w / 2 - 0.1, y, z: TOP, w: 0.2, d, h: post, tone: SCREEN });
|
|
// Each row sits on the last; sums keep faces flush.
|
|
let z = TOP + post;
|
|
for (let i = 0; i < ROWS; i++) {
|
|
out.push({ x, y, z, w, d, h: row, tone: SCREEN });
|
|
z += row;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
type Box = [number, number, number, number];
|
|
|
|
/** Screen bounds of blocks, in SVG units. */
|
|
function bounds(blocks: Required<Block>[]): Box {
|
|
const points = blocks.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 = points.map((p) => p[0]);
|
|
const ys = points.map((p) => p[1]);
|
|
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
|
|
}
|
|
|
|
/** World point c drawn at screen point a, scale s. */
|
|
interface Cam {
|
|
s: number;
|
|
cx: number;
|
|
cy: number;
|
|
ax: number;
|
|
ay: number;
|
|
}
|
|
|
|
/** Fits `box` into the screen rectangle `area`. */
|
|
function fit(box: Box, area: Box, most: number): Cam {
|
|
const s = Math.min((area[2] - area[0]) / (box[2] - box[0]), (area[3] - area[1]) / (box[3] - box[1]), most);
|
|
return { s, cx: (box[0] + box[2]) / 2, cy: (box[1] + box[3]) / 2, ax: (area[0] + area[2]) / 2, ay: (area[1] + area[3]) / 2 };
|
|
}
|
|
|
|
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
|
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
|
|
function between(a: Cam, b: Cam, t: number): Cam {
|
|
return {
|
|
s: Math.exp(mix(Math.log(a.s), Math.log(b.s), t)),
|
|
cx: mix(a.cx, b.cx, t),
|
|
cy: mix(a.cy, b.cy, t),
|
|
ax: mix(a.ax, b.ax, t),
|
|
ay: mix(a.ay, b.ay, t),
|
|
};
|
|
}
|
|
|
|
/** Maps a clip's box onto the screen's left face. */
|
|
function face(rows: Required<Block>[], origin: [number, number]): string {
|
|
const [first, top] = [rows[0], rows[rows.length - 1]];
|
|
const y = first.y + first.d;
|
|
const z = top.z + top.h;
|
|
const corner = project(first.x, y, z);
|
|
const across = project(first.x + first.w, y, z);
|
|
const down = project(first.x, y, first.z);
|
|
const m = [
|
|
(across[0] - corner[0]) / CLIP[0],
|
|
(across[1] - corner[1]) / CLIP[0],
|
|
(down[0] - corner[0]) / CLIP[1],
|
|
(down[1] - corner[1]) / CLIP[1],
|
|
corner[0] - origin[0],
|
|
corner[1] - origin[1],
|
|
];
|
|
return `matrix(${m.join(", ")})`;
|
|
}
|
|
|
|
/** A clip that loads only while its app is current. */
|
|
function clip(name: string, transform: string, lands: number): HTMLVideoElement {
|
|
const video = document.createElement("video");
|
|
video.className = "clip";
|
|
video.muted = true;
|
|
video.defaultMuted = true;
|
|
video.loop = true;
|
|
video.playsInline = true;
|
|
video.preload = "none";
|
|
video.dataset.src = `/clips/${name.slice(3).toLowerCase()}`;
|
|
video.style.transform = transform;
|
|
video.style.setProperty("--at", `${lands.toFixed(3)}s`);
|
|
video.setAttribute("aria-hidden", "true");
|
|
return video;
|
|
}
|
|
|
|
function start(video: HTMLVideoElement): void {
|
|
if (!video.querySelector("source")) {
|
|
for (const kind of ["webm", "mp4"]) {
|
|
const source = document.createElement("source");
|
|
source.src = `${video.dataset.src}.${kind}`;
|
|
source.type = `video/${kind}`;
|
|
video.append(source);
|
|
}
|
|
}
|
|
video.play().catch((e: DOMException) => {
|
|
// A pause during loading interrupts play; that is expected.
|
|
if (e.name !== "AbortError") throw e;
|
|
});
|
|
}
|
|
|
|
function stop(video: HTMLVideoElement): void {
|
|
if (!video.querySelector("source")) return;
|
|
video.pause();
|
|
video.replaceChildren();
|
|
video.load();
|
|
}
|
|
|
|
/** A rail cube for one app. */
|
|
function cube(): SVGSVGElement {
|
|
const svg = stage([block({ x: 0, y: 0, w: 1, d: 1, h: 0.5, tone: DESK })]);
|
|
svg.setAttribute("aria-hidden", "true");
|
|
return svg;
|
|
}
|
|
|
|
const lang = () => (document.documentElement.lang.startsWith("zh") ? "zh" : "en");
|
|
|
|
/** Sets a button's keyed text now and on language change. */
|
|
function label(el: HTMLElement, key: Key): void {
|
|
el.dataset.t = key;
|
|
el.textContent = TEXT[lang()][key];
|
|
}
|
|
|
|
/** The pinned tour, starting with `hero` over the whole desk. */
|
|
export function tour(hero: HTMLElement): { section: HTMLElement; host: HTMLElement; svg: SVGSVGElement } {
|
|
const calm = matchMedia("(prefers-reduced-motion: reduce)");
|
|
const still = () => calm.matches;
|
|
const built = model.map((part) => part.blocks.map(block));
|
|
grow(built);
|
|
const screens = apps.map((p) => slab(p.name));
|
|
const shown = screens.map((list, a) =>
|
|
list.map((b, i) => {
|
|
const g = block(b);
|
|
g.classList.add("slab");
|
|
g.dataset.app = String(a);
|
|
g.dataset.row = String(i - list.length + ROWS);
|
|
g.style.setProperty("--at", `${(i * STEP).toFixed(3)}s`);
|
|
return g;
|
|
}),
|
|
);
|
|
const svg = stage([...built.flat(), ...shown.flat()]);
|
|
svg.classList.add("grow", "seen");
|
|
svg.setAttribute("aria-hidden", "true");
|
|
const view = svg.viewBox.baseVal;
|
|
// Exact size keeps clips on their faces when zoomed.
|
|
svg.style.width = `${view.width}px`;
|
|
svg.style.height = `${view.height}px`;
|
|
const origin: [number, number] = [view.x, view.y];
|
|
|
|
const world = document.createElement("div");
|
|
world.className = "world";
|
|
world.append(svg);
|
|
const host = document.createElement("div");
|
|
host.className = "model";
|
|
host.append(world);
|
|
|
|
// Screens stay out of the hit layer.
|
|
const marks = shown.flat().map((g) => {
|
|
const mark = document.createComment("");
|
|
g.replaceWith(mark);
|
|
return [mark, g] as const;
|
|
});
|
|
touch(host, svg, new Map(apps.map((p, i) => [p.name, built[i + 1]])));
|
|
for (const [mark, g] of marks) mark.replaceWith(g);
|
|
|
|
const clips = apps.map((p, i) => {
|
|
const rows = screens[i].filter((b) => b.w > 0.2);
|
|
return clip(p.name, face(rows, origin), shown[i].length * STEP);
|
|
});
|
|
world.append(...clips);
|
|
|
|
const plays = clips.map((video) => {
|
|
const button = document.createElement("button");
|
|
button.className = "toggle-clip";
|
|
const sync = () => label(button, video.paused ? "clipPlay" : "clipPause");
|
|
video.addEventListener("play", sync);
|
|
video.addEventListener("pause", sync);
|
|
button.addEventListener("click", () => (video.paused ? start(video) : video.pause()));
|
|
video.addEventListener("click", () => (video.paused ? start(video) : video.pause()));
|
|
sync();
|
|
return button;
|
|
});
|
|
|
|
const copy = document.createElement("div");
|
|
copy.className = "copy";
|
|
const articles = apps.map((p, i) => {
|
|
const article = document.createElement("article");
|
|
const h2 = document.createElement("h2");
|
|
h2.textContent = p.name;
|
|
const get = said("a", "download");
|
|
get.className = "get";
|
|
get.href = RELEASES;
|
|
const acts = document.createElement("div");
|
|
acts.className = "acts";
|
|
acts.append(get, plays[i]);
|
|
article.append(h2, said("p", p.name as Key), acts);
|
|
return article;
|
|
});
|
|
copy.append(...articles);
|
|
|
|
const rail = document.createElement("div");
|
|
rail.className = "rail";
|
|
const steps = apps.map((p) => {
|
|
const a = document.createElement("a");
|
|
a.href = `#${p.name.toLowerCase()}`;
|
|
a.setAttribute("aria-label", p.name);
|
|
a.append(cube());
|
|
return a;
|
|
});
|
|
rail.append(...steps);
|
|
|
|
const pin = document.createElement("div");
|
|
pin.className = "pin";
|
|
hero.classList.add("hero");
|
|
pin.append(hero, host, copy, rail);
|
|
const section = document.createElement("section");
|
|
section.className = "tour";
|
|
const stops = apps.map((p, i) => {
|
|
const stop = document.createElement("div");
|
|
stop.id = p.name.toLowerCase();
|
|
stop.className = "stop";
|
|
stop.style.top = `calc(var(--span) * ${i + 1})`;
|
|
return stop;
|
|
});
|
|
section.append(pin, ...stops);
|
|
|
|
const desk = bounds(model.flatMap((p) => p.blocks));
|
|
const homes = apps.map((p, i) => bounds([...p.blocks, ...screens[i]]));
|
|
let top = 0;
|
|
let span = 1;
|
|
let cams: Cam[] = [];
|
|
let current = -1;
|
|
let queued = false;
|
|
|
|
const measure = () => {
|
|
const [w, h] = [pin.clientWidth, pin.clientHeight];
|
|
top = section.getBoundingClientRect().top + scrollY;
|
|
span = h;
|
|
const below = hero.offsetTop + hero.offsetHeight + 32;
|
|
const whole = fit(desk, [24, below, w - 24, h - 32], 2);
|
|
const area: Box = w <= NARROW ? [16, h * 0.04, w - 52, h * 0.56] : [w * 0.42, h * 0.08, w - 88, h * 0.92];
|
|
cams = [whole, ...homes.map((box) => fit(box, area, 8)), whole];
|
|
};
|
|
|
|
const choose = (next: number) => {
|
|
if (next === current) return;
|
|
if (current >= 0) {
|
|
for (const g of shown[current]) g.classList.remove("up");
|
|
clips[current].classList.remove("up");
|
|
stop(clips[current]);
|
|
articles[current].classList.remove("current");
|
|
steps[current].removeAttribute("aria-current");
|
|
}
|
|
current = next;
|
|
pin.classList.toggle("touring", current >= 0);
|
|
if (current < 0) return;
|
|
for (const g of shown[current]) g.classList.add("up");
|
|
for (const i of [current - 1, current, current + 1]) {
|
|
if (clips[i] && !clips[i].poster) clips[i].poster = `${clips[i].dataset.src}.jpg`;
|
|
}
|
|
clips[current].classList.add("up");
|
|
articles[current].classList.add("current");
|
|
steps[current].setAttribute("aria-current", "step");
|
|
if (!still() && !document.hidden) start(clips[current]);
|
|
};
|
|
|
|
const render = () => {
|
|
queued = false;
|
|
// The first measure comes with the first layout.
|
|
if (!cams.length) return;
|
|
const p = Math.min(Math.max((scrollY - top) / span, 0), last);
|
|
const k = Math.min(Math.floor(p), last - 1);
|
|
const f = p - k;
|
|
let cam: Cam;
|
|
if (still()) cam = cams[Math.round(p)];
|
|
// Leaving the hero, the desk moves at once.
|
|
else if (k === 0 && f < 1 - HOLD) cam = between(cams[0], cams[1], ease(f / (1 - HOLD)));
|
|
else if (f <= HOLD) cam = cams[k];
|
|
else if (f >= 1 - HOLD) cam = cams[k + 1];
|
|
else cam = between(cams[k], cams[k + 1], ease((f - HOLD) / (1 - 2 * HOLD)));
|
|
const tx = cam.ax - cam.s * (cam.cx - origin[0]);
|
|
const ty = cam.ay - cam.s * (cam.cy - origin[1]);
|
|
world.style.transform = `matrix(${cam.s}, 0, 0, ${cam.s}, ${tx}, ${ty})`;
|
|
host.dispatchEvent(new Event("moved"));
|
|
const fade = still() ? (p < 0.5 ? 1 : 0) : Math.max(0, 1 - p / HOLD);
|
|
hero.style.opacity = String(fade);
|
|
hero.style.visibility = fade > 0 ? "" : "hidden";
|
|
hero.style.translate = still() ? "" : `0 ${(-p * span * 0.3).toFixed(1)}px`;
|
|
const at = Math.round(p);
|
|
choose(at >= 1 && at < last ? at - 1 : -1);
|
|
};
|
|
|
|
const later = () => {
|
|
if (queued) return;
|
|
queued = true;
|
|
requestAnimationFrame(render);
|
|
};
|
|
addEventListener("scroll", later, { passive: true });
|
|
const sizes = new ResizeObserver(() => {
|
|
measure();
|
|
later();
|
|
});
|
|
sizes.observe(pin);
|
|
sizes.observe(hero);
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (current < 0) return;
|
|
if (document.hidden) clips[current].pause();
|
|
else if (!still()) start(clips[current]);
|
|
});
|
|
return { section, host, svg };
|
|
}
|