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,91 @@
|
||||
.film {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2.5rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 9rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.film-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.film-stage .stage {
|
||||
width: min(100%, 52rem, calc(78svh * var(--ratio)));
|
||||
margin: 0 auto;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
.film.flat .stage {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.film .notch {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.film video {
|
||||
position: absolute;
|
||||
display: block;
|
||||
transform-origin: 0 0;
|
||||
object-fit: fill;
|
||||
background: #000;
|
||||
outline: var(--bezel) solid color-mix(in oklab, #3a3a3c, black 9%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.film.flat video {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* The bezel matches the screen's left face at night. */
|
||||
:root[data-theme="dark"] .film video:not(:focus-visible) {
|
||||
outline-color: color-mix(in oklab, #303032, black 9%);
|
||||
}
|
||||
|
||||
.film video:focus-visible {
|
||||
outline-color: var(--accent);
|
||||
}
|
||||
|
||||
/* The film appears once its screen has landed. */
|
||||
.film:not(.seen) video,
|
||||
.film:not(.seen) .film-play {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.film.seen video {
|
||||
animation: shown 0.2s ease-out var(--shown) backwards;
|
||||
}
|
||||
|
||||
@keyframes shown {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.film-play {
|
||||
position: absolute;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.film.flat .film-play {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.film-stage .stage {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.film.seen video {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// The film on a block screen; it flattens to play.
|
||||
import { block, project, stage, UNIT } from "./iso.ts";
|
||||
import { grow } from "./beat.ts";
|
||||
import { ACCENT } from "./model.ts";
|
||||
import { said } from "./text.ts";
|
||||
import "./film.css";
|
||||
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
const PLINTH = "#e6e6e2";
|
||||
const DARK = "#3a3a3c";
|
||||
const MORPH = 520;
|
||||
const EASE = "cubic-bezier(0.2, 0.8, 0.3, 1)";
|
||||
|
||||
/** The screen; the film fills its left face. */
|
||||
const SCREEN = { x: 4, y: 1, z: 1.6, w: 16.8, d: 0.8, h: 9.8, tone: DARK };
|
||||
const BEZEL = 0.4;
|
||||
|
||||
/** The film on the face: corner, across, down. */
|
||||
function face(): [number, number][] {
|
||||
const x = SCREEN.x + BEZEL;
|
||||
const y = SCREEN.y + SCREEN.d;
|
||||
const z = SCREEN.z + SCREEN.h - BEZEL;
|
||||
const corner = project(x, y, z);
|
||||
const across = project(x + 16, y, z);
|
||||
const down = project(x, y, z - 9);
|
||||
return [corner, [across[0] - corner[0], across[1] - corner[1]], [down[0] - corner[0], down[1] - corner[1]]];
|
||||
}
|
||||
|
||||
/** A play triangle on a block's left face. */
|
||||
function notch(g: SVGGElement, x: number, y: number, z: number, w: number, h: number): void {
|
||||
const p = (dx: number, dz: number) => project(x + dx * w, y, z + dz * h).map((v) => v.toFixed(2)).join(",");
|
||||
const tri = document.createElementNS(SVG, "polygon");
|
||||
tri.setAttribute("points", [p(0.32, 0.8), p(0.32, 0.2), p(0.74, 0.5)].join(" "));
|
||||
tri.setAttribute("class", "notch");
|
||||
g.append(tri);
|
||||
}
|
||||
|
||||
function scene(): { svg: SVGSVGElement; prop: SVGGElement } {
|
||||
const plinth = block({ x: 0, y: 0, w: 22, d: 5.2, h: 0.8, tone: PLINTH });
|
||||
const feet = [5.5, 18.5].map((x) => block({ x, y: 1.1, z: 0.8, w: 1, d: 0.6, h: 0.8, tone: DARK }));
|
||||
const screen = block(SCREEN);
|
||||
const spec = { x: 0.6, y: 2.4, z: 0.8, w: 2.4, d: 2.2, h: 1.6, tone: ACCENT };
|
||||
const prop = block(spec);
|
||||
notch(prop, spec.x, spec.y + spec.d, spec.z, spec.w, spec.h);
|
||||
grow([[plinth], [...feet, screen], [prop]]);
|
||||
const svg = stage([plinth, ...feet, screen, prop]);
|
||||
svg.classList.add("grow");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.style.setProperty("--step", `${UNIT * 0.5}px`);
|
||||
svg.style.setProperty("--ratio", String(svg.viewBox.baseVal.width / svg.viewBox.baseVal.height));
|
||||
return { svg, prop };
|
||||
}
|
||||
|
||||
/** A screen on a plinth that becomes a player. */
|
||||
export function film(): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.id = "film";
|
||||
section.className = "film";
|
||||
const { svg, prop } = scene();
|
||||
const land = prop.style.getPropertyValue("--land");
|
||||
section.style.setProperty("--shown", land);
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.className = "film-stage";
|
||||
const video = document.createElement("video");
|
||||
video.src = "/film/ely-office.mp4";
|
||||
video.poster = "/film/poster.jpg";
|
||||
video.preload = "none";
|
||||
video.playsInline = true;
|
||||
const play = said("button", "filmPlay");
|
||||
play.className = "film-play";
|
||||
host.append(svg, video, play);
|
||||
const line = said("p", "filmLine");
|
||||
line.id = "film-line";
|
||||
video.setAttribute("aria-labelledby", line.id);
|
||||
section.append(host, line);
|
||||
|
||||
const reduced = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let flat = false;
|
||||
let onFace = "none";
|
||||
let morph: Animation | undefined;
|
||||
|
||||
const layout = () => {
|
||||
const box = host.getBoundingClientRect();
|
||||
const art = svg.getBoundingClientRect();
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const k = art.width / vb.width;
|
||||
const w = Math.floor(Math.min((box.width * 16) / (16 + 2 * BEZEL), (box.height * 16) / (9 + 2 * BEZEL)));
|
||||
const h = Math.round((w * 9) / 16);
|
||||
const left = (box.width - w) / 2;
|
||||
const top = (box.height - h) / 2;
|
||||
Object.assign(video.style, { width: `${w}px`, height: `${h}px`, left: `${left}px`, top: `${top}px` });
|
||||
video.style.setProperty("--bezel", `${(w * BEZEL) / 16}px`);
|
||||
const [corner, across, down] = face();
|
||||
const ox = art.left - box.left + (corner[0] - vb.x) * k - left;
|
||||
const oy = art.top - box.top + (corner[1] - vb.y) * k - top;
|
||||
onFace = `matrix(${(across[0] * k) / w}, ${(across[1] * k) / w}, ${(down[0] * k) / h}, ${(down[1] * k) / h}, ${ox}, ${oy})`;
|
||||
if (!flat && !morph) video.style.transform = onFace;
|
||||
const p = prop.getBoundingClientRect();
|
||||
Object.assign(play.style, {
|
||||
left: `${p.left - box.left}px`,
|
||||
top: `${p.top - box.top}px`,
|
||||
width: `${p.width}px`,
|
||||
height: `${p.height}px`,
|
||||
});
|
||||
};
|
||||
|
||||
const move = (to: string) => {
|
||||
const from = getComputedStyle(video).transform;
|
||||
morph?.cancel();
|
||||
video.style.transform = to;
|
||||
if (reduced.matches) return;
|
||||
morph = video.animate([{ transform: from }, { transform: to }], { duration: MORPH, easing: EASE });
|
||||
const own = morph;
|
||||
own.onfinish = () => {
|
||||
if (morph !== own) return;
|
||||
morph = undefined;
|
||||
if (!flat) video.style.transform = onFace;
|
||||
};
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (flat) return;
|
||||
flat = true;
|
||||
section.classList.add("flat");
|
||||
video.controls = true;
|
||||
move("none");
|
||||
host.scrollIntoView({ block: "center", behavior: reduced.matches ? "instant" : "smooth" });
|
||||
video.focus({ preventScroll: true });
|
||||
video.play().catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (!flat) return;
|
||||
flat = false;
|
||||
section.classList.remove("flat");
|
||||
video.pause();
|
||||
video.controls = false;
|
||||
move(onFace);
|
||||
play.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
play.addEventListener("click", open);
|
||||
video.addEventListener("click", () => {
|
||||
if (!flat) open();
|
||||
});
|
||||
video.addEventListener("ended", close);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (flat && e.key === "Escape") close();
|
||||
});
|
||||
|
||||
const lift = (up: boolean, press = false) => {
|
||||
prop.classList.toggle("lifted", up);
|
||||
prop.classList.toggle("pressed", up && press);
|
||||
};
|
||||
play.addEventListener("pointerenter", () => lift(true));
|
||||
play.addEventListener("pointerleave", () => lift(play.matches(":focus-visible")));
|
||||
play.addEventListener("focus", () => lift(true));
|
||||
play.addEventListener("blur", () => lift(false));
|
||||
play.addEventListener("pointerdown", () => lift(true, true));
|
||||
play.addEventListener("pointerup", () => lift(true));
|
||||
|
||||
new ResizeObserver(layout).observe(host);
|
||||
new IntersectionObserver(
|
||||
(entries, seen) => {
|
||||
if (!entries.some((e) => e.isIntersecting)) return;
|
||||
svg.classList.add("seen");
|
||||
section.classList.add("seen");
|
||||
video.preload = "metadata";
|
||||
seen.disconnect();
|
||||
},
|
||||
{ rootMargin: "50% 0px" },
|
||||
).observe(section);
|
||||
return section;
|
||||
}
|
||||
+12
-3
@@ -1,15 +1,24 @@
|
||||
/* One colour, three tones; no shadows. */
|
||||
.block {
|
||||
--ink: var(--tone);
|
||||
}
|
||||
|
||||
/* At night a block takes its dark tone. */
|
||||
:root[data-theme="dark"] .block {
|
||||
--ink: var(--tone-dark, var(--tone));
|
||||
}
|
||||
|
||||
.face.top {
|
||||
fill: var(--tone);
|
||||
fill: var(--ink);
|
||||
}
|
||||
|
||||
.face.left,
|
||||
.face.outline {
|
||||
fill: color-mix(in oklab, var(--tone), black 9%);
|
||||
fill: color-mix(in oklab, var(--ink), black 9%);
|
||||
}
|
||||
|
||||
.face.right {
|
||||
fill: color-mix(in oklab, var(--tone), black 20%);
|
||||
fill: color-mix(in oklab, var(--ink), black 20%);
|
||||
}
|
||||
|
||||
/* Each block drops one step and lands on its beat. */
|
||||
|
||||
+25
-29
@@ -1,50 +1,43 @@
|
||||
import "./style.css";
|
||||
import "./iso.css";
|
||||
import { block, stage } from "./iso.ts";
|
||||
import { grow } from "./beat.ts";
|
||||
import "./tour.css";
|
||||
import { tour } from "./tour.ts";
|
||||
import { props, reveal } from "./regions.ts";
|
||||
import { night } from "./night.ts";
|
||||
import { rhythm } from "./rhythm.ts";
|
||||
import { playground } from "./play.ts";
|
||||
import { film } from "./film.ts";
|
||||
import { model } from "./model.ts";
|
||||
import { touch } from "./touch.ts";
|
||||
import { regions, reveal } from "./regions.ts";
|
||||
import { said, speak, type Lang } from "./text.ts";
|
||||
|
||||
const REPO = "https://github.com/ZacharyZhang-NY/ElyOffice";
|
||||
const RELEASE = `${REPO}/releases/download/v0.1.0`;
|
||||
const APPS = model.slice(1, -1).map((p) => p.name);
|
||||
|
||||
const app = document.querySelector<HTMLElement>("#app");
|
||||
if (!app) throw new Error("index.html has no #app");
|
||||
|
||||
const apps = model.slice(1, -1);
|
||||
const built = model.map((part) => part.blocks.map(block));
|
||||
grow(built);
|
||||
const parts = new Map(apps.map((p) => [p.name, built[model.indexOf(p)]]));
|
||||
|
||||
const hero = document.createElement("header");
|
||||
hero.className = "hero";
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = "Ely Office";
|
||||
const host = document.createElement("div");
|
||||
host.className = "model";
|
||||
const svg = stage(built.flat());
|
||||
svg.classList.add("grow", "seen");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
host.append(svg);
|
||||
const github = document.createElement("a");
|
||||
github.href = REPO;
|
||||
github.textContent = "GitHub";
|
||||
hero.append(title, said("p", "tagline"), said("p", "platforms"), github, host);
|
||||
touch(host, svg, parts);
|
||||
hero.append(title, said("p", "tagline"), said("p", "platforms"), github);
|
||||
const { section, host, svg } = tour(hero);
|
||||
rhythm(host, svg);
|
||||
|
||||
const sections = regions();
|
||||
const shelf = props();
|
||||
|
||||
const footer = document.createElement("footer");
|
||||
footer.id = "download";
|
||||
const version = document.createElement("h2");
|
||||
version.textContent = "Ely Office 0.1.0";
|
||||
const list = document.createElement("ul");
|
||||
for (const p of apps) {
|
||||
for (const name of APPS) {
|
||||
const a = document.createElement("a");
|
||||
a.href = `${RELEASE}/${p.name}-0.1.0-arm64.dmg`;
|
||||
a.textContent = p.name;
|
||||
a.href = `${RELEASE}/${name}-0.1.0-arm64.dmg`;
|
||||
a.textContent = name;
|
||||
const li = document.createElement("li");
|
||||
li.append(a);
|
||||
list.append(li);
|
||||
@@ -59,13 +52,16 @@ footer.append(version, said("p", "platforms"), said("p", "needs"), said("p", "un
|
||||
const toggle = said("button", "other");
|
||||
toggle.className = "lang";
|
||||
let lang: Lang = navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
|
||||
toggle.addEventListener("click", () => {
|
||||
lang = lang === "en" ? "zh" : "en";
|
||||
/** Writes the page in `next`; the toggle names the other. */
|
||||
const say = (next: Lang) => {
|
||||
lang = next;
|
||||
speak(lang);
|
||||
});
|
||||
toggle.lang = lang === "en" ? "zh-CN" : "en";
|
||||
};
|
||||
toggle.addEventListener("click", () => say(lang === "en" ? "zh" : "en"));
|
||||
|
||||
const main = document.createElement("main");
|
||||
main.append(...sections);
|
||||
app.append(toggle, hero, main, footer);
|
||||
speak(lang);
|
||||
reveal(sections);
|
||||
main.append(section, playground(), shelf, film());
|
||||
app.append(night(), toggle, main, footer);
|
||||
say(lang);
|
||||
reveal([shelf]);
|
||||
@@ -0,0 +1,73 @@
|
||||
/* Night: soft greys, never black; the accent stays. */
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
color: #f5f5f7;
|
||||
background: #252525;
|
||||
--quiet: #c4c2c0;
|
||||
}
|
||||
|
||||
/* The accent passes 4.5:1 as text only when lighter. */
|
||||
:root[data-theme="dark"] {
|
||||
--link: #78a6ee;
|
||||
}
|
||||
|
||||
/* Faces and words re-tone as the wave reaches them. */
|
||||
.waving .face {
|
||||
transition: fill 0.2s ease-out var(--wave, 0s);
|
||||
}
|
||||
|
||||
.waving :is(h1, h2, h3, p, a, li, button) {
|
||||
transition: color 0.2s ease-out var(--wave, 0s);
|
||||
}
|
||||
|
||||
.night-sky {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.night {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 1.5rem;
|
||||
z-index: 1;
|
||||
padding: 0.25rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.night .stage {
|
||||
width: 2.5rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.night:hover,
|
||||
.night:focus-visible,
|
||||
body:has(.hero:hover) .night {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.night {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.night-name {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.night {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// A desk lamp that turns the page dark and back.
|
||||
import "./night.css";
|
||||
import { block, stage } from "./iso.ts";
|
||||
import { TEXT, type Lang } from "./text.ts";
|
||||
|
||||
const SIXTEENTH = 60 / 112 / 4;
|
||||
/** Wave steps; the last block lands by 1.14 s. */
|
||||
const STEPS = 7;
|
||||
const LAND = 0.2;
|
||||
const KEY = "ely-theme";
|
||||
const PAGE = { light: "#fbfbfa", dark: "#252525" };
|
||||
|
||||
type Theme = keyof typeof PAGE;
|
||||
|
||||
/** Light tone to night tone, by the same three-face rule. */
|
||||
export const NIGHT: Record<string, string> = {
|
||||
"#f4f4f2": "#5c5c5a",
|
||||
"#e6e6e2": "#3a3a3a",
|
||||
"#3a3a3c": "#303032",
|
||||
"#3d7fe5": "#3d7fe5",
|
||||
};
|
||||
const GLOW = "#f2e6c9";
|
||||
const DESK = "#e6e6e2";
|
||||
const PAPER = "#f4f4f2";
|
||||
|
||||
/** Gives every block under `root` its night tone. */
|
||||
function tone(root: ParentNode): void {
|
||||
const blocks = [...root.querySelectorAll<SVGGElement>("g.block")];
|
||||
if (root instanceof SVGGElement && root.matches("g.block")) blocks.push(root);
|
||||
for (const g of blocks) {
|
||||
if (g.style.getPropertyValue("--tone-dark")) continue;
|
||||
const light = g.style.getPropertyValue("--tone").trim().toLowerCase();
|
||||
const dark = NIGHT[light];
|
||||
if (!dark) throw new Error(`no night tone for ${light}`);
|
||||
g.style.setProperty("--tone-dark", dark);
|
||||
}
|
||||
}
|
||||
|
||||
function stored(): Theme | null {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === "dark" || v === "light" ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function keep(theme: Theme): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, theme);
|
||||
} catch {
|
||||
// Storage may be off; the choice lasts this page.
|
||||
}
|
||||
}
|
||||
|
||||
/** Base, post, arm, shade, bulb and a pool of light. */
|
||||
function lamp(): SVGSVGElement {
|
||||
const glow = [
|
||||
block({ x: 0.9, y: 0.1, z: 0.2, w: 0.8, d: 0.8, h: 0.04, tone: DESK }),
|
||||
block({ x: 1.1, y: 0.3, z: 0.95, w: 0.4, d: 0.4, h: 0.35, tone: DESK }),
|
||||
];
|
||||
for (const g of glow) g.style.setProperty("--tone-dark", GLOW);
|
||||
const svg = stage([
|
||||
block({ x: 0, y: 0, w: 1.8, d: 1.8, h: 0.2, tone: DESK }),
|
||||
glow[0],
|
||||
block({ x: 0.2, y: 0.35, z: 0.2, w: 0.35, d: 0.3, h: 2.0, tone: PAPER }),
|
||||
block({ x: 0.55, y: 0.35, z: 1.9, w: 0.75, d: 0.3, h: 0.3, tone: PAPER }),
|
||||
glow[1],
|
||||
block({ x: 0.95, y: 0.15, z: 1.3, w: 0.7, d: 0.7, h: 0.6, tone: PAPER }),
|
||||
]);
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
return svg;
|
||||
}
|
||||
|
||||
const lang = (): Lang => (document.documentElement.lang.startsWith("zh") ? "zh" : "en");
|
||||
|
||||
let run = 0;
|
||||
|
||||
/** Turns the page to `theme`, rippling out from `from`. */
|
||||
function turn(theme: Theme, from: DOMRect | null): void {
|
||||
const root = document.documentElement;
|
||||
const before = root.dataset.theme;
|
||||
if (before === theme) return;
|
||||
const still = !from || !before || matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const id = ++run;
|
||||
root.querySelector(".night-sky")?.remove();
|
||||
if (still) {
|
||||
root.classList.remove("waving");
|
||||
root.style.removeProperty("background-color");
|
||||
root.dataset.theme = theme;
|
||||
return;
|
||||
}
|
||||
const x = from.x + from.width / 2;
|
||||
const y = from.y + from.height / 2;
|
||||
const corner = Math.max(...[0, innerWidth].flatMap((cx) => [0, innerHeight].map((cy) => Math.hypot(cx - x, cy - y))));
|
||||
const all = [...document.querySelectorAll<HTMLElement | SVGElement>("g.block, h1, h2, h3, p, a, li, button")];
|
||||
const away = all.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const seen = r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth;
|
||||
return { el, seen, d: Math.hypot(r.x + r.width / 2 - x, r.y + r.height / 2 - y) };
|
||||
});
|
||||
// The farthest thing in view lands on the last step.
|
||||
const reach = Math.max(1, ...away.filter((a) => a.seen).map((a) => a.d));
|
||||
for (const { el, d } of away) {
|
||||
const step = Math.min(STEPS, Math.round((d / reach) * STEPS));
|
||||
el.style.setProperty("--wave", `${(step * SIXTEENTH).toFixed(3)}s`);
|
||||
}
|
||||
// The new sky spreads at the wave's pace.
|
||||
const sky = document.createElement("div");
|
||||
sky.className = "night-sky";
|
||||
sky.style.background = PAGE[theme];
|
||||
root.style.backgroundColor = PAGE[before as Theme];
|
||||
document.body.append(sky);
|
||||
root.classList.add("waving");
|
||||
root.dataset.theme = theme;
|
||||
const last = STEPS * SIXTEENTH;
|
||||
const span = last + LAND;
|
||||
const circle = (r: number) => ({ clipPath: `circle(${r.toFixed(1)}px at ${x}px ${y}px)` });
|
||||
const grown = sky.animate(
|
||||
[circle(0), { ...circle(Math.min(reach, corner)), offset: last / span }, circle(corner)],
|
||||
{ duration: span * 1000, easing: "linear", fill: "forwards" },
|
||||
);
|
||||
grown.finished.then(() => {
|
||||
if (id !== run) return;
|
||||
sky.remove();
|
||||
root.style.removeProperty("background-color");
|
||||
root.classList.remove("waving");
|
||||
});
|
||||
}
|
||||
|
||||
/** The lamp button; it follows the system until pressed. */
|
||||
export function night(): HTMLElement {
|
||||
const button = document.createElement("button");
|
||||
button.className = "night";
|
||||
const name = document.createElement("span");
|
||||
name.className = "night-name";
|
||||
button.append(lamp(), name);
|
||||
|
||||
const label = () => {
|
||||
const key = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||
name.dataset.t = key;
|
||||
name.textContent = TEXT[lang()][key];
|
||||
};
|
||||
const system = matchMedia("(prefers-color-scheme: dark)");
|
||||
const set = (theme: Theme, from: DOMRect | null) => {
|
||||
turn(theme, from);
|
||||
label();
|
||||
};
|
||||
|
||||
tone(document);
|
||||
new MutationObserver((changes) => {
|
||||
for (const c of changes) for (const n of c.addedNodes) if (n instanceof Element) tone(n);
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
set(stored() ?? (system.matches ? "dark" : "light"), null);
|
||||
system.addEventListener("change", () => stored() || set(system.matches ? "dark" : "light", button.getBoundingClientRect()));
|
||||
button.addEventListener("click", () => {
|
||||
const theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||
keep(theme);
|
||||
set(theme, button.getBoundingClientRect());
|
||||
});
|
||||
return button;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
.play {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4rem;
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
padding: 9rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.play .board {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.play .stage {
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.play .caption {
|
||||
translate: -50% -100%;
|
||||
}
|
||||
|
||||
.play:hover .caption,
|
||||
.play:focus-within .caption {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.play .caption {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.file {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.file:focus-visible .face.outline {
|
||||
stroke: #1d1d1f;
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .file:focus-visible .face.outline {
|
||||
stroke: #f4f4f2;
|
||||
}
|
||||
|
||||
.play .foot {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 1.5rem;
|
||||
min-height: 5.5rem;
|
||||
}
|
||||
|
||||
.again {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 1.0625rem;
|
||||
color: var(--link);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.play:hover .again,
|
||||
.again:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.again {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.again {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+8
-54
@@ -1,38 +1,10 @@
|
||||
// One region per app, then the three props.
|
||||
import { block, screen, stage, type Block } from "./iso.ts";
|
||||
// Three props: offline, open source, local files.
|
||||
import { block, stage } from "./iso.ts";
|
||||
import { grow } from "./beat.ts";
|
||||
import { model, type Part } from "./model.ts";
|
||||
import { said, type Key } from "./text.ts";
|
||||
|
||||
const RELEASE = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/download/v0.1.0";
|
||||
|
||||
const PAPER = "#f4f4f2";
|
||||
const DESK = "#e6e6e2";
|
||||
const SCREEN = "#3a3a3c";
|
||||
const SHOT = [1440, 810];
|
||||
|
||||
/** A part's blocks moved to corner (x, y). */
|
||||
function moved(part: Part, x: number, y: number): Required<Block>[] {
|
||||
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, x: snap(b.x - left + x), y: snap(b.y - back + y), z: snap(b.z - floor + 0.4) }));
|
||||
}
|
||||
|
||||
/** A plinth, the app's screen, and its building. */
|
||||
function scene(part: Part): SVGSVGElement {
|
||||
const plinth = block({ x: 0, y: 0, w: 11.6, d: 5, h: 0.4, tone: DESK });
|
||||
const slab = block({ x: 0.4, y: 1.2, z: 0.4, w: 7.2, d: 0.25, h: 4.05, tone: SCREEN });
|
||||
screen(slab, `/shots/${part.name.slice(3).toLowerCase()}.jpg`, SHOT[0], SHOT[1]);
|
||||
const house = moved(part, 8.4, 1.2).map(block);
|
||||
grow([[plinth], [slab], house]);
|
||||
const svg = stage([plinth, slab, ...house]);
|
||||
svg.classList.add("grow");
|
||||
svg.setAttribute("role", "img");
|
||||
svg.dataset.shot = part.name;
|
||||
return svg;
|
||||
}
|
||||
|
||||
const t = (x: number, y: number, z: number, w: number, d: number, h: number, tone = PAPER) =>
|
||||
block({ x, y, z, w, d, h, tone });
|
||||
@@ -78,30 +50,12 @@ function prop(blocks: SVGGElement[], line: Key): HTMLElement {
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** The app regions and the props row. */
|
||||
export function regions(): HTMLElement[] {
|
||||
const apps = model.slice(1, -1).map((part) => {
|
||||
const section = document.createElement("section");
|
||||
section.id = part.name.toLowerCase();
|
||||
section.className = "region";
|
||||
const h2 = document.createElement("h2");
|
||||
h2.textContent = part.name;
|
||||
const get = said("a", "download");
|
||||
get.className = "get";
|
||||
get.href = `${RELEASE}/${part.name}-0.1.0-arm64.dmg`;
|
||||
const text = document.createElement("div");
|
||||
text.append(h2, said("p", part.name as Key), get);
|
||||
section.append(scene(part), text);
|
||||
return section;
|
||||
});
|
||||
const props = document.createElement("section");
|
||||
props.className = "props";
|
||||
props.append(
|
||||
prop(cable(), "offline"),
|
||||
prop(box(), "open"),
|
||||
prop(folder(), "local"),
|
||||
);
|
||||
return [...apps, props];
|
||||
/** The props row. */
|
||||
export function props(): HTMLElement {
|
||||
const row = document.createElement("section");
|
||||
row.className = "props";
|
||||
row.append(prop(cable(), "offline"), prop(box(), "open"), prop(folder(), "local"));
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Starts each model's growth when it scrolls near. */
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/* The speaker shows on intent and stays while it plays. */
|
||||
.stage .block.speaker {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.model:has(.stage:hover, .speaker-button:hover) .speaker,
|
||||
.model:focus-within .speaker,
|
||||
.model.playing .speaker {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.stage .block.speaker {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.speaker-button {
|
||||
position: absolute;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.speaker-button:focus-visible {
|
||||
outline: 2px solid #3d7fe5;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.speaker-button span {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Buildings hop by their faces; translate stays touch's. */
|
||||
.dance > .face {
|
||||
transform: translateY(var(--hop, 0px));
|
||||
}
|
||||
|
||||
.pulse {
|
||||
fill: color-mix(in oklab, var(--ink), white 60%);
|
||||
}
|
||||
|
||||
/* The desk cell under the pointer rises a little. */
|
||||
.stage .block.tile {
|
||||
animation: none;
|
||||
translate: 0 -6px;
|
||||
transition: translate 0.12s ease-out;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.stage .block.tile {
|
||||
translate: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Still, the woofer stays pushed out while it plays. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stage .block.speaker,
|
||||
.stage .block.tile {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.model.playing .woofer {
|
||||
translate: -4.85px 2.8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// 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);
|
||||
}
|
||||
+6
-63
@@ -2,9 +2,11 @@
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", sans-serif;
|
||||
color: #1d1d1f;
|
||||
background: #fbfbfa;
|
||||
--page: #fbfbfa;
|
||||
background: var(--page);
|
||||
--quiet: #6e6e73;
|
||||
--accent: #3d7fe5;
|
||||
--link: #2a6ad0;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -35,7 +37,7 @@ p {
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -50,7 +52,7 @@ button:focus-visible {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 10rem 1.5rem 9rem;
|
||||
padding: 7rem 1.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -58,10 +60,6 @@ button:focus-visible {
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.hero .model {
|
||||
margin-top: 6rem;
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
@@ -74,51 +72,6 @@ button:focus-visible {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.region {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
align-items: center;
|
||||
gap: 6rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 9rem 1.5rem;
|
||||
}
|
||||
|
||||
.region > svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.region:nth-child(even) {
|
||||
grid-template-columns: 1fr 1.6fr;
|
||||
}
|
||||
|
||||
.region:nth-child(even) > svg {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.region div {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.get {
|
||||
font-size: 1.0625rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.region:hover .get,
|
||||
.region:focus-within .get {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.get {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.props {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -186,23 +139,14 @@ body:has(.hero:hover) .lang {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.region,
|
||||
.props {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3rem;
|
||||
padding: 6rem 1.5rem;
|
||||
}
|
||||
|
||||
.region:nth-child(even) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.region:nth-child(even) > svg {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 7rem 1.5rem 6rem;
|
||||
padding-top: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +179,6 @@ body:has(.hero:hover) .lang {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.caption,
|
||||
.get,
|
||||
.lang {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,23 @@ const en = {
|
||||
source: "Source code",
|
||||
shot: "screenshot",
|
||||
other: "中文",
|
||||
roads: "Drag a file down the road.",
|
||||
fileDoc: "ElyDoc file",
|
||||
fileTable: "ElyTable file",
|
||||
fileShow: "ElyShow file",
|
||||
fileMark: "ElyMark file",
|
||||
moveHint: "Arrow keys move it along the road.",
|
||||
toTree: "Open a Markdown file's history in ElyTree.",
|
||||
toPdf: "Export to PDF, open it in ElyPDF.",
|
||||
putBack: "Put the files back",
|
||||
play: "Play music",
|
||||
pause: "Pause music",
|
||||
filmPlay: "Play the film",
|
||||
filmLine: "90 seconds. Real use, no mockups.",
|
||||
dark: "Switch to dark",
|
||||
light: "Switch to light",
|
||||
clipPlay: "Play",
|
||||
clipPause: "Pause",
|
||||
};
|
||||
|
||||
const zh: typeof en = {
|
||||
@@ -39,6 +56,23 @@ const zh: typeof en = {
|
||||
source: "源代码",
|
||||
shot: "截图",
|
||||
other: "English",
|
||||
roads: "把文件沿着路拖过去。",
|
||||
fileDoc: "ElyDoc 文件",
|
||||
fileTable: "ElyTable 文件",
|
||||
fileShow: "ElyShow 文件",
|
||||
fileMark: "ElyMark 文件",
|
||||
moveHint: "方向键沿路移动它。",
|
||||
toTree: "在 ElyTree 里看这份 Markdown 的历史。",
|
||||
toPdf: "导出 PDF,在 ElyPDF 打开。",
|
||||
putBack: "把文件放回去",
|
||||
play: "播放音乐",
|
||||
pause: "暂停音乐",
|
||||
filmPlay: "播放影片",
|
||||
filmLine: "90 秒,全部是真实操作。",
|
||||
dark: "切换到深色",
|
||||
light: "切换到浅色",
|
||||
clipPlay: "播放",
|
||||
clipPause: "暂停",
|
||||
};
|
||||
|
||||
export type Key = keyof typeof en;
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/* One stage pins while the camera visits each app. */
|
||||
.tour {
|
||||
--span: 100svh;
|
||||
position: relative;
|
||||
height: calc(var(--span) * 8);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
.pin {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: var(--span);
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.pin .hero {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pin .model {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.world {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.world .stage {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.stop {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* Screen rows land on sixteenths as the reader arrives. */
|
||||
.grow .block.slab {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.block.slab {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
translate: 0 calc(-1 * var(--step));
|
||||
transition:
|
||||
opacity 0.1s ease-out,
|
||||
translate 0.1s ease-out,
|
||||
visibility 0s 0.1s;
|
||||
}
|
||||
|
||||
.block.slab.up {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
translate: 0 0;
|
||||
transition:
|
||||
opacity 0.2s cubic-bezier(0.2, 0.8, 0.3, 1) var(--at),
|
||||
translate 0.2s cubic-bezier(0.2, 0.8, 0.3, 1) var(--at);
|
||||
}
|
||||
|
||||
.clip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
object-fit: fill;
|
||||
transform-origin: 0 0;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.1s ease-out,
|
||||
visibility 0s 0.1s;
|
||||
}
|
||||
|
||||
.clip.up {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 0.2s ease-out var(--at);
|
||||
}
|
||||
|
||||
/* The copy owns a plain strip beside the model. */
|
||||
.copy {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
width: 38%;
|
||||
padding: 0 3vw 0 7vw;
|
||||
background: var(--page);
|
||||
translate: -100% 0;
|
||||
transition: translate 0.2s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
|
||||
.touring .copy {
|
||||
translate: 0 0;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--page: #252525;
|
||||
}
|
||||
|
||||
.copy article {
|
||||
display: grid;
|
||||
grid-area: 1 / 1;
|
||||
gap: 1rem;
|
||||
justify-items: start;
|
||||
align-content: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.15s ease-out,
|
||||
visibility 0s 0.15s;
|
||||
}
|
||||
|
||||
.copy article.current {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 0.2s ease-out 0.1s;
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.get,
|
||||
.toggle-clip {
|
||||
font-size: 1.0625rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.toggle-clip {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 300;
|
||||
color: var(--quiet);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy:hover :is(.get, .toggle-clip),
|
||||
.copy article:focus-within :is(.get, .toggle-clip) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rail {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 2rem;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
translate: 0 -50%;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.15s ease-out,
|
||||
visibility 0s 0.15s;
|
||||
}
|
||||
|
||||
.touring .rail {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.rail a {
|
||||
display: block;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.rail .stage {
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.rail .block {
|
||||
transition: translate 0.15s ease-out;
|
||||
}
|
||||
|
||||
.rail a:hover .block,
|
||||
.rail a:focus-visible .block {
|
||||
translate: 0 -4px;
|
||||
}
|
||||
|
||||
.rail a[aria-current] .block {
|
||||
--ink: var(--accent);
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.get,
|
||||
.toggle-clip {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.copy {
|
||||
inset: 58% 0 0;
|
||||
align-items: start;
|
||||
width: auto;
|
||||
padding: 2rem 1.5rem 0;
|
||||
translate: 0 100%;
|
||||
}
|
||||
|
||||
.rail {
|
||||
top: 30%;
|
||||
right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.copy,
|
||||
.block.slab,
|
||||
.block.slab.up,
|
||||
.clip,
|
||||
.clip.up,
|
||||
.copy article,
|
||||
.copy article.current,
|
||||
.rail,
|
||||
.touring .rail,
|
||||
.rail .block,
|
||||
.get,
|
||||
.toggle-clip {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// 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";
|
||||
|
||||
const RELEASE = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/download/v0.1.0";
|
||||
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 = `${RELEASE}/${p.name}-0.1.0-arm64.dmg`;
|
||||
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})`;
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user