Files
ElyOffice/site/src/night.ts
T

164 lines
5.8 KiB
TypeScript

// 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;
}