Site: scroll tour with live clips, beat-reactive desk and speaker, road playground, night wave, film on an isometric screen
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const PAGE = "/";
|
||||
|
||||
/** Scrolls to the film and mutes it for autoplay. */
|
||||
async function reach(page: Page): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") errors.push(m.text());
|
||||
});
|
||||
await page.goto(PAGE);
|
||||
await page.locator("#film").scrollIntoViewIfNeeded();
|
||||
await expect(page.locator("#film")).toHaveClass(/seen/);
|
||||
await page.evaluate(() => {
|
||||
const video = document.querySelector<HTMLVideoElement>("#film video")!;
|
||||
video.muted = true;
|
||||
Object.assign(window, { filmVideo: video });
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
const box = (page: Page) => page.locator("#film video").evaluate((v) => v.getBoundingClientRect().toJSON());
|
||||
|
||||
test("the film flattens to play and returns to its face", async ({ page }) => {
|
||||
const errors = await reach(page);
|
||||
await expect(page.locator("#film video")).toHaveAttribute("preload", "metadata");
|
||||
const face = await box(page);
|
||||
const play = page.getByRole("button", { name: "Play the film" });
|
||||
await play.click();
|
||||
await expect.poll(() => page.locator("#film video").evaluate((v: HTMLVideoElement) => v.paused)).toBe(false);
|
||||
await expect.poll(() => page.locator("#film video").evaluate((v) => v.getAnimations().length)).toBe(0);
|
||||
const flat = await box(page);
|
||||
expect(flat.width / flat.height).toBeCloseTo(16 / 9, 1);
|
||||
expect(flat.width).toBeGreaterThan(page.viewportSize()!.width * 0.7);
|
||||
expect(flat.width).toBeGreaterThan(face.width * 1.5);
|
||||
await expect(page.locator("#film video")).toHaveJSProperty("controls", true);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(play).toBeFocused();
|
||||
await expect.poll(() => page.locator("#film video").evaluate((v) => v.getAnimations().length)).toBe(0);
|
||||
const back = await box(page);
|
||||
expect(back.width).toBeCloseTo(face.width, 0);
|
||||
expect(back.height).toBeCloseTo(face.height, 0);
|
||||
const state = await page.evaluate(() => {
|
||||
const video = document.querySelector<HTMLVideoElement>("#film video")!;
|
||||
return { same: video === (window as unknown as { filmVideo: HTMLVideoElement }).filmVideo, paused: video.paused, at: video.currentTime };
|
||||
});
|
||||
expect(state.same).toBe(true);
|
||||
expect(state.paused).toBe(true);
|
||||
expect(state.at).toBeGreaterThan(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("with reduced motion the film plays flat at once", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
const errors = await reach(page);
|
||||
await page.getByRole("button", { name: "Play the film" }).click();
|
||||
const video = page.locator("#film video");
|
||||
expect(await video.evaluate((v) => [v.getAnimations().length, getComputedStyle(v).transform])).toEqual([0, "none"]);
|
||||
await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(false);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "Play the film" })).toBeFocused();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const PAGE = "/";
|
||||
const DESK = { light: "rgb(230, 230, 226)", dark: "rgb(58, 58, 58)" };
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
});
|
||||
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
const open = async (page: Page) => {
|
||||
await page.goto(PAGE);
|
||||
await expect(page.locator(".model .block").first()).toBeAttached();
|
||||
};
|
||||
|
||||
/** The hero desk's top face colour. */
|
||||
const desk = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const top = document.querySelector(".model .block .face.top")!;
|
||||
return getComputedStyle(top).fill;
|
||||
});
|
||||
|
||||
const lamp = (page: Page) => page.getByRole("button", { name: "Switch to dark" });
|
||||
|
||||
test("the lamp turns the page dark in a wave", async ({ page }) => {
|
||||
await open(page);
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("light");
|
||||
expect(await desk(page)).toBe(DESK.light);
|
||||
|
||||
// Read the nearest and farthest hero blocks mid-wave.
|
||||
const mid = await page.evaluate(async () => {
|
||||
const tops = [...document.querySelectorAll<SVGGElement>(".model .block")];
|
||||
document.querySelector<HTMLButtonElement>(".night")!.click();
|
||||
const wave = (g: SVGGElement) => parseFloat(g.style.getPropertyValue("--wave"));
|
||||
tops.sort((a, b) => wave(a) - wave(b));
|
||||
const [near, far] = [tops[0], tops[tops.length - 1]];
|
||||
const fill = (g: SVGGElement) => getComputedStyle(g.querySelector(".face.top")!).fill;
|
||||
// Sample between the near landing and the far start.
|
||||
const gap = (wave(near) + 0.2 + wave(far)) / 2;
|
||||
await new Promise((r) => setTimeout(r, gap * 1000));
|
||||
return {
|
||||
steps: [wave(near), wave(far)],
|
||||
near: [fill(near), near.style.getPropertyValue("--tone-dark")],
|
||||
far: [fill(far), far.style.getPropertyValue("--tone")],
|
||||
};
|
||||
});
|
||||
expect(mid.steps[0] + 0.25).toBeLessThan(mid.steps[1]);
|
||||
expect(mid.steps[1]).toBeLessThanOrEqual(1);
|
||||
const rgb = (hex: string) => `rgb(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(", ")})`;
|
||||
expect(mid.near[0]).toBe(rgb(mid.near[1]));
|
||||
expect(mid.far[0]).toBe(rgb(mid.far[1]));
|
||||
|
||||
await expect.poll(() => desk(page), { timeout: 1200 }).toBe(DESK.dark);
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("dark");
|
||||
await expect(page.getByRole("button", { name: "Switch to light" })).toBeAttached();
|
||||
await expect(page.locator(".night-sky")).toHaveCount(0);
|
||||
await expect(page.locator("html")).toHaveCSS("background-color", "rgb(37, 37, 37)");
|
||||
});
|
||||
|
||||
test("dark text keeps 4.5:1 against the page", async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await open(page);
|
||||
const worst = await page.evaluate(() => {
|
||||
const parse = (c: string) => c.match(/[\d.]+/g)!.slice(0, 3).map(Number);
|
||||
const lum = (c: string) => {
|
||||
const [r, g, b] = parse(c).map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const page = lum(getComputedStyle(document.documentElement).backgroundColor);
|
||||
const ratios = [...document.querySelectorAll<HTMLElement>("h1, h2, h3, p, a, li, button")]
|
||||
.filter((el) => el.textContent?.trim() && getComputedStyle(el).color !== "rgba(0, 0, 0, 0)")
|
||||
.map((el) => {
|
||||
const text = lum(getComputedStyle(el).color);
|
||||
return { ratio: (Math.max(text, page) + 0.05) / (Math.min(text, page) + 0.05), text: el.textContent };
|
||||
});
|
||||
return ratios.sort((a, b) => a.ratio - b.ratio)[0];
|
||||
});
|
||||
expect(worst.ratio, String(worst.text)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
test("the system scheme sets the first theme; the lamp's choice stays", async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await open(page);
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("dark");
|
||||
expect(await desk(page)).toBe(DESK.dark);
|
||||
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe("light");
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await lamp(page).click();
|
||||
expect(await desk(page)).toBe(DESK.dark);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await page.reload();
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("dark");
|
||||
});
|
||||
|
||||
test("reduced motion switches at once", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "light" });
|
||||
await open(page);
|
||||
await lamp(page).click();
|
||||
expect(await desk(page)).toBe(DESK.dark);
|
||||
await expect(page.locator(".night-sky")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const TREE = "Open a Markdown file's history in ElyTree.";
|
||||
|
||||
/** Opens the board and waits until it has grown. */
|
||||
async function open(page: Page): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
||||
await page.goto("/");
|
||||
await page.locator("#roads").scrollIntoViewIfNeeded();
|
||||
await expect(page.locator("#roads .stage")).not.toHaveClass(/grow/, { timeout: 10_000 });
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Screen centre of a road cell. */
|
||||
async function cell(page: Page, at: string) {
|
||||
const box = (await page.locator(`#roads [data-cell="${at}"]:not(.file)`).boundingBox())!;
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
const mark = (page: Page) => page.getByRole("button", { name: "ElyMark file" });
|
||||
|
||||
/** Screen point of a grid spot on the desk top. */
|
||||
const desk = (page: Page, x: number, y: number) =>
|
||||
page.evaluate(([x, y]) => {
|
||||
const svg = document.querySelector<SVGSVGElement>("#roads .stage")!;
|
||||
const p = new DOMPoint((x - y) * Math.cos(Math.PI / 6) * 28, ((x + y) / 2 - 0.6) * 28).matrixTransform(svg.getScreenCTM()!);
|
||||
return { x: p.x, y: p.y };
|
||||
}, [x, y]);
|
||||
|
||||
test("dragging Mark's file down its road reaches ElyTree", async ({ page }) => {
|
||||
const errors = await open(page);
|
||||
const from = (await mark(page).boundingBox())!;
|
||||
const to = await cell(page, "14,12");
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x, to.y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect(mark(page)).toHaveAttribute("data-cell", "14,12");
|
||||
await expect(page.locator("#roads .news")).toHaveText(TREE);
|
||||
await expect(page.locator('#roads [data-cell="14,12"]:not(.file)')).toHaveCSS("--tone", "#3d7fe5");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a file dragged onto empty desk stays on its road", async ({ page }) => {
|
||||
const errors = await open(page);
|
||||
const from = (await mark(page).boundingBox())!;
|
||||
const away = await desk(page, 10.5, 8.5);
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(away.x, away.y);
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(mark(page)).toHaveAttribute("data-cell", "5,10");
|
||||
await expect(page.locator("#roads .news")).toHaveText("");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("arrow keys walk Mark's file to ElyTree, then reset", async ({ page }) => {
|
||||
const errors = await open(page);
|
||||
await mark(page).focus();
|
||||
for (const key of ["ArrowDown", "ArrowDown", ...Array(9).fill("ArrowRight")])
|
||||
await page.keyboard.press(key);
|
||||
await expect(mark(page)).toHaveAttribute("data-cell", "14,12");
|
||||
await expect(page.locator("#roads .news")).toHaveText(TREE);
|
||||
await page.getByRole("button", { name: "Put the files back" }).click();
|
||||
await expect(mark(page)).toHaveAttribute("data-cell", "5,10");
|
||||
await expect(page.locator("#roads .news")).toHaveText("");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test.describe("reduced motion", () => {
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
test("files stack at ElyPDF and the line says why", async ({ page }) => {
|
||||
const errors = await open(page);
|
||||
const doc = page.getByRole("button", { name: "ElyDoc file" });
|
||||
await doc.focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
for (let i = 0; i < 13; i++) await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(doc).toHaveAttribute("data-cell", "15,5");
|
||||
await expect(page.locator("#roads .news")).toHaveText("Export to PDF, open it in ElyPDF.");
|
||||
const show = page.getByRole("button", { name: "ElyShow file" });
|
||||
await show.focus();
|
||||
for (const key of ["ArrowDown", "ArrowDown", "ArrowRight", "ArrowRight", "ArrowRight", "ArrowRight", "ArrowRight", "ArrowUp"])
|
||||
await page.keyboard.press(key);
|
||||
await expect(show).toHaveAttribute("data-cell", "15,5");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.goto("/");
|
||||
await page.locator(".tour .model").scrollIntoViewIfNeeded();
|
||||
await button(page).hover();
|
||||
await page.evaluate(() =>
|
||||
Promise.all(document.querySelector(".tour .model")!.getAnimations({ subtree: true }).map((a) => a.finished)),
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
const button = (page: Page) => page.locator(".speaker-button");
|
||||
const paused = (page: Page) => page.evaluate(() => document.querySelector<HTMLAudioElement>(".tour audio")!.paused);
|
||||
|
||||
/** Screen top of each building's first top face. */
|
||||
const tops = (page: Page) =>
|
||||
page.evaluate(
|
||||
(apps) =>
|
||||
apps.map((n) => document.querySelector(`.tour .block[data-part="${n}"] .face.top`)!.getBoundingClientRect().top),
|
||||
APPS,
|
||||
);
|
||||
|
||||
/** Whether anything dances: hops, road light or a moving woofer. */
|
||||
const dancing = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const hops = [...document.querySelectorAll<SVGGElement>(".tour .block")].filter((g) =>
|
||||
g.style.getPropertyValue("--hop"),
|
||||
);
|
||||
const moved = [...document.querySelectorAll<SVGGElement>(".tour .woofer")].filter((g) => g.style.translate);
|
||||
return hops.length + document.querySelectorAll(".tour .pulse").length + moved.length;
|
||||
});
|
||||
|
||||
test("the speaker plays and pauses the music", async ({ page }) => {
|
||||
await expect(button(page)).toHaveAccessibleName("Play music");
|
||||
await button(page).click();
|
||||
await expect.poll(() => paused(page)).toBe(false);
|
||||
await expect(button(page)).toHaveAccessibleName("Pause music");
|
||||
await button(page).click();
|
||||
await expect.poll(() => paused(page)).toBe(true);
|
||||
await expect(button(page)).toHaveAccessibleName("Play music");
|
||||
});
|
||||
|
||||
test("buildings hop on the beat; all six on the bar", async ({ page }) => {
|
||||
const rest = await tops(page);
|
||||
await button(page).click();
|
||||
await page.waitForFunction(
|
||||
([apps, rest]) =>
|
||||
apps.every(
|
||||
(n, i) =>
|
||||
document.querySelector(`.tour .block[data-part="${n}"] .face.top`)!.getBoundingClientRect().top < rest[i] - 10,
|
||||
),
|
||||
[APPS, rest] as const,
|
||||
{ polling: "raf", timeout: 6000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
([apps, rest]) =>
|
||||
apps.filter(
|
||||
(n, i) =>
|
||||
document.querySelector(`.tour .block[data-part="${n}"] .face.top`)!.getBoundingClientRect().top < rest[i] - 10,
|
||||
).length === 1,
|
||||
[APPS, rest] as const,
|
||||
{ polling: "raf", timeout: 6000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("paused, nothing moves", async ({ page }) => {
|
||||
const rest = await tops(page);
|
||||
await button(page).click();
|
||||
await expect.poll(() => dancing(page)).toBeGreaterThan(0);
|
||||
await button(page).click();
|
||||
await expect.poll(() => paused(page)).toBe(true);
|
||||
await expect.poll(() => dancing(page)).toBe(0);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(await dancing(page)).toBe(0);
|
||||
expect(await tops(page)).toEqual(rest);
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("reduced motion", () => {
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
test("music plays, buildings stay, the woofer stays out", async ({ page }) => {
|
||||
const rest = await tops(page);
|
||||
await button(page).click();
|
||||
await expect.poll(() => paused(page)).toBe(false);
|
||||
await page.waitForTimeout(1200);
|
||||
expect(await dancing(page)).toBe(0);
|
||||
expect(await tops(page)).toEqual(rest);
|
||||
const push = await page.locator(".tour .woofer").first().evaluate((g) => getComputedStyle(g).translate);
|
||||
expect(push).not.toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
test("the desk cell under the pointer rises", async ({ page }) => {
|
||||
const at = await page.evaluate(() => {
|
||||
const svg = document.querySelector<SVGSVGElement>(".tour .stage")!;
|
||||
const [cos, sin, unit, top] = [Math.cos(Math.PI / 6), 0.5, 28, 0.6];
|
||||
const p = new DOMPoint((11.5 - 9.5) * cos * unit, ((11.5 + 9.5) * sin - top) * unit).matrixTransform(
|
||||
svg.getScreenCTM()!,
|
||||
);
|
||||
return { x: p.x, y: p.y };
|
||||
});
|
||||
await page.mouse.move(at.x, at.y);
|
||||
await expect(page.locator(".tour .tile")).toHaveCount(1);
|
||||
await page.mouse.move(at.x, at.y - 600);
|
||||
await expect(page.locator(".tour .tile")).toHaveCount(0);
|
||||
});
|
||||
@@ -61,6 +61,7 @@ test.describe("English locale", () => {
|
||||
await page.mouse.move(5, 500);
|
||||
await expect(lang).toHaveCSS("opacity", "0");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(lang).toBeFocused();
|
||||
await expect(lang).toHaveCSS("opacity", "1");
|
||||
});
|
||||
@@ -79,9 +80,9 @@ test.describe("English locale", () => {
|
||||
|
||||
test("links point at the release and the repository", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".region .get")).toHaveCount(APPS.length);
|
||||
for (const app of APPS) {
|
||||
await expect(page.locator(`#${app.toLowerCase()} .get`)).toHaveAttribute("href", dmg(app));
|
||||
await expect(page.locator(".tour .get")).toHaveCount(APPS.length);
|
||||
for (const [i, app] of APPS.entries()) {
|
||||
await expect(page.locator(".tour .get").nth(i)).toHaveAttribute("href", dmg(app));
|
||||
}
|
||||
await expect(page.locator("#download ul a")).toHaveText([...APPS, TEXT.en.source]);
|
||||
for (const [i, app] of APPS.entries()) {
|
||||
@@ -103,17 +104,19 @@ test.describe("English locale", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("region downloads show on hover or focus", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
for (const app of APPS) {
|
||||
const region = page.locator(`#${app.toLowerCase()}`);
|
||||
const get = region.locator(".get");
|
||||
await region.scrollIntoViewIfNeeded();
|
||||
await page.mouse.move(0, 0);
|
||||
test("each app's copy arrives at its anchor; its download shows on intent", async ({ page }) => {
|
||||
for (const [i, app] of APPS.entries()) {
|
||||
await page.goto(`/#${app.toLowerCase()}`);
|
||||
const article = page.locator(".copy article").nth(i);
|
||||
await expect(article).toHaveClass(/current/);
|
||||
await expect(page.locator(".copy article.current")).toHaveCount(1);
|
||||
await expect(page.locator(".rail a").nth(i)).toHaveAttribute("aria-current", "step");
|
||||
const get = article.locator(".get");
|
||||
await page.mouse.move(1000, 5);
|
||||
await expect(get).toHaveCSS("opacity", "0");
|
||||
await region.locator("h2").hover();
|
||||
await article.locator("h2").hover();
|
||||
await expect(get).toHaveCSS("opacity", "1");
|
||||
await page.locator(".lang").hover();
|
||||
await page.mouse.move(1000, 5);
|
||||
await expect(get).toHaveCSS("opacity", "0");
|
||||
await get.focus();
|
||||
await expect(get).toHaveCSS("opacity", "1");
|
||||
|
||||
@@ -5,12 +5,13 @@ const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
|
||||
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
/** Screen top of the building's first block, in SVG pixels. */
|
||||
/** Building top over the desk top, in SVG pixels; camera-free. */
|
||||
const top = (page: Page, name: string) =>
|
||||
page.evaluate((n) => {
|
||||
const svg = document.querySelector<SVGSVGElement>(".hero .stage")!;
|
||||
const svg = document.querySelector<SVGSVGElement>(".model .stage")!;
|
||||
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
|
||||
return (document.querySelector(`.block[data-part="${n}"]`)!.getBoundingClientRect().top + scrollY) / scale;
|
||||
const at = (sel: string) => document.querySelector(sel)!.getBoundingClientRect().top;
|
||||
return (at(`.block[data-part="${n}"]`) - at('.block[data-part=""]')) / scale;
|
||||
}, name);
|
||||
|
||||
/** A point that hits the building; `low` picks the lowest. */
|
||||
@@ -38,7 +39,7 @@ const spot = (page: Page, name: string, low = false) =>
|
||||
[name, low] as const,
|
||||
);
|
||||
|
||||
const caption = (page: Page, name: string) => page.locator(".caption", { hasText: name });
|
||||
const caption = (page: Page, name: string) => page.locator(".tour .caption", { hasText: name });
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
@@ -46,7 +47,7 @@ test.beforeEach(async ({ page }) => {
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".hero .hit-face:not(.ground)").first()).toBeAttached();
|
||||
await expect(page.locator(".model .hit-face:not(.ground)").first()).toBeAttached();
|
||||
});
|
||||
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
@@ -59,7 +60,7 @@ test("six links in reading order; reduced motion skips growth", async ({ page })
|
||||
|
||||
test("focus lifts one cell, shows the name, survives the mouse", async ({ page }) => {
|
||||
const rest = await top(page, "ElyDoc");
|
||||
for (let i = 0; i < 3; i++) await page.keyboard.press("Tab");
|
||||
await page.locator(".doors a").first().focus();
|
||||
await expect(page.locator(".doors a").first()).toBeFocused();
|
||||
expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0);
|
||||
await expect(caption(page, "ElyDoc")).toHaveCSS("opacity", "1");
|
||||
@@ -89,7 +90,7 @@ test("hover at the bottom edge holds still", async ({ page }) => {
|
||||
|
||||
test("the hit owner is the block you see", async ({ page }) => {
|
||||
const wrong = await page.evaluate(() => {
|
||||
const r = document.querySelector(".hero .stage")!.getBoundingClientRect();
|
||||
const r = document.querySelector(".model .stage")!.getBoundingClientRect();
|
||||
const out: string[] = [];
|
||||
const owner = (x: number, y: number) => {
|
||||
const face = document.elementsFromPoint(x, y).find((e) => e.classList.contains("face"));
|
||||
@@ -120,6 +121,8 @@ test("a primary press sinks one cell; only a click starting on it opens", async
|
||||
|
||||
await page.evaluate(() => history.replaceState(null, "", "/"));
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
// The camera settles on the next frame.
|
||||
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const again = await spot(page, "ElyShow");
|
||||
await page.mouse.click(again.x, again.y, { button: "right" });
|
||||
await page.mouse.move(5, 5);
|
||||
@@ -131,7 +134,7 @@ test("a primary press sinks one cell; only a click starting on it opens", async
|
||||
|
||||
test("holding Enter sinks the focused building until release", async ({ page }) => {
|
||||
const rest = await top(page, "ElyDoc");
|
||||
for (let i = 0; i < 3; i++) await page.keyboard.press("Tab");
|
||||
await page.locator(".doors a").first().focus();
|
||||
await page.keyboard.down("Enter");
|
||||
expect(await top(page, "ElyDoc")).toBeCloseTo(rest, 0);
|
||||
await expect(page).toHaveURL(/#elydoc$/);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
});
|
||||
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
const frame = (page: Page) => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
/** Scrolls to `spans` stage heights into the tour. */
|
||||
async function at(page: Page, spans: number) {
|
||||
await page.evaluate((s) => {
|
||||
document.documentElement.style.scrollBehavior = "auto";
|
||||
scrollTo(0, s * document.querySelector<HTMLElement>(".pin")!.clientHeight);
|
||||
}, spans);
|
||||
await frame(page);
|
||||
}
|
||||
|
||||
/** Largest gap in px between clip and face corners. */
|
||||
const drift = (page: Page, i: number) =>
|
||||
page.evaluate((i) => {
|
||||
const video = document.querySelectorAll<HTMLVideoElement>(".clip")[i];
|
||||
const world = video.parentElement!;
|
||||
const host = world.parentElement!.getBoundingClientRect();
|
||||
const m = new DOMMatrix(getComputedStyle(world).transform).multiply(new DOMMatrix(getComputedStyle(video).transform));
|
||||
const [w, h] = [video.offsetWidth, video.offsetHeight];
|
||||
const clip = [[0, 0], [w, 0], [w, h], [0, h]].map(([x, y]) => m.transformPoint(new DOMPoint(x, y)));
|
||||
const row = (k: number) => document.querySelector<SVGGElement>(`.block.slab[data-app="${i}"][data-row="${k}"]`)!;
|
||||
const ctm = row(0).ownerSVGElement!.getScreenCTM()!;
|
||||
const corners = (g: SVGGElement) =>
|
||||
g.querySelector(".face.left")!.getAttribute("points")!.split(" ").map((p) => {
|
||||
const [x, y] = p.split(",").map(Number);
|
||||
return new DOMPoint(x, y).matrixTransform(ctm);
|
||||
});
|
||||
// Left face points: bottom-left, bottom-right, top-right, top-left.
|
||||
const [bl, br] = corners(row(0));
|
||||
const [, , tr, tl] = corners(row(3));
|
||||
const face = [tl, tr, br, bl];
|
||||
return Math.max(...clip.map((p, k) => Math.hypot(p.x + host.left - face[k].x, p.y + host.top - face[k].y)));
|
||||
}, i);
|
||||
|
||||
test("each clip stays on its screen face, held or gliding", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
for (const [i] of APPS.entries()) {
|
||||
for (const s of [i + 1, i + 1.5]) {
|
||||
await at(page, s);
|
||||
await page.waitForTimeout(800);
|
||||
expect(await drift(page, i), `${APPS[i]} at ${s}`).toBeLessThan(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("only the current app's clip loads and plays", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await at(page, 3);
|
||||
const clips = page.locator(".clip");
|
||||
await expect.poll(() => clips.nth(2).evaluate((v: HTMLVideoElement) => !v.paused && v.currentTime > 0)).toBe(true);
|
||||
for (const i of [0, 1, 3, 4, 5]) await expect(clips.nth(i).locator("source")).toHaveCount(0);
|
||||
await at(page, 4);
|
||||
await expect(clips.nth(2).locator("source")).toHaveCount(0);
|
||||
await expect.poll(() => clips.nth(3).evaluate((v: HTMLVideoElement) => !v.paused)).toBe(true);
|
||||
await at(page, 0);
|
||||
await expect(page.locator(".clip source")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the rail scrolls to an app and marks it", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await at(page, 1);
|
||||
await page.locator(".rail a").nth(4).click();
|
||||
await expect(page.locator(".rail a").nth(4)).toHaveAttribute("aria-current", "step");
|
||||
await expect(page.locator(".copy article.current h2")).toHaveText("ElyTree");
|
||||
});
|
||||
|
||||
test.describe("reduced motion", () => {
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
test("clips wait for a press; the camera jumps", async ({ page }) => {
|
||||
await page.goto("/#elyshow");
|
||||
const clip = page.locator(".clip").nth(2);
|
||||
await expect(page.locator(".copy article.current h2")).toHaveText("ElyShow");
|
||||
await expect(clip).toHaveAttribute("poster", "/clips/show.jpg");
|
||||
expect(await clip.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
|
||||
const held = await page.evaluate(() => getComputedStyle(document.querySelector(".world")!).transform);
|
||||
await at(page, 3.4);
|
||||
expect(await page.evaluate(() => getComputedStyle(document.querySelector(".world")!).transform)).toBe(held);
|
||||
await page.locator(".copy article.current .toggle-clip").click();
|
||||
await expect.poll(() => clip.evaluate((v: HTMLVideoElement) => !v.paused)).toBe(true);
|
||||
await expect(page.locator(".copy article.current .toggle-clip")).toHaveText("Pause");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("phone", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("no sideways scroll; copy sits below the stage", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
for (const s of [0, 1, 2.5, 4, 6, 7]) {
|
||||
await at(page, s);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
|
||||
}
|
||||
await at(page, 2);
|
||||
const copy = await page.locator(".copy article.current").boundingBox();
|
||||
expect(copy!.y).toBeGreaterThan(844 * 0.55);
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { order, type Block } from "../src/iso.ts";
|
||||
import { model } from "../src/model.ts";
|
||||
import { slab } from "../src/tour.ts";
|
||||
|
||||
type Box = Required<Block>;
|
||||
const apps = model.slice(1, -1);
|
||||
const all = model.flatMap((p) => p.blocks);
|
||||
const screens = apps.map((p) => slab(p.name));
|
||||
|
||||
const spans = (b: Box) => [
|
||||
[b.x - b.z - b.h, b.x + b.w - b.z],
|
||||
[b.y - b.z - b.h, b.y + b.d - b.z],
|
||||
[b.x - b.y - b.d, b.x + b.w - b.y],
|
||||
];
|
||||
const overlap = (a: Box, b: Box) => spans(a).every(([lo, hi], i) => lo < spans(b)[i][1] && spans(b)[i][0] < hi);
|
||||
const drawn = (list: Box[]) => {
|
||||
const at = order(list);
|
||||
return (b: Box) => at.indexOf(list.indexOf(b));
|
||||
};
|
||||
|
||||
test("the desk and every screen draw without intersections", () => {
|
||||
const list = [...all, ...screens.flat()];
|
||||
assert.equal(order(list).length, list.length);
|
||||
});
|
||||
|
||||
test("nothing covers a screen's face, so its clip shows true", () => {
|
||||
for (const [i, p] of apps.entries()) {
|
||||
const rows = screens[i].filter((b) => b.w > 0.2);
|
||||
const list = [...all, ...screens[i]];
|
||||
const rank = drawn(list);
|
||||
for (const row of rows)
|
||||
for (const b of all) assert.ok(!(overlap(row, b) && rank(b) > rank(row)), `${p.name} covered by ${JSON.stringify(b)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("screens are sixteen by nine", () => {
|
||||
for (const rows of screens) {
|
||||
const face = rows.filter((b) => b.w > 0.2);
|
||||
const h = face.reduce((s, b) => s + b.h, 0);
|
||||
assert.ok(Math.abs(face[0].w / h - 16 / 9) < 1e-9);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds web clips, posters and the launch film.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
rec="$here/out/rec"
|
||||
clips="$here/../public/clips"
|
||||
film="$here/../public/film"
|
||||
mkdir -p "$clips" "$film"
|
||||
|
||||
# name source start end
|
||||
cuts=(
|
||||
"doc doc 3.0 10.6"
|
||||
"table table 16.0 23.6"
|
||||
"show show-play 2.6 9.6"
|
||||
"pdf pdf 8.8 16.3"
|
||||
"tree tree 9.5 16.8"
|
||||
"mark mark 5.4 13.4"
|
||||
)
|
||||
|
||||
fade=0.2
|
||||
size="scale=1280:720:flags=lanczos,fps=30,format=yuv420p"
|
||||
|
||||
for cut in "${cuts[@]}"; do
|
||||
read -r name src start end <<<"$cut"
|
||||
len=$(echo "$end - $start" | bc)
|
||||
at=$(echo "$len - $fade" | bc)
|
||||
one=$(echo "$start + 0.05" | bc)
|
||||
frames=$(echo "$len * 30 / 1" | bc)
|
||||
# Last 0.2 s fades into the first frame.
|
||||
graph="[0:v]trim=start=$start:end=$end,setpts=PTS-STARTPTS,$size[a];
|
||||
[0:v]trim=start=$start:end=$one,setpts=PTS-STARTPTS,$size,trim=end_frame=1,tpad=stop_mode=clone:stop_duration=$fade[b];
|
||||
[a][b]xfade=transition=fade:duration=$fade:offset=$at[v]"
|
||||
ffmpeg -v error -y -i "$rec/$src.mp4" -filter_complex "$graph" -map "[v]" -frames:v "$frames" -an \
|
||||
-c:v libx264 -profile:v high -preset slow -crf 24 -pix_fmt yuv420p \
|
||||
-movflags +faststart "$clips/$name.mp4"
|
||||
ffmpeg -v error -y -i "$clips/$name.mp4" -an \
|
||||
-c:v libvpx-vp9 -crf 36 -b:v 0 -row-mt 1 -deadline good -cpu-used 2 \
|
||||
"$clips/$name.webm"
|
||||
ffmpeg -v error -y -i "$clips/$name.mp4" -frames:v 1 -q:v 4 "$clips/$name.jpg"
|
||||
done
|
||||
|
||||
src="$here/out/ElyOffice-launch.mp4"
|
||||
ffmpeg -v error -y -i "$src" -vf "scale=1920:1080:flags=lanczos,format=yuv420p" \
|
||||
-c:v libx264 -profile:v high -preset slow -crf 23 \
|
||||
-c:a aac -b:a 160k -movflags +faststart "$film/ely-office.mp4"
|
||||
ffmpeg -v error -y -ss 17 -i "$src" -frames:v 1 -q:v 3 "$film/poster.jpg"
|
||||
|
||||
ls -l "$clips" "$film"
|
||||