2026-07-20 12:25:00 +02:00

410 lines
14 KiB
JavaScript

// Progressive enhancement for .tour pages (framework-in-brief):
// a section progress rail and a subtle reveal-on-scroll.
// No-JS or reduced-motion readers get the full static page.
// Deliberately avoids requestAnimationFrame and IntersectionObserver so it
// also behaves in throttled/automation environments and print pipelines.
(function () {
"use strict";
var tour = document.querySelector(".tour");
if (!tour) {
return;
}
var reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---- Section deck -----------------------------------------------------
// Every section whose heading is followed by an .unpacks chip collapses
// to a header strip (heading + chip); the body opens on click. Bodies
// collapse via height:0, not display:none, for the same mermaid-width
// reason as .fold. Anchor navigation (rail dots, "section N" links,
// search) auto-expands the target section. No-JS readers get the full
// static page.
var secs = [];
Array.prototype.slice
.call(tour.querySelectorAll("h2[id]"))
.forEach(function (h2) {
var chip = h2.nextElementSibling;
if (!chip || !chip.classList.contains("unpacks")) {
return;
}
var sec = document.createElement("section");
sec.className = "sec sec--closed";
h2.parentNode.insertBefore(sec, h2);
var head = document.createElement("div");
head.className = "sec-head";
head.setAttribute("role", "button");
head.setAttribute("tabindex", "0");
var chev = document.createElement("span");
chev.className = "sec-chev";
head.appendChild(h2);
head.appendChild(chip);
head.appendChild(chev);
var numMatch = h2.textContent.match(/^\s*(\d+)/);
if (numMatch) {
var num = document.createElement("span");
num.className = "sec-num";
num.textContent = numMatch[1];
sec.appendChild(num);
}
var body = document.createElement("div");
body.className = "sec-body";
sec.appendChild(head);
var node = sec.nextSibling;
while (node && !(node.nodeType === 1 && node.tagName === "H2")) {
var next = node.nextSibling;
if (node.nodeType === 1 && node.classList.contains("slide") && !body.children.length) {
sec.appendChild(node);
} else {
body.appendChild(node);
}
node = next;
}
sec.appendChild(body);
function render() {
var open = !sec.classList.contains("sec--closed");
chev.textContent = open ? "▾" : "▸";
head.setAttribute("aria-expanded", String(open));
}
function toggle() {
sec.classList.toggle("sec--closed");
render();
renderDeckControl();
onScroll();
}
function clickToggle(event) {
if (event.target.closest && event.target.closest("a")) {
return;
}
toggle();
}
head.addEventListener("click", clickToggle);
head.addEventListener("keydown", function (event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
});
var slide = sec.querySelector(".slide");
if (slide) {
slide.addEventListener("click", clickToggle);
}
render();
secs.push({ sec: sec, render: render });
});
var deckControl = null;
function renderDeckControl() {
if (!deckControl) {
return;
}
var anyClosed = secs.some(function (s) {
return s.sec.classList.contains("sec--closed");
});
deckControl.textContent = anyClosed ? "▸ expand all sections" : "▾ collapse all sections";
}
if (secs.length) {
var controls = document.createElement("div");
controls.className = "sec-controls";
deckControl = document.createElement("button");
deckControl.type = "button";
deckControl.className = "sec-controls__toggle";
deckControl.addEventListener("click", function () {
var anyClosed = secs.some(function (s) {
return s.sec.classList.contains("sec--closed");
});
secs.forEach(function (s) {
s.sec.classList.toggle("sec--closed", !anyClosed);
s.render();
});
renderDeckControl();
onScroll();
});
controls.appendChild(deckControl);
secs[0].sec.parentNode.insertBefore(controls, secs[0].sec);
renderDeckControl();
}
function openSectionFor(hash) {
if (!hash || hash.length < 2) {
return;
}
var target;
try {
target = document.getElementById(decodeURIComponent(hash.slice(1)));
} catch (e) {
return;
}
if (!target) {
return;
}
secs.forEach(function (s) {
if (s.sec.contains(target) && s.sec.classList.contains("sec--closed")) {
s.sec.classList.remove("sec--closed");
s.render();
renderDeckControl();
window.setTimeout(function () {
target.scrollIntoView();
onScroll();
}, 0);
}
});
}
window.addEventListener("hashchange", function () {
openSectionFor(window.location.hash);
});
openSectionFor(window.location.hash);
// ---- Section progress rail -------------------------------------------
var headings = Array.prototype.slice.call(tour.querySelectorAll("h2[id]"));
var dots = [];
if (headings.length >= 4) {
var rail = document.createElement("nav");
rail.className = "tour-rail";
rail.setAttribute("aria-label", "Sections");
dots = headings.map(function (heading, index) {
var dot = document.createElement("a");
dot.href = "#" + heading.id;
var label = heading.textContent.trim();
dot.textContent = String(index + 1);
dot.setAttribute("data-label", label);
dot.setAttribute("aria-label", label);
rail.appendChild(dot);
return dot;
});
document.body.appendChild(rail);
}
var active = -1;
function updateRail() {
if (!dots.length) {
return;
}
var cutoff = window.innerHeight * 0.34;
var current = -1;
for (var i = 0; i < headings.length; i += 1) {
if (headings[i].getBoundingClientRect().top <= cutoff) {
current = i;
}
}
if (current === active) {
return;
}
if (active >= 0) {
dots[active].classList.remove("on");
}
active = current;
if (active >= 0) {
dots[active].classList.add("on");
}
}
// ---- Reveal-on-scroll -------------------------------------------------
var pending = [];
if (!reducedMotion) {
pending = Array.prototype.slice.call(tour.children).filter(function (el) {
var tag = el.tagName;
return tag === "PRE" || tag === "TABLE" || tag === "UL" || tag === "OL" ||
el.classList.contains("duo") || el.classList.contains("facts") ||
el.classList.contains("seq") || el.classList.contains("mermaid");
});
pending.forEach(function (el) {
el.classList.add("reveal");
});
}
function updateReveals() {
if (!pending.length) {
return;
}
var limit = window.innerHeight * 0.96;
pending = pending.filter(function (el) {
var rect = el.getBoundingClientRect();
if (rect.top <= limit && rect.bottom >= 0) {
el.classList.add("on");
return false;
}
return true;
});
}
// ---- Collapsible diagrams ---------------------------------------------
// Each .fold wraps one diagram; a toggle button replaces it until clicked.
// Collapsed via height:0 (not display:none) so mermaid still renders at
// real width while hidden. Without JS the diagrams stay fully visible.
Array.prototype.slice.call(tour.querySelectorAll(".fold")).forEach(function (fold) {
var label = fold.getAttribute("data-label") || "diagram";
var body = document.createElement("div");
body.className = "fold__body";
while (fold.firstChild) {
body.appendChild(fold.firstChild);
}
var toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "fold__toggle";
fold.appendChild(toggle);
fold.appendChild(body);
fold.classList.add("fold--closed");
function render() {
var open = !fold.classList.contains("fold--closed");
toggle.textContent = (open ? "▾ " : "▸ ") + label;
toggle.setAttribute("aria-expanded", String(open));
}
toggle.addEventListener("click", function () {
fold.classList.toggle("fold--closed");
render();
onScroll();
});
render();
});
// ---- Architecture map: click opens a zoomed, pannable overlay ---------
// Clicking a §N badge navigates to its section instead; a drag pans the
// zoomed map without closing it.
var archMap = tour.querySelector(".arch-map svg");
if (archMap) {
archMap.style.cursor = "zoom-in";
archMap.addEventListener("click", function (event) {
if (event.target.closest && event.target.closest("a")) {
return;
}
openArchMapOverlay(archMap, event);
});
}
function openArchMapOverlay(svg, event) {
var overlay = document.createElement("div");
overlay.className = "arch-map-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "Architecture map");
var content = document.createElement("div");
content.className = "arch-map-overlay__content";
var controls = document.createElement("div");
controls.className = "arch-map-overlay__controls";
controls.innerHTML = "<span>Drag to pan · Esc to close</span>";
var closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.textContent = "Close";
closeButton.setAttribute("aria-label", "Close architecture map");
controls.appendChild(closeButton);
var zoomWidth = Math.min(1700, Math.round(window.innerWidth * 1.9));
overlay.style.setProperty("--fw-zoom-width", zoomWidth + "px");
var clone = svg.cloneNode(true);
clone.style.display = "block";
content.appendChild(clone);
overlay.appendChild(controls);
overlay.appendChild(content);
document.body.appendChild(overlay);
var previousFocus = document.activeElement;
closeButton.focus();
// Scroll the overlay so the clicked point sits centered.
var rect = svg.getBoundingClientRect();
var fx = (event.clientX - rect.left) / rect.width;
var fy = (event.clientY - rect.top) / rect.height;
var zoomHeight = zoomWidth * (rect.height / rect.width);
content.scrollLeft = Math.max(0, fx * zoomWidth - content.clientWidth / 2);
content.scrollTop = Math.max(0, fy * zoomHeight - content.clientHeight / 2);
var dragging = false;
var moved = false;
var startX = 0;
var startY = 0;
var startLeft = 0;
var startTop = 0;
overlay.addEventListener("pointerdown", function (e) {
dragging = true;
moved = false;
startX = e.clientX;
startY = e.clientY;
startLeft = content.scrollLeft;
startTop = content.scrollTop;
});
overlay.addEventListener("pointermove", function (e) {
if (!dragging) {
return;
}
var dx = e.clientX - startX;
var dy = e.clientY - startY;
if (Math.abs(dx) + Math.abs(dy) > 6) {
moved = true;
}
content.scrollLeft = startLeft - dx;
content.scrollTop = startTop - dy;
});
overlay.addEventListener("pointerup", function () {
dragging = false;
});
function onKey(e) {
if (e.key === "Escape") {
close();
}
}
function close() {
overlay.remove();
document.removeEventListener("keydown", onKey);
if (previousFocus && previousFocus.focus) {
previousFocus.focus();
}
}
controls.addEventListener("pointerdown", function (e) {
e.stopPropagation();
});
controls.addEventListener("click", function (e) {
e.stopPropagation();
});
closeButton.addEventListener("click", function (e) {
e.stopPropagation();
close();
});
overlay.addEventListener("click", function () {
if (moved) {
moved = false;
return;
}
close();
});
document.addEventListener("keydown", onKey);
}
// ---- One throttled driver for both -----------------------------------
var ticking = false;
function update() {
updateRail();
updateReveals();
}
function onScroll() {
if (ticking) {
return;
}
ticking = true;
window.setTimeout(function () {
ticking = false;
update();
}, 60);
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
// Initial pass: mark what is already in view (mermaid renders async and
// shifts layout, so run again shortly after load).
update();
window.setTimeout(update, 400);
window.setTimeout(update, 1500);
})();