up/docs/javascripts/extra.js
Tolaria fbc488cc95 perf(action-points): pool confetti into one shared canvas + single rAF
Each section/page completion previously appended a new full-viewport
(100vw x 100vh, z-index 99999) canvas with its own requestAnimationFrame
loop. Completing several sections in a row stacked multiple fullscreen
layers, each independently repainting the whole viewport at 60fps — the
real cause of click-induced slowdown (the change handler itself is ~1ms).

Now: one shared canvas, one rAF loop, particles pooled across completions.
The loop stops and the canvas is removed once no particles remain, so
there is no idle fullscreen compositing layer. Resize keeps it sized.

Verified live: 1 canvas after two back-to-back section completions,
0 canvases after the animation ends, green pills still white-on-green.
2026-06-03 23:17:44 +08:00

840 lines
26 KiB
JavaScript

/**
* Godspark Refined — Zensical Light JS
* - Scroll reveal for `.gp-reveal`
* - Cursor glow via `[gp-glow]` attribute
* - Reduced-motion safe
*/
;(function () {
"use strict";
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
function initScrollReveal() {
if (prefersReducedMotion) return;
const nodes = document.querySelectorAll(".gp-reveal");
if (!nodes.length) return;
const thresholdRaw = getComputedStyle(document.documentElement)
.getPropertyValue("--gp-reveal-threshold")
.trim();
const threshold = Number.isFinite(parseFloat(thresholdRaw))
? parseFloat(thresholdRaw)
: 0.12;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold, rootMargin: "0px 0px -16px 0px" }
);
nodes.forEach((el) => observer.observe(el));
}
function initCursorGlow() {
const el = document.documentElement;
const enabled =
el.hasAttribute("gp-glow") || el.classList.contains("gp-glow-active");
if (!enabled) return;
if (prefersReducedMotion) return;
el.classList.add("gp-glow-active");
let raf = false;
function setGlow(x, y) {
el.style.setProperty("--gp-cursor-x", x + "px");
el.style.setProperty("--gp-cursor-y", y + "px");
}
function onMove(e) {
if (raf) return;
raf = true;
requestAnimationFrame(() => {
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
setGlow(cx, cy);
raf = false;
});
}
function onHide() { el.style.setProperty("--gp-glow-opacity", "0"); }
function onShow() { el.style.setProperty("--gp-glow-opacity", "1"); }
const passive = { passive: true };
document.addEventListener("mousemove", onMove, passive);
document.addEventListener("touchmove", onMove, passive);
document.addEventListener("mouseleave", onHide);
document.addEventListener("mouseenter", onShow);
}
function boot() {
initScrollReveal();
initCursorGlow();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();
/* ═══════════════════════════════════════════════════════════════════════════
Action Points — Interactive Checklist
═══════════════════════════════════════════════════════════════════════ */
;(function () {
"use strict";
// ── Constants ──────────────────────────────────────
var STORAGE_KEY = "toa-action-points";
var VERSION = 2;
var REDUCED_MOTION = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
// ── Storage ────────────────────────────────────────
function getStorage() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return _fresh();
var data = JSON.parse(raw);
if (data.version !== VERSION) return _migrate(data);
return data;
} catch (e) {
return _fresh();
}
}
function _fresh() {
return { version: VERSION, pages: {} };
}
function _migrate(old) {
// v1 → v2: add completedDates tracking
var data = _fresh();
if (old.pages) {
Object.keys(old.pages).forEach(function (path) {
var op = old.pages[path];
data.pages[path] = {
lastUpdated: op.lastUpdated || null,
checked: op.checked || [],
completedDates: {},
_checked: op._checked,
_total: op._total,
};
// Seed completedDates from checked array (assume checked=today)
var td = todayStr();
(op.checked || []).forEach(function (label) {
data.pages[path].completedDates[label] = [td];
});
});
}
saveStorage(data);
return data;
}
function saveStorage(data) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
/* quota exceeded — silently drop */
}
}
function getPagePath() {
var p = location.pathname;
if (p.slice(-1) !== "/") p += "/";
return p;
}
function getNormLabel(li) {
// Clone li, remove the task-list-control label, get remaining text
var clone = li.cloneNode(true);
var ctrl = clone.querySelector(".task-list-control");
if (ctrl) ctrl.remove();
return (clone.textContent || "").replace(/\s+/g, " ").trim().toLowerCase();
}
function getPageData(path) {
var store = getStorage();
if (!store.pages[path]) {
store.pages[path] = { checked: [], completedDates: {}, lastUpdated: null };
}
if (!store.pages[path].completedDates) {
store.pages[path].completedDates = {};
}
return store.pages[path];
}
function setPageChecked(path, label, checked) {
var store = getStorage();
if (!store.pages[path]) {
store.pages[path] = { checked: [], completedDates: {}, lastUpdated: null };
}
var page = store.pages[path];
if (!page.completedDates) page.completedDates = {};
if (!page.completedDates[label]) page.completedDates[label] = [];
var td = todayStr();
if (checked) {
if (page.checked.indexOf(label) === -1) page.checked.push(label);
if (page.completedDates[label].indexOf(td) === -1) {
page.completedDates[label].push(td);
}
} else {
page.checked = page.checked.filter(function (l) {
return l !== label;
});
page.completedDates[label] = page.completedDates[label].filter(function (d) {
return d !== td;
});
}
page.lastUpdated = new Date().toISOString();
saveStorage(store);
}
// Persist page summary (checked count, total) for nav aggregation
function savePageSummary(path, checkedCount, total) {
var store = getStorage();
if (!store.pages[path]) {
store.pages[path] = { checked: [], lastUpdated: null };
}
store.pages[path]._checked = checkedCount;
store.pages[path]._total = total;
// Don't bump lastUpdated for summary-only writes
saveStorage(store);
}
function getPageSummary(path) {
var store = getStorage();
var page = store.pages[path];
if (!page || page._total === undefined) return null;
return { checked: page._checked || 0, total: page._total || 0 };
}
// ── Build-time totals manifest ─────────────────────
// toa-totals.json maps page-URL -> task count, generated at build time
// (see main.py). Seeding _total for every page lets nav badges show
// correct totals on FIRST load, before the user has visited each page.
// Fetched once per session; cached by the browser thereafter.
var _totalsLoaded = false;
function seedTotalsFromManifest(cb) {
if (_totalsLoaded) {
cb && cb(false);
return;
}
_totalsLoaded = true; // mark immediately so concurrent navs don't double-fetch
// Resolve the manifest relative to the site root. The script tag for
// extra.js is loaded from <base>/javascripts/extra.js, so derive the URL
// from document scripts to stay correct under any base_url.
var url = "javascripts/toa-totals.json";
try {
var scripts = document.querySelectorAll('script[src*="javascripts/extra.js"]');
if (scripts.length) {
url = scripts[0].src.replace(/extra\.js.*$/, "toa-totals.json");
} else {
// Fallback: resolve from origin
url = new URL("javascripts/toa-totals.json", location.origin + "/").href;
}
} catch (e) {
/* use relative fallback */
}
fetch(url, { cache: "force-cache" })
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (totals) {
if (!totals) {
cb && cb(false);
return;
}
var store = getStorage();
var changed = false;
Object.keys(totals).forEach(function (p) {
var path = p.slice(-1) === "/" ? p : p + "/";
if (!store.pages[path]) {
store.pages[path] = { checked: [], completedDates: {}, lastUpdated: null };
}
// Only seed _total; never clobber _checked (real progress wins).
if (store.pages[path]._total !== totals[p]) {
store.pages[path]._total = totals[p];
changed = true;
}
if (store.pages[path]._checked === undefined) {
store.pages[path]._checked = (store.pages[path].checked || []).length;
}
});
if (changed) saveStorage(store);
cb && cb(true);
})
.catch(function () {
cb && cb(false);
});
}
// ── Checkbox initialization ────────────────────────
function initCheckboxes() {
var path = getPagePath();
var pageData = getPageData(path);
var items = document.querySelectorAll("li.task-list-item");
if (!items.length) return { checked: 0, total: 0 };
var total = items.length;
var checkedCount = 0;
items.forEach(function (li) {
var cb = li.querySelector('input[type="checkbox"]');
if (!cb) return;
var label = getNormLabel(li);
var isChecked = pageData.checked.indexOf(label) !== -1;
// Enable checkbox
cb.disabled = false;
cb.checked = isChecked;
if (isChecked) checkedCount++;
cb.addEventListener("change", function () {
var now = cb.checked;
setPageChecked(path, label, now);
// Recompute & re-render
var counts = computeHeadingCounts();
savePageSummary(path, counts.pageChecked, counts.pageTotal);
renderHeadingPills(counts);
updatePageHeading(counts);
updateNavBadges();
checkCompletions(counts);
});
});
return { checked: checkedCount, total: total };
}
// ── Heading counters ───────────────────────────────
function computeHeadingCounts() {
var article = document.querySelector("article");
if (!article) return { groups: [], pageChecked: 0, pageTotal: 0 };
var groups = [];
var current = null;
// Tree-walk: assign items to the nearest preceding h2/h3 in document order.
// This is immune to compareDocumentPosition quirks.
function walk(node) {
if (node.nodeType !== 1) return; // skip non-elements
var tag = node.tagName;
if (tag === "H2" || tag === "H3") {
current = node;
groups.push({ heading: node, items: [], level: parseInt(tag[1]) });
} else if (tag === "LI" && node.classList.contains("task-list-item")) {
if (current) {
groups[groups.length - 1].items.push(node);
}
}
// Recurse into children
var children = node.childNodes;
for (var i = 0; i < children.length; i++) {
walk(children[i]);
}
}
// Walk article's children (skip article itself to avoid h1)
var kids = article.childNodes;
for (var i = 0; i < kids.length; i++) {
walk(kids[i]);
}
// Compute checked counts
var pageChecked = 0;
var pageTotal = 0;
groups.forEach(function (g) {
var checked = 0;
g.items.forEach(function (li) {
var cb = li.querySelector('input[type="checkbox"]');
if (cb && cb.checked) checked++;
});
g.checked = checked;
g.total = g.items.length;
pageChecked += checked;
pageTotal += g.items.length;
});
return { groups: groups, pageChecked: pageChecked, pageTotal: pageTotal };
}
function renderHeadingPills(counts) {
// Clear previous data attributes from all article headings
document.querySelectorAll("article h2, article h3").forEach(function (h) {
h.removeAttribute("data-toa-count");
h.removeAttribute("data-toa-complete");
h.classList.remove("toa-heading--complete");
});
counts.groups.forEach(function (g) {
if (g.total === 0) return;
g.heading.setAttribute("data-toa-count", g.checked + "/" + g.total);
if (g.checked === g.total) {
g.heading.setAttribute("data-toa-complete", "true");
g.heading.classList.add("toa-heading--complete");
}
});
}
function updatePageHeading(counts) {
var h1 = document.querySelector("article h1");
if (!h1) return;
h1.removeAttribute("data-toa-count");
h1.removeAttribute("data-toa-complete");
h1.classList.remove("toa-heading--complete");
if (counts.pageTotal === 0) return;
h1.setAttribute("data-toa-count", counts.pageChecked + "/" + counts.pageTotal);
if (counts.pageChecked === counts.pageTotal) {
h1.setAttribute("data-toa-complete", "true");
h1.classList.add("toa-heading--complete");
}
}
// ── Completion detection ───────────────────────────
var _completedHeadings = {};
var _pageWasComplete = false;
function checkCompletions(counts) {
var path = getPagePath();
// Check heading completions
counts.groups.forEach(function (g) {
if (g.total === 0) return;
var id = g.heading.id || g.heading.textContent.replace(/\s+/g, "_");
var justCompleted = g.checked === g.total && !_completedHeadings[id];
_completedHeadings[id] = g.checked === g.total;
if (justCompleted) {
fireHeadingComplete();
}
});
// Check page completion
var pageComplete =
counts.pageTotal > 0 && counts.pageChecked === counts.pageTotal;
if (pageComplete && !_pageWasComplete) {
firePageComplete();
}
_pageWasComplete = pageComplete;
// Save summary for nav aggregation
savePageSummary(path, counts.pageChecked, counts.pageTotal);
}
// ── Confetti ───────────────────────────────────────
// Single shared canvas + one rAF loop. Multiple completions add particles
// to the same pool instead of stacking N fullscreen canvases (each of which
// would independently repaint the whole viewport at 60fps — the cause of
// click-induced slowdown). The loop fully stops when no particles remain,
// and the canvas is removed so there is no idle fullscreen layer.
var _confettiCanvas = null;
var _confettiCtx = null;
var _confettiParticles = [];
var _confettiRAF = 0;
var CONFETTI_COLORS = [
"#4a8262", "#a8c5b6", "#3d6b52", "#e9dfc6", "#92ad9e", "#d4e8dc",
];
function _ensureConfettiCanvas() {
if (_confettiCanvas) return;
var canvas = document.createElement("canvas");
canvas.style.cssText =
"position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:99999;";
document.body.appendChild(canvas);
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
_confettiCanvas = canvas;
_confettiCtx = canvas.getContext("2d");
}
function _destroyConfettiCanvas() {
if (_confettiCanvas) {
_confettiCanvas.remove();
_confettiCanvas = null;
_confettiCtx = null;
}
_confettiParticles = [];
_confettiRAF = 0;
}
function _confettiTick(now) {
var ctx = _confettiCtx;
var canvas = _confettiCanvas;
if (!ctx || !canvas) {
_confettiRAF = 0;
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
var alive = 0;
for (var i = 0; i < _confettiParticles.length; i++) {
var p = _confettiParticles[i];
var elapsed = now - p.start;
var progress = elapsed / p.duration;
if (progress >= 1) continue; // dead — skip
alive++;
p.x += p.vx;
p.vy += 0.12;
p.y += p.vy;
p.rot += p.rotV;
var fade = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate((p.rot * Math.PI) / 180);
ctx.globalAlpha = fade;
ctx.fillStyle = p.color;
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
ctx.restore();
}
if (alive === 0) {
_destroyConfettiCanvas();
return;
}
// Periodically compact the array so dead particles don't accumulate.
if (_confettiParticles.length > 400) {
_confettiParticles = _confettiParticles.filter(function (p) {
return now - p.start < p.duration;
});
}
_confettiRAF = requestAnimationFrame(_confettiTick);
}
function fireConfetti(intensity) {
if (REDUCED_MOTION) return;
_ensureConfettiCanvas();
var canvas = _confettiCanvas;
var count = intensity === "page" ? 140 : 55;
var duration = intensity === "page" ? 3200 : 2200;
var drift = intensity === "page" ? 1.8 : 1.2;
var start = performance.now();
for (var i = 0; i < count; i++) {
_confettiParticles.push({
x: Math.random() * canvas.width,
y: -(Math.random() * 60 + 10),
w: Math.random() * 7 + 2,
h: Math.random() * 4 + 1.5,
color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
vx: (Math.random() - 0.5) * drift,
vy: Math.random() * 2 + 1.2,
rot: Math.random() * 360,
rotV: (Math.random() - 0.5) * 8,
start: start,
duration: duration,
});
}
// Start the single loop only if it isn't already running.
if (!_confettiRAF) {
_confettiRAF = requestAnimationFrame(_confettiTick);
}
}
// Keep the shared canvas sized to the viewport.
window.addEventListener("resize", function () {
if (_confettiCanvas) {
_confettiCanvas.width = window.innerWidth;
_confettiCanvas.height = window.innerHeight;
}
});
// ── Sound ──────────────────────────────────────────
var _audioCtx = null;
function getAudioCtx() {
if (!_audioCtx) {
_audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (_audioCtx.state === "suspended") {
_audioCtx.resume();
}
return _audioCtx;
}
function playTone(freq, duration, when) {
if (REDUCED_MOTION) return;
try {
var ctx = getAudioCtx();
var osc = ctx.createOscillator();
var gain = ctx.createGain();
var t = when || ctx.currentTime;
osc.type = "sine";
osc.frequency.setValueAtTime(freq, t);
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.3, t + 0.04);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + duration);
} catch (e) {
/* Web Audio not available */
}
}
function fireHeadingComplete() {
playTone(432, 1.6);
fireConfetti("heading");
}
function firePageComplete() {
var ctx;
try {
ctx = getAudioCtx();
} catch (e) {
fireConfetti("page");
return;
}
// Ascending arpeggio centered on 528 Hz
var notes = [528 * 0.667, 528 * 0.75, 528 * 0.833, 528];
var t = ctx.currentTime;
notes.forEach(function (freq, i) {
playTone(freq, 0.7, t + i * 0.18);
});
fireConfetti("page");
}
// ── Navigation badges ──────────────────────────────
function updateNavBadges() {
// Pause observer during our own DOM changes to prevent feedback loops
if (_navObserver) _navObserver.disconnect();
// Remove old badges
document.querySelectorAll(".toa-nav-badge").forEach(function (el) {
el.remove();
});
var store = getStorage();
// Only badge PAGE links in the primary navigation. Exclude:
// - the secondary nav ("On this page" / table-of-contents), whose links
// are in-page #anchors and would all resolve to the current page total
// - any link whose href is/contains a # fragment (same reason)
var navLinks = document.querySelectorAll(
".md-nav--primary .md-nav__link[href]"
);
navLinks.forEach(function (link) {
var href = link.getAttribute("href");
// Skip in-page anchor links (TOC entries, heading permalinks).
if (!href || href.charAt(0) === "#" || href.indexOf("#") !== -1) return;
// Defensive: skip if this link lives in a secondary (TOC) nav.
if (link.closest(".md-nav--secondary")) return;
// Resolve relative URLs to absolute paths
var url;
try {
url = new URL(href, location.origin + location.pathname).pathname;
} catch (e) {
return;
}
if (url.slice(-1) !== "/") url += "/";
// Check if this is a section (index) page with child pages.
// NOTE: Material wraps the ACTIVE page's .md-nav__item with a nested
// <nav class="md-nav md-nav--secondary"> holding the "On this page" TOC.
// A naive ".md-nav--nested, nav.md-nav" probe matches that TOC and
// mis-classifies the active leaf page as a section. So we detect real
// section items via Material's own marker class and explicitly ignore
// any nested nav that is the secondary (TOC) nav.
var parentNav = link.closest(".md-nav__item");
var nestedNav = parentNav
? parentNav.querySelector(":scope > nav.md-nav:not(.md-nav--secondary)")
: null;
var isSection =
(parentNav && parentNav.classList.contains("md-nav__item--nested")) ||
!!nestedNav;
var badge;
if (isSection) {
// Aggregate all sub-pages whose paths start with this URL
badge = aggregateChildren(url, store);
} else {
badge = getPageSummary(url);
}
if (!badge || badge.total === 0) return;
var span = document.createElement("span");
span.className = "toa-nav-badge";
if (badge.checked === badge.total) {
span.className += " toa-nav-badge--complete";
span.textContent = "\u2713"; // ✓
link.classList.add("toa-nav--complete");
} else {
span.textContent = badge.checked + "/" + badge.total;
link.classList.remove("toa-nav--complete");
}
link.appendChild(span);
});
// Resume observer
if (_navObserver) {
var sb = document.querySelector(".md-sidebar--primary");
if (sb) _navObserver.observe(sb, { childList: true, subtree: true });
}
}
function aggregateChildren(parentUrl, store) {
var checked = 0;
var total = 0;
Object.keys(store.pages).forEach(function (path) {
if (path === parentUrl) return;
if (path.indexOf(parentUrl) === 0) {
var s = store.pages[path];
if (s && s._total !== undefined) {
checked += s._checked || 0;
total += s._total || 0;
}
}
});
if (total === 0) return null;
return { checked: checked, total: total };
}
// ── Export ─────────────────────────────────────────
function createExportLink() {
// Only add if there's data to export
var store = getStorage();
var hasData = Object.keys(store.pages).length > 0;
var existing = document.querySelector(".toa-export");
if (existing) existing.remove();
if (!hasData) return;
// Find a good spot — footer copyright area or near comments
var container =
document.querySelector(".md-copyright") ||
document.querySelector("#__comments") ||
document.querySelector("article");
if (!container) return;
var link = document.createElement("a");
link.className = "toa-export";
link.textContent = "Download my progress";
link.href = "#";
link.addEventListener("click", function (e) {
e.preventDefault();
var raw = localStorage.getItem(STORAGE_KEY) || "{}";
var blob = new Blob([raw], { type: "application/json" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "tree-of-ascension-progress.json";
a.click();
URL.revokeObjectURL(url);
});
container.appendChild(link);
}
// ── Main init ──────────────────────────────────────
var _lastInitPath = null;
function init() {
var path = getPagePath();
if (_lastInitPath === path) return; // guard against duplicate instant-nav fires
_lastInitPath = path;
_completedHeadings = {};
_pageWasComplete = false;
var result = initCheckboxes();
var counts = computeHeadingCounts();
savePageSummary(path, result.checked, result.total);
renderHeadingPills(counts);
updatePageHeading(counts);
updateNavBadges();
createExportLink();
// Seed build-time totals so nav badges show correct numbers for pages
// the user hasn't visited yet, then re-render badges once loaded.
seedTotalsFromManifest(function (ok) {
if (ok) updateNavBadges();
});
// Mark existing completions so we don't re-trigger on page load
counts.groups.forEach(function (g) {
if (g.total === 0) return;
var id = g.heading.id || g.heading.textContent.replace(/\s+/g, "_");
_completedHeadings[id] = g.checked === g.total;
if (g.checked === g.total) {
g.heading.classList.add("toa-heading--complete");
}
});
_pageWasComplete =
counts.pageTotal > 0 && counts.pageChecked === counts.pageTotal;
}
// ── Boot ───────────────────────────────────────────
var _navObserver = null;
function setupNavObserver() {
if (_navObserver) return;
var sidebar = document.querySelector(".md-sidebar--primary");
if (!sidebar) return;
_navObserver = new MutationObserver(function () {
updateNavBadges();
});
_navObserver.observe(sidebar, { childList: true, subtree: true });
}
function ready(fn) {
if (typeof document$ !== "undefined") {
document$.subscribe(function () {
requestAnimationFrame(function () {
requestAnimationFrame(fn);
});
setupNavObserver();
});
} else {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
fn();
setupNavObserver();
});
} else {
fn();
setupNavObserver();
}
}
}
ready(init);
})();