244 lines
9.8 KiB
TypeScript
244 lines
9.8 KiB
TypeScript
// Draws one film frame with the site's own iso renderer.
|
|
import "../../src/iso.css";
|
|
import { UNIT, block, order, project } from "../../src/iso.ts";
|
|
import { ACCENT, model } from "../../src/model.ts";
|
|
import { type Box, DESK, PAPER, SCREEN, SHEET, lamp, props, screens, tiles } from "./parts.ts";
|
|
import { BAR, BEAT, glide, toward } from "./time.ts";
|
|
import { DESK_END, type Moment, SEED, TURN, moment } from "./timeline.ts";
|
|
import { film, montage, recording, turnedBlock, turnedOrder } from "./turn.ts";
|
|
import { view } from "../opening/plan.ts";
|
|
|
|
export const WIDTH = 1920;
|
|
export const HEIGHT = 1080;
|
|
export const FPS = 30;
|
|
|
|
/** Bars [from, to); plates push into one app. */
|
|
export interface Scene {
|
|
name: string;
|
|
from: number;
|
|
to: number;
|
|
app?: string;
|
|
}
|
|
|
|
const PLATES = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
|
|
export const scenes: Scene[] = [
|
|
{ name: "opening", from: 0, to: 4 },
|
|
{ name: "rise", from: 4, to: 8 },
|
|
...PLATES.map((app, i) => ({ name: `plate-${app.slice(3).toLowerCase()}`, from: 8 + i * 4, to: 9 + i * 4, app })),
|
|
{ name: "files", from: 32, to: 36 },
|
|
{ name: "props", from: 36, to: 40 },
|
|
{ name: "end", from: 40, to: 42 },
|
|
];
|
|
|
|
/** Global frames [first, end) whose time falls in the scene. */
|
|
export function frames(scene: Scene): [number, number] {
|
|
const first = (bar: number) => Math.ceil((bar * BAR * FPS) - 1e-9);
|
|
return [first(scene.from), first(scene.to)];
|
|
}
|
|
|
|
/** Light tone to dark tone; three faces derive from it. */
|
|
const DARK: Record<string, string> = {
|
|
[DESK]: "#3a3a3a",
|
|
[PAPER]: "#5c5c5a",
|
|
[SCREEN]: "#3a3a3c",
|
|
[SHEET]: "#7a7a78",
|
|
[ACCENT]: ACCENT,
|
|
};
|
|
const PAGE = ["#fbfbfa", "#252525"];
|
|
const INK = ["#1d1d1f", "#f5f5f7"];
|
|
const SOFT = ["#6e6e73", "#a1a1a6"];
|
|
|
|
const mix = (a: string, b: string, u: number) =>
|
|
u <= 0 ? a : u >= 1 ? b : `color-mix(in oklab, ${a}, ${b} ${(u * 100).toFixed(2)}%)`;
|
|
|
|
function tone(light: string, dark: number): string {
|
|
const night = DARK[light];
|
|
if (!night) throw new Error(`no dark tone for ${light}`);
|
|
return mix(light, night, dark);
|
|
}
|
|
|
|
/** World to frame pixels: scale, then the origin's pixel. */
|
|
interface Camera {
|
|
s: number;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
const corners = (b: Box): [number, number][] =>
|
|
[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))));
|
|
|
|
/** Frames `boxes` inside w by h, centred at (cx, cy). */
|
|
function fit(boxes: Box[], w: number, h: number, cx: number, cy: number): Camera {
|
|
const all = boxes.flatMap(corners);
|
|
const xs = all.map(([x]) => x);
|
|
const ys = all.map(([, y]) => y);
|
|
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
|
const s = Math.min(w / (x1 - x0), h / (y1 - y0));
|
|
return { s, x: cx - (s * (x0 + x1)) / 2, y: cy - (s * (y0 + y1)) / 2 };
|
|
}
|
|
|
|
/** Eases between cameras by log scale and centre point. */
|
|
function blend(a: Camera, b: Camera, u: number): Camera {
|
|
if (u <= 0) return a;
|
|
if (u >= 1) return b;
|
|
const s = a.s * (b.s / a.s) ** u;
|
|
const ca = [(WIDTH / 2 - a.x) / a.s, (HEIGHT / 2 - a.y) / a.s];
|
|
const cb = [(WIDTH / 2 - b.x) / b.s, (HEIGHT / 2 - b.y) / b.s];
|
|
const [cx, cy] = [ca[0] + (cb[0] - ca[0]) * u, ca[1] + (cb[1] - ca[1]) * u];
|
|
return { s, x: WIDTH / 2 - s * cx, y: HEIGHT / 2 - s * cy };
|
|
}
|
|
|
|
const everything = [...model.flatMap((p) => p.blocks), ...lamp, ...props.flatMap((p) => p.blocks)];
|
|
/** Model under the title; whole model; end card. */
|
|
const hero = fit(everything, 1720, 790, WIDTH / 2, 640);
|
|
const wide = fit(everything, 1800, 960, WIDTH / 2, 545);
|
|
/** End card: below the title, or above the delivered voxel type. */
|
|
const ending = () => (hidden.has("name") ? fit(everything, 1500, 340, WIDTH / 2, 235) : fit(everything, 1440, 600, WIDTH / 2, 575));
|
|
/** The opening's last framing, in this renderer's terms. */
|
|
const opened = (() => {
|
|
const v = view(514);
|
|
const [cx, cy] = project(v.cx, v.cy, 0);
|
|
return { s: v.s / UNIT, x: v.ox - (v.s / UNIT) * cx, y: v.oy - (v.s / UNIT) * cy };
|
|
})();
|
|
const close = props.map((p) => fit(p.blocks, 1200, 640, WIDTH / 2, 420));
|
|
const seed = (() => {
|
|
const [px, py] = project(SEED[0] + 0.5, SEED[1] + 0.5, model[0].blocks[0].h);
|
|
return { s: 10, x: WIDTH / 2 - 10 * px, y: HEIGHT / 2 - 10 * py };
|
|
})();
|
|
|
|
/** The camera for model scenes at time `t`. */
|
|
function camera(t: number): Camera {
|
|
const beat = t / BEAT;
|
|
if (beat < 16) {
|
|
const x = Math.min(beat / (DESK_END + 2), 1);
|
|
const v = 1 - (1 - x) ** 1.6;
|
|
const s = 1 / (1 / seed.s + (1 / hero.s - 1 / seed.s) * v);
|
|
const c = blend(seed, hero, Math.log(s / seed.s) / Math.log(hero.s / seed.s));
|
|
return { ...c, s };
|
|
}
|
|
if (beat < 128) return blend(hero, { ...hero, s: hero.s * 1.05 }, (beat - 16) / 16);
|
|
if (beat < TURN[1]) return blend(montage, wide, glide((beat - TURN[0]) / (TURN[1] - TURN[0])));
|
|
if (beat < 144) return blend(wide, close[0], toward(t, 144, BEAT / 2, glide));
|
|
if (beat < 148) return blend(close[0], close[1], toward(t, 148, BEAT / 2, glide));
|
|
if (beat < 152) return blend(close[1], close[2], toward(t, 152, BEAT / 2, glide));
|
|
return blend(close[2], ending(), toward(t, 160, BAR, glide));
|
|
}
|
|
|
|
/** Pinned face corners: TL, TR, BL, BR of +y side. */
|
|
function face(b: Box): [number, number][] {
|
|
const y = b.y + b.d;
|
|
return [project(b.x, y, b.z + b.h), project(b.x + b.w, y, b.z + b.h), project(b.x, y, b.z), project(b.x + b.w, y, b.z)];
|
|
}
|
|
|
|
/** The flat window the recording lands in; matches edit/plan.json. */
|
|
const WINDOW = { x: 144, y: 40, w: 1632, h: 918 };
|
|
const RECT: [number, number][] = [
|
|
[WINDOW.x, WINDOW.y],
|
|
[WINDOW.x + WINDOW.w, WINDOW.y],
|
|
[WINDOW.x, WINDOW.y + WINDOW.h],
|
|
[WINDOW.x + WINDOW.w, WINDOW.y + WINDOW.h],
|
|
];
|
|
|
|
/** Face bounds in world pixels. */
|
|
function bounds(app: string) {
|
|
const pts = face(screens[app]);
|
|
const xs = pts.map(([x]) => x);
|
|
const ys = pts.map(([, y]) => y);
|
|
return { x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
|
|
}
|
|
|
|
/** Pushes toward the screen, then into it on the last beat. */
|
|
function push(app: string, t: number, beat: number): { cam: Camera; flat: number } {
|
|
const f = bounds(app);
|
|
const [cx, cy] = [(f.x0 + f.x1) / 2, (f.y0 + f.y1) / 2];
|
|
const at = (s: number, px: number, py: number): Camera => ({ s, x: px - s * cx, y: py - s * cy });
|
|
const near = Math.min((0.62 * HEIGHT) / (f.y1 - f.y0), 1060 / (f.x1 - f.x0));
|
|
const into = WINDOW.w / (f.x1 - f.x0);
|
|
const start = opened;
|
|
const aim = at(near, 1250, 520);
|
|
const full = at(into, WINDOW.x + WINDOW.w / 2, WINDOW.y + WINDOW.h / 2);
|
|
const flat = toward(t, beat + 4, BEAT, glide);
|
|
const cam = flat > 0 ? blend(aim, full, flat) : blend(start, aim, toward(t, beat + 3, 3 * BEAT, glide));
|
|
return { cam, flat };
|
|
}
|
|
|
|
const svg = document.querySelector<SVGSVGElement>("#frame");
|
|
const world = document.querySelector<SVGGElement>("#world");
|
|
if (!svg || !world) throw new Error("index.html lacks #frame or #world");
|
|
|
|
/** Draws the moment; leaving slabs carry their recordings. */
|
|
function draw(m: Moment, cam: Camera, f: number): Promise<void>[] {
|
|
const page = mix(PAGE[0], PAGE[1], m.dark);
|
|
document.body.style.background = page;
|
|
svg!.style.background = page;
|
|
world!.setAttribute("transform", `matrix(${cam.s} 0 0 ${cam.s} ${cam.x} ${cam.y})`);
|
|
const loads: Promise<void>[] = [];
|
|
const groups = m.pieces.map(({ box, land, dark }) => {
|
|
const toned = { ...box, tone: tone(box.tone, dark) };
|
|
const g = m.angle ? turnedBlock(toned, m.angle) : block(toned);
|
|
const href = recording(box, land, f);
|
|
if (href) {
|
|
const { el, ready } = film(box, m.angle, href);
|
|
g.append(el);
|
|
loads.push(ready);
|
|
}
|
|
if (tiles.includes(box)) g.classList.add("tile");
|
|
if (land < 1) {
|
|
g.setAttribute("transform", `translate(0 ${(-UNIT * (1 - land)).toFixed(3)})`);
|
|
g.setAttribute("opacity", land.toFixed(4));
|
|
}
|
|
return g;
|
|
});
|
|
const boxes = m.pieces.map((p) => p.box);
|
|
world!.replaceChildren(...(m.angle ? turnedOrder(boxes, m.angle) : order(boxes)).map((i) => groups[i]));
|
|
for (const el of document.querySelectorAll<HTMLElement>("[data-text]")) {
|
|
const shown = hidden.has(el.dataset.text!) ? 0 : (m.texts[el.dataset.text!] ?? 0);
|
|
el.style.opacity = shown.toFixed(4);
|
|
const [day, dark] = el.dataset.soft === undefined ? INK : SOFT;
|
|
el.style.color = mix(day, dark, m.dark);
|
|
}
|
|
return loads;
|
|
}
|
|
|
|
/** Texts another layer draws instead. */
|
|
const hidden = new Set<string>();
|
|
|
|
/** Screen-face corners in frame pixels, and the flattening. */
|
|
export interface Face {
|
|
opacity: number;
|
|
flat: number;
|
|
corners: [number, number][];
|
|
}
|
|
|
|
/** Draws global frame `f` of `scene`; plates return the face. */
|
|
async function render(name: string, f: number): Promise<Face | null> {
|
|
const scene = scenes.find((s) => s.name === name);
|
|
if (!scene) throw new Error(`no scene ${name}`);
|
|
const [first, end] = frames(scene);
|
|
if (f < first || f >= end) throw new Error(`frame ${f} is outside ${name}`);
|
|
const t = f / FPS;
|
|
const app = scene.app;
|
|
const beat = scene.from * 4;
|
|
const m = moment(t, app ? { app, beat: beat + 1 } : undefined);
|
|
const { cam, flat } = app ? push(app, t, beat) : { cam: camera(t), flat: 0 };
|
|
await Promise.all(draw(m, cam, f));
|
|
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
if (!app) return null;
|
|
const piece = m.pieces.find((p) => p.box === screens[app]);
|
|
if (!piece) return { opacity: 0, flat, corners: [] };
|
|
const lift = -UNIT * (1 - piece.land);
|
|
const pinned = face(piece.box).map(([x, y]) => [cam.x + cam.s * x, cam.y + cam.s * (y + lift)]);
|
|
return {
|
|
opacity: piece.land,
|
|
flat,
|
|
corners: pinned.map(([x, y], i) => [x + (RECT[i][0] - x) * flat, y + (RECT[i][1] - y) * flat]),
|
|
};
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
ely: { scenes: Scene[]; frames: typeof frames; render: typeof render; hide: (names: string[]) => void };
|
|
}
|
|
}
|
|
window.ely = { scenes, frames, render, hide: (names) => names.forEach((n) => hidden.add(n)) };
|