website

Personal website
git clone https://github.com/michaelneuper/website.git
README | Files | Log | Refs

commit 5e27370cfe48cb48955c31ff14d4e20112ec0b5f
parent 2ee7df5049ad48d222efe9140e44799180aef1a9
Author: Michael Neuper <michael@michaelneuper.com>
Date:   Sun, 25 Oct 2026 13:51:38 +0200

reduce loading time

Diffstat:
Msrc/scripts/home/projects.js | 18+++++++++++++++++-
Msrc/scripts/home/wall.js | 232++++++++++++++++++++++++++++++++++++++++---------------------------------------
2 files changed, 135 insertions(+), 115 deletions(-)

diff --git a/src/scripts/home/projects.js b/src/scripts/home/projects.js @@ -47,6 +47,19 @@ export function initProjects() { let settle = 0; let unmount = null; + // A demo is built the first time the section comes within a screen of view, + // not on load: the 8-bit computer alone is some 500 elements, and building it + // held up a page that opens on the name. Anything that asks for a project + // (the computer's commands) builds it at once. + let wakeDemos; + const demosNear = new Promise((resolve) => { wakeDemos = resolve; }); + const near = new IntersectionObserver((entries) => { + if (!entries.some((e) => e.isIntersecting)) return; + near.disconnect(); + wakeDemos(); + }, { rootMargin: "100% 0px" }); + near.observe(root); + const mod = (k) => ((k % n) + n) % n; // how far card i is from position p, the short way round: -n/2 to n/2 const offset = (i, p) => mod(i - p + n / 2) - n / 2; @@ -269,6 +282,7 @@ export function initProjects() { box.append(host); row.append(box); try { + await demosNear; const { mount } = await import(`../demos/${demo}.js`); if (shown === i && host.isConnected) unmount = mount(host) ?? null; } catch (err) { @@ -280,7 +294,9 @@ export function initProjects() { // Other parts of the page can open a project by its slug (the computer commands do). on("project", (name) => { const i = items.findIndex((li) => li.dataset.name === name); - if (i >= 0) goTo(i, { now: true }); + if (i < 0) return; + wakeDemos(); + goTo(i, { now: true }); }); // ── the address ────────────────────────────────────────────────────── diff --git a/src/scripts/home/wall.js b/src/scripts/home/wall.js @@ -1,11 +1,15 @@ // The code wall behind the name: three Emacs windows of my C (src/data/wall.js), -// after the wall on hancore.dev. Each window scrolls at its own pace, and now -// and then a ghost evil cursor changes a word, makes a typo, fixes it and undoes -// the change. With a mouse, pointing at a line lights it. The wall is only -// decoration: the canvas is aria-hidden, and without JavaScript the landing -// section is the name and lede on their own. - -import { $, clamp, ease, on, reducedMotion, themeColors, frameWhileVisible, fitCanvas, fontsReady } from "../util.js"; +// after the wall on hancore.dev. Each window scrolls a line at a time at its own +// pace, and now and then a ghost evil cursor changes a word, makes a typo, fixes +// it and undoes the change. With a mouse, pointing at a line lights it. The wall +// is only decoration: the canvas is aria-hidden, and without JavaScript the +// landing section is the name and lede on their own. +// +// It's drawn only when something changes, and only the window that changed: +// a full redraw on every frame kept slower phones busy enough to count against +// the page's load time. + +import { $, clamp, on, reducedMotion, themeColors, frameWhileVisible, fitCanvas, fontsReady } from "../util.js"; import { setSegment } from "../emacs/index.js"; import { WALL } from "../../data/wall.js"; @@ -17,6 +21,7 @@ const GAP = 4; // blank lines between the end of a file and its start again const NARROW = 920; // one window below the width where the nav becomes a menu const FEATHER = 160; // how far the calm patch around the name fades out const CALM = 0.03; // what's left of the wall behind the name +const MASK = 8; // the calm patch is worked out on a grid this many px apart const TAB = 4; // the files' tab-width // doom-gruvbox's font-lock, faint: [colour, alpha] @@ -169,9 +174,8 @@ export async function initWall() { tokens: lines.map((l, i) => tokenize(l, src.inBlock[i])), len: lines.length, top: w.top, - anim: null, period: 2.3 + n * 0.65, - next: 1.5 + n * 0.9, + next: 0, point: w.top + 4, col: 0, mode: "N", @@ -182,15 +186,21 @@ export async function initWall() { let H = 0; let wins = []; let calm = { l: 0, t: 0, r: 0, b: 0 }; + let mask = null; // how much of the wall shows at each point, MASK px apart let wall = 1; // how strong the wall is: fainter with one window, and in light themes let hover = null; // { win, k, idx } - let dirty = true; + const dirty = new Set(); // the windows to draw again // Each window has a modeline at the bottom, except the one window on a small // screen, where it would sit right on top of the page's own. const bodyTop = () => LH + 6; const bodyBottom = () => (wins.length > 1 ? H - LH - 2 : H); const bodyRows = () => Math.max(1, Math.floor((bodyBottom() - bodyTop()) / LH)); + const winOf = (v) => wins.find((w) => w.view === v); + const touch = (v) => { + const win = v && winOf(v); + if (win) dirty.add(win); + }; // How much of the wall shows at (x, y): almost none behind the name and lede. function fadeAt(x, y) { @@ -199,6 +209,23 @@ export async function initWall() { return CALM + (1 - CALM) * smooth(Math.hypot(dx, dy) / FEATHER); } + // The fade as an image, worked out once per layout: each window is drawn at + // full strength and then multiplied by it, which is far cheaper than fading + // every piece of text by where it sits. + function makeMask() { + const mw = Math.ceil(W / MASK) + 1; + const mh = Math.ceil(H / MASK) + 1; + mask = document.createElement("canvas"); + mask.width = mw; + mask.height = mh; + const m = mask.getContext("2d"); + const img = m.createImageData(mw, mh); + for (let y = 0; y < mh; y++) { + for (let x = 0; x < mw; x++) img.data[(y * mw + x) * 4 + 3] = Math.round(255 * fadeAt(x * MASK, y * MASK)); + } + m.putImageData(img, 0, 0); + } + function layout() { W = canvas.clientWidth; H = canvas.clientHeight; @@ -209,8 +236,11 @@ export async function initWall() { const n = W < NARROW ? 1 : 3; const w = (W - (n - 1)) / n; wins = Array.from({ length: n }, (_, i) => ({ x: Math.round(i * (w + 1)), w: Math.round(w), view: views[i] })); - for (const win of wins) keepPointInView(win.view); - dirty = true; + for (const win of wins) { + keepPointInView(win.view); + dirty.add(win); + } + makeMask(); } function keepPointInView(v) { @@ -220,129 +250,111 @@ export async function initWall() { // ── drawing ───────────────────────────────────────────────────────── - // Text in short pieces, so each fades by where it sits. - function run(text, x, y, [colour, a]) { - ctx.fillStyle = themeColors()[colour]; - for (let i = 0; i < text.length; i += 4) { - const piece = text.slice(i, i + 4); - if (!piece.trim()) continue; - const px = x + i * cw; - const f = a * wall * fadeAt(px + piece.length * cw / 2, y); - if (f < 0.015) continue; - ctx.globalAlpha = f; - ctx.fillText(piece, px, y); - } - ctx.globalAlpha = 1; - } - - // A band of colour, faded by where it sits. - function band(x, y, w, h, colour, a) { + function text(s, x, y, [colour, a]) { ctx.fillStyle = themeColors()[colour]; - for (let bx = x; bx < x + w; bx += 16) { - const bw = Math.min(16, x + w - bx); - const f = a * fadeAt(bx + bw / 2, y + h / 2); - if (f < 0.015) continue; - ctx.globalAlpha = f; - ctx.fillRect(bx, y, bw, h); - } - ctx.globalAlpha = 1; + ctx.globalAlpha = a * wall; + ctx.fillText(s, x, y); } function drawWindow(win) { const v = win.view; + const c = themeColors(); const x0 = win.x + cw; + // the window and the divider on its left + const left = win.x ? win.x - 1 : 0; + const right = win.x + win.w; ctx.save(); ctx.beginPath(); - ctx.rect(win.x, 0, win.w, H); + ctx.rect(left, 0, right - left, H); ctx.clip(); + ctx.clearRect(left, 0, right - left, H); + if (win.x) { + ctx.fillStyle = c.rule; + ctx.fillRect(win.x - 1, 0, 1, H); + } // the header line: the file - run(v.src.path, x0, LH / 2 + 3, ["dim", 0.55]); + text(v.src.path, x0, LH / 2 + 3, ["dim", 0.55]); // the text, with relative line numbers as in my config const top = bodyTop(); const bottom = bodyBottom(); - ctx.save(); - ctx.beginPath(); - ctx.rect(win.x, top, win.w, bottom - top); - ctx.clip(); - const first = Math.floor(v.top); - const off = (v.top - first) * LH; const rows = bodyRows(); - for (let row = -1; row <= rows + 1; row++) { - const k = first + row; - const y = top + row * LH - off + LH / 2; + for (let row = 0; row < rows; row++) { + const k = Math.floor(v.top) + row; + const y = top + row * LH + LH / 2; + if (y + LH / 2 > bottom) break; const idx = mod(k, v.len + GAP); - if (hover && hover.win === win && hover.k === k) { - ctx.fillStyle = themeColors().hl; + if (hover?.win === win && hover.k === k) { + ctx.globalAlpha = 1; + ctx.fillStyle = c.hl; ctx.fillRect(win.x, y - LH / 2, win.w, LH); } if (idx >= v.len) continue; const num = k === v.point ? String(v.src.start + idx) : String(Math.abs(k - v.point)); - run(num.padStart(NUM_W - 1), x0, y, k === v.point ? ["fg", 0.55] : ["dim", 0.32]); + text(num.padStart(NUM_W - 1), x0, y, k === v.point ? ["fg", 0.55] : ["dim", 0.32]); let col = NUM_W; for (const [t, kind] of v.tokens[idx]) { - if (kind !== "space") run(t, x0 + col * cw, y, KIND[kind]); + if (kind !== "space") text(t, x0 + col * cw, y, KIND[kind]); col += t.length; } if (ghost.view === v && k === v.point) drawCursor(v, x0 + (NUM_W + v.col) * cw, y, v.text[idx]); } - ctx.restore(); - if (bottom === H) { - ctx.restore(); - return; - } // the window's modeline: the line pointed at, or the cursor's - band(win.x, H - LH, win.w, LH, "bgAlt", 0.7); - const y = H - LH / 2; - const pointed = hover?.win === win; - const line = v.src.start + (pointed ? hover.idx : Math.min(mod(v.point, v.len + GAP), v.len - 1)); - const busy = ghost.view === v; - run(`<${busy ? v.mode : "N"}>`, x0, y, [busy && v.mode === "I" ? "yellow" : "green", 0.6]); - run(v.src.file, x0 + 5 * cw, y, ["fg", 0.55]); - run(`${line}:${busy && !pointed ? v.col : 0}`, x0 + (6 + v.src.file.length) * cw, y, ["dim", 0.5]); - run("C", win.x + win.w - 3 * cw, y, ["blue", 0.55]); + if (bottom < H) { + ctx.globalAlpha = 0.7; + ctx.fillStyle = c.bgAlt; + ctx.fillRect(win.x, H - LH, win.w, LH); + const y = H - LH / 2; + const pointed = hover?.win === win; + const line = v.src.start + (pointed ? hover.idx : Math.min(mod(v.point, v.len + GAP), v.len - 1)); + const busy = ghost.view === v; + text(`<${busy ? v.mode : "N"}>`, x0, y, [busy && v.mode === "I" ? "yellow" : "green", 0.6]); + text(v.src.file, x0 + 5 * cw, y, ["fg", 0.55]); + text(`${line}:${busy && !pointed ? v.col : 0}`, x0 + (6 + v.src.file.length) * cw, y, ["dim", 0.5]); + text("C", win.x + win.w - 3 * cw, y, ["blue", 0.55]); + } + + // then fade it all behind the name + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "destination-in"; + ctx.drawImage(mask, left / MASK, 0, (right - left) / MASK, H / MASK, left, 0, right - left, H); + ctx.globalCompositeOperation = "source-over"; ctx.restore(); } - function drawCursor(v, x, y, text) { - const f = fadeAt(x, y); + function drawCursor(v, x, y, line) { const c = themeColors(); ctx.fillStyle = c.fg; if (v.mode === "I") { - ctx.globalAlpha = 0.8 * f; + ctx.globalAlpha = 0.8; ctx.fillRect(x, y - LH / 2 + 2, 2, LH - 4); - ctx.globalAlpha = 1; return; } - ctx.globalAlpha = 0.75 * f; + ctx.globalAlpha = 0.75; ctx.fillRect(x, y - LH / 2 + 1, cw, LH - 2); - ctx.globalAlpha = 1; - const ch = text[v.col]; + const ch = line[v.col]; if (ch && ch !== " ") { + ctx.globalAlpha = 1; ctx.fillStyle = c.bg; ctx.fillText(ch, x, y); } } function draw() { - dirty = false; + if (!dirty.size || !mask) return; // Dark text on a light page reads more strongly at the same alpha. wall = (wins.length === 1 ? 0.5 : 0.62) * (themeColors().light ? 0.75 : 1); - ctx.clearRect(0, 0, W, H); ctx.font = FONT; ctx.textBaseline = "middle"; - for (const win of wins) drawWindow(win); - // the dividers between windows - ctx.fillStyle = themeColors().rule; - for (const win of wins.slice(1)) { - for (let y = 0; y < H; y += 8) { - ctx.globalAlpha = fadeAt(win.x - 1, y + 4); - ctx.fillRect(win.x - 1, y, 1, 8); - } - } - ctx.globalAlpha = 1; + for (const win of dirty) drawWindow(win); + dirty.clear(); + } + + function redrawAll() { + for (const win of wins) dirty.add(win); + draw(); } // ── the ghost: one evil cursor that edits a line and undoes it ───────── @@ -399,14 +411,16 @@ export async function initWall() { v.mode = "N"; v.col = 0; keepPointInView(v); + dirty.add(win); - // Bring the line to where it reads, if it isn't there already. + // Bring the line to where it reads, if it isn't there already, as Emacs + // does: in one jump. const rowNow = k - first; if (rowNow < 2 || rowNow > bodyRows() - 3 || fadeAt(win.x + (NUM_W + 12) * cw, bodyTop() + rowNow * LH + LH / 2) < 0.6) { - const to = k - row; - const dur = clamp(0.4 + Math.abs(to - v.top) * 0.012, 0.5, 1.2); - at(dur + 0.1, () => scrollTo(v, to, now, dur)); - at(0.3, () => keepPointInView(v)); + at(0.4, () => { + v.top = k - row; + keepPointInView(v); + }); } // j and k to the line, or a jump when it's far @@ -473,45 +487,35 @@ export async function initWall() { ghost.wait = 0.8; } - function scrollTo(v, to, t = now, dur = 0.6) { - v.anim = { from: v.top, to, t0: t, dur }; - } - // ── the loop ──────────────────────────────────────────────────────── - let now = 0; + // Most frames nothing is due, and nothing is drawn. function tick(t, dt) { - now = t; - for (const win of wins) { + for (const [n, win] of wins.entries()) { const v = win.view; - if (v.anim) { - const p = (t - v.anim.t0) / v.anim.dur; - v.top = v.anim.from + (v.anim.to - v.anim.from) * ease(p); - if (p >= 1) { - v.top = v.anim.to; - v.anim = null; - } - if (ghost.view !== v) keepPointInView(v); - dirty = true; - } else if (ghost.view !== v && t >= v.next) { - scrollTo(v, Math.round(v.top) + (rand() < 0.15 ? 3 : 1), t); + if (!v.next) v.next = t + 1.5 + n * 0.9; + if (ghost.view !== v && t >= v.next) { + v.top += rand() < 0.15 ? 3 : 1; + keepPointInView(v); v.next = t + v.period * (0.8 + rand() * 0.4); + dirty.add(win); } } if (ghost.view || ghost.queue.length) { ghost.wait -= dt; while (ghost.wait <= 0 && ghost.queue.length) { + const v = ghost.view; const a = ghost.queue.shift(); a.fn(); ghost.wait += a.delay; - dirty = true; + touch(v); } } else { ghost.wait -= dt; if (ghost.wait <= -2) plan(); } select(); - if (dirty) draw(); + draw(); } // ── the modeline ──────────────────────────────────────────────────── @@ -533,15 +537,15 @@ export async function initWall() { const win = wins.find((w) => x >= w.x && x < w.x + w.w); if (!win || y < bodyTop() || y > bodyBottom()) return null; const v = win.view; - const first = Math.floor(v.top); - const off = (v.top - first) * LH; - const k = first + Math.floor((y - bodyTop() + off) / LH); + const k = Math.floor(v.top) + Math.floor((y - bodyTop()) / LH); const idx = mod(k, v.len + GAP); return idx < v.len ? { win, k, idx } : null; } function point(h) { if (h?.win === hover?.win && h?.k === hover?.k) return; + if (hover) dirty.add(hover.win); + if (h) dirty.add(h.win); hover = h; select(); draw(); @@ -562,7 +566,7 @@ export async function initWall() { }); ro.observe(canvas); ro.observe(id); - on("theme", () => draw()); + on("theme", redrawAll); // Under reduced motion the wall holds still, and pointing still works. if (!reducedMotion.matches) frameWhileVisible(canvas, tick);