459 lines
17 KiB
JavaScript
459 lines
17 KiB
JavaScript
/* gallery.js — Two orthogonal systems:
|
|
1. Named exhibits (.exhibit[data-exhibit-name]) — TOC integration only.
|
|
2. Math focusables — every .katex-display gets a hover expand button
|
|
that opens a full-size overlay. Navigation is global (all focusables
|
|
in document order). Group name in overlay comes from nearest exhibit
|
|
or heading. */
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
function slugify(name) {
|
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
/* ============================================================
|
|
NAMED EXHIBITS (TOC integration)
|
|
============================================================ */
|
|
|
|
var exhibits = [];
|
|
|
|
function discoverExhibits() {
|
|
document.querySelectorAll('.exhibit[data-exhibit-name]').forEach(function (el) {
|
|
var name = el.dataset.exhibitName || '';
|
|
var type = el.dataset.exhibitType || 'equation';
|
|
var id = 'exhibit-' + slugify(name);
|
|
el.id = id;
|
|
exhibits.push({ el: el, type: type, name: name, id: id });
|
|
});
|
|
}
|
|
|
|
function initProofExhibit(entry) {
|
|
var body = entry.el.querySelector('.exhibit-body');
|
|
if (!body) return;
|
|
|
|
var header = document.createElement('div');
|
|
header.className = 'exhibit-header';
|
|
|
|
var label = document.createElement('span');
|
|
label.className = 'exhibit-header-label';
|
|
label.textContent = 'Proof.';
|
|
|
|
var name = document.createElement('span');
|
|
name.className = 'exhibit-header-name';
|
|
name.textContent = entry.name;
|
|
|
|
header.appendChild(label);
|
|
header.appendChild(name);
|
|
entry.el.insertBefore(header, body);
|
|
}
|
|
|
|
/* ============================================================
|
|
MATH FOCUSABLES
|
|
Auto-discover every .katex-display in #markdownBody.
|
|
KaTeX must be called with output:'htmlAndMathml' so the
|
|
original LaTeX survives in <annotation encoding="application/x-tex">.
|
|
============================================================ */
|
|
|
|
var focusables = []; /* { katexEl, wrapperEl, source, groupName } */
|
|
|
|
function getSource(katexEl) {
|
|
var ann = katexEl.querySelector('annotation[encoding="application/x-tex"]');
|
|
return ann ? ann.textContent.trim() : '';
|
|
}
|
|
|
|
function getGroupName(katexEl, markdownBody) {
|
|
/* Named exhibit takes priority */
|
|
var exhibit = katexEl.closest('.exhibit[data-exhibit-name]');
|
|
if (exhibit) return exhibit.dataset.exhibitName || '';
|
|
|
|
/* Otherwise: nearest preceding heading */
|
|
var headings = Array.from(markdownBody.querySelectorAll(':is(h1,h2,h3,h4,h5,h6)'));
|
|
var nearest = null;
|
|
headings.forEach(function (h) {
|
|
if (h.compareDocumentPosition(katexEl) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
nearest = h;
|
|
}
|
|
});
|
|
return nearest ? nearest.textContent.trim() : '';
|
|
}
|
|
|
|
function getCaption(katexEl) {
|
|
var exhibit = katexEl.closest('.exhibit[data-exhibit-caption]');
|
|
if (!exhibit) return '';
|
|
/* A proof's caption belongs to the proof as a whole, not to each
|
|
individual equation line within it. Only propagate for equation
|
|
exhibits where the math IS the primary content. */
|
|
if (exhibit.dataset.exhibitType === 'proof') return '';
|
|
return exhibit.dataset.exhibitCaption || '';
|
|
}
|
|
|
|
function discoverFocusableMath(markdownBody) {
|
|
markdownBody.querySelectorAll('.katex-display').forEach(function (katexEl) {
|
|
var source = getSource(katexEl);
|
|
var groupName = getGroupName(katexEl, markdownBody);
|
|
var caption = getCaption(katexEl);
|
|
|
|
/* Wrap in .math-focusable — the entire wrapper is the click target */
|
|
var wrapper = document.createElement('div');
|
|
wrapper.className = 'math-focusable';
|
|
if (caption) wrapper.dataset.caption = caption; /* drives CSS ::after tooltip */
|
|
katexEl.parentNode.insertBefore(wrapper, katexEl);
|
|
wrapper.appendChild(katexEl);
|
|
|
|
/* Decorative expand glyph (pointer-events: none in CSS) */
|
|
var glyph = document.createElement('span');
|
|
glyph.className = 'exhibit-expand';
|
|
glyph.setAttribute('aria-hidden', 'true');
|
|
glyph.textContent = '⤢';
|
|
wrapper.appendChild(glyph);
|
|
|
|
var entry = {
|
|
type: 'math',
|
|
katexEl: katexEl,
|
|
wrapperEl: wrapper,
|
|
source: source,
|
|
groupName: groupName,
|
|
caption: caption
|
|
};
|
|
focusables.push(entry);
|
|
|
|
/* Click anywhere on the wrapper opens the overlay */
|
|
wrapper.addEventListener('click', function () {
|
|
openOverlay(focusables.indexOf(entry));
|
|
});
|
|
});
|
|
}
|
|
|
|
function discoverFocusableScores(markdownBody) {
|
|
markdownBody.querySelectorAll('.score-fragment').forEach(function (figEl) {
|
|
var svgEl = figEl.querySelector('svg');
|
|
if (!svgEl) return;
|
|
|
|
var captionEl = figEl.querySelector('.score-caption');
|
|
var captionText = captionEl ? captionEl.textContent.trim() : '';
|
|
var name = figEl.dataset.exhibitName || '';
|
|
var groupName = name || getGroupName(figEl, markdownBody);
|
|
|
|
/* Expand glyph — decorative affordance, same as math focusables */
|
|
var glyph = document.createElement('span');
|
|
glyph.className = 'exhibit-expand';
|
|
glyph.setAttribute('aria-hidden', 'true');
|
|
glyph.textContent = '⤢';
|
|
figEl.appendChild(glyph);
|
|
|
|
var entry = {
|
|
type: 'score',
|
|
wrapperEl: figEl,
|
|
svgEl: svgEl,
|
|
groupName: groupName,
|
|
caption: captionText
|
|
};
|
|
focusables.push(entry);
|
|
|
|
figEl.addEventListener('click', function () {
|
|
openOverlay(focusables.indexOf(entry));
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
OVERLAY
|
|
============================================================ */
|
|
|
|
var overlay, overlayGroup, overlayBody, overlayCaption;
|
|
var overlayPrev, overlayNext, overlayCounter, overlayClose;
|
|
var currentIdx = -1;
|
|
|
|
function buildOverlay() {
|
|
overlay = document.createElement('div');
|
|
overlay.id = 'gallery-overlay';
|
|
overlay.setAttribute('hidden', '');
|
|
overlay.setAttribute('role', 'dialog');
|
|
overlay.setAttribute('aria-modal', 'true');
|
|
|
|
/* All children are absolute or flex-centered — no panel wrapper */
|
|
|
|
overlayClose = document.createElement('button');
|
|
overlayClose.id = 'gallery-overlay-close';
|
|
overlayClose.setAttribute('aria-label', 'Close');
|
|
overlayClose.textContent = '✕';
|
|
overlay.appendChild(overlayClose);
|
|
|
|
overlayGroup = document.createElement('div');
|
|
overlayGroup.id = 'gallery-overlay-name';
|
|
overlay.appendChild(overlayGroup);
|
|
|
|
overlayBody = document.createElement('div');
|
|
overlayBody.id = 'gallery-overlay-body';
|
|
overlay.appendChild(overlayBody);
|
|
|
|
overlayCaption = document.createElement('div');
|
|
overlayCaption.id = 'gallery-overlay-caption';
|
|
overlay.appendChild(overlayCaption);
|
|
|
|
overlayCounter = document.createElement('div');
|
|
overlayCounter.id = 'gallery-overlay-counter';
|
|
overlay.appendChild(overlayCounter);
|
|
|
|
overlayPrev = document.createElement('button');
|
|
overlayPrev.id = 'gallery-overlay-prev';
|
|
overlayPrev.className = 'gallery-nav-btn';
|
|
overlayPrev.setAttribute('aria-label', 'Previous equation');
|
|
overlayPrev.textContent = '←';
|
|
overlay.appendChild(overlayPrev);
|
|
|
|
overlayNext = document.createElement('button');
|
|
overlayNext.id = 'gallery-overlay-next';
|
|
overlayNext.className = 'gallery-nav-btn';
|
|
overlayNext.setAttribute('aria-label', 'Next equation');
|
|
overlayNext.textContent = '→';
|
|
overlay.appendChild(overlayNext);
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
/* Clicking the dark surround (not the content stage) closes */
|
|
overlay.addEventListener('click', function (e) {
|
|
if (e.target === overlay) closeOverlay();
|
|
});
|
|
overlayClose.addEventListener('click', closeOverlay);
|
|
overlayPrev.addEventListener('click', function (e) { e.stopPropagation(); navigate(-1); });
|
|
overlayNext.addEventListener('click', function (e) { e.stopPropagation(); navigate(+1); });
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (overlay.hasAttribute('hidden')) return;
|
|
if (e.key === 'Escape') { closeOverlay(); return; }
|
|
if (e.key === 'ArrowLeft') { navigate(-1); return; }
|
|
if (e.key === 'ArrowRight') { navigate(+1); return; }
|
|
});
|
|
}
|
|
|
|
function openOverlay(idx) {
|
|
currentIdx = idx;
|
|
/* Show before rendering — measurements (scrollWidth etc.) return 0
|
|
on elements inside display:none, so the fit loop needs the overlay
|
|
to be visible before it runs. The browser will not repaint until
|
|
JS yields, so the user sees only the final fitted size. */
|
|
overlay.removeAttribute('hidden');
|
|
renderOverlay();
|
|
overlayClose.focus();
|
|
}
|
|
|
|
function closeOverlay() {
|
|
var returnTo = currentIdx >= 0 ? focusables[currentIdx].wrapperEl : null;
|
|
overlay.setAttribute('hidden', '');
|
|
currentIdx = -1;
|
|
if (returnTo) returnTo.focus();
|
|
}
|
|
|
|
function navigate(delta) {
|
|
var next = currentIdx + delta;
|
|
if (next < 0 || next >= focusables.length) return;
|
|
currentIdx = next;
|
|
renderOverlay();
|
|
focusables[currentIdx].wrapperEl.scrollIntoView({
|
|
behavior: 'instant',
|
|
block: 'center'
|
|
});
|
|
}
|
|
|
|
function renderOverlay() {
|
|
var entry = focusables[currentIdx];
|
|
|
|
overlayGroup.textContent = entry.groupName;
|
|
|
|
if (entry.type === 'score') {
|
|
overlayBody.className = 'is-score';
|
|
overlayBody.style.overflow = 'hidden';
|
|
overlayBody.innerHTML = '';
|
|
overlayBody.appendChild(entry.svgEl.cloneNode(true));
|
|
} else {
|
|
overlayBody.className = '';
|
|
overlayBody.style.overflow = 'hidden';
|
|
|
|
/* Re-render from source, or clone rendered HTML */
|
|
if (entry.source && typeof katex !== 'undefined') {
|
|
try {
|
|
overlayBody.innerHTML = katex.renderToString(entry.source, {
|
|
displayMode: true,
|
|
throwOnError: false
|
|
});
|
|
} catch (e) {
|
|
overlayBody.innerHTML = entry.katexEl.outerHTML;
|
|
}
|
|
} else {
|
|
overlayBody.innerHTML = entry.katexEl.outerHTML;
|
|
}
|
|
|
|
/* Fit font size — set directly on .katex-display to avoid cascade.
|
|
The overlay must already be visible (not display:none) for
|
|
scrollWidth/clientWidth to return real values. */
|
|
var katexEl = overlayBody.querySelector('.katex-display');
|
|
if (katexEl) {
|
|
var maxSize = 1.4;
|
|
var minSize = 0.4;
|
|
var step = 0.05;
|
|
var fitted = false;
|
|
for (var fs = maxSize; fs >= minSize; fs -= step) {
|
|
katexEl.style.fontSize = fs + 'em';
|
|
if (overlayBody.scrollWidth <= overlayBody.clientWidth &&
|
|
overlayBody.scrollHeight <= overlayBody.clientHeight) {
|
|
fitted = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!fitted) overlayBody.style.overflow = 'auto'; /* absolute last resort */
|
|
}
|
|
}
|
|
|
|
overlayCaption.textContent = entry.caption || '';
|
|
overlayCaption.hidden = !entry.caption;
|
|
|
|
var total = focusables.length;
|
|
overlayCounter.textContent = (currentIdx + 1) + ' / ' + total;
|
|
overlayPrev.disabled = (currentIdx === 0);
|
|
overlayNext.disabled = (currentIdx === total - 1);
|
|
}
|
|
|
|
/* ============================================================
|
|
TOC INTEGRATION
|
|
============================================================ */
|
|
|
|
function patchTOC() {
|
|
var toc = document.getElementById('toc');
|
|
if (!toc || exhibits.length === 0) return;
|
|
|
|
var headings = Array.from(
|
|
document.querySelectorAll('#markdownBody :is(h1,h2,h3,h4,h5,h6)[id]')
|
|
);
|
|
|
|
var headingMap = new Map();
|
|
exhibits.forEach(function (entry) {
|
|
var nearest = null;
|
|
headings.forEach(function (h) {
|
|
if (h.compareDocumentPosition(entry.el) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
nearest = h;
|
|
}
|
|
});
|
|
if (nearest) {
|
|
if (!headingMap.has(nearest.id)) headingMap.set(nearest.id, []);
|
|
headingMap.get(nearest.id).push(entry);
|
|
}
|
|
});
|
|
|
|
toc.querySelectorAll('a[data-target]').forEach(function (link) {
|
|
var list = headingMap.get(link.dataset.target);
|
|
if (!list || list.length === 0) return;
|
|
|
|
var row = document.createElement('div');
|
|
row.className = 'toc-exhibits-inline';
|
|
|
|
list.forEach(function (entry) {
|
|
var a = document.createElement('a');
|
|
a.href = '#' + entry.id;
|
|
|
|
var badge = document.createElement('span');
|
|
badge.className = 'toc-exhibit-type-badge';
|
|
badge.textContent = entry.type;
|
|
a.appendChild(badge);
|
|
a.appendChild(document.createTextNode(entry.name));
|
|
row.appendChild(a);
|
|
});
|
|
|
|
var li = link.closest('li');
|
|
if (li) li.appendChild(row);
|
|
});
|
|
|
|
/* Contained Herein */
|
|
var tocNav = toc.querySelector('.toc-nav');
|
|
if (!tocNav) return;
|
|
|
|
var contained = document.createElement('div');
|
|
contained.className = 'toc-contained';
|
|
|
|
var toggleBtn = document.createElement('button');
|
|
toggleBtn.className = 'toc-contained-toggle';
|
|
toggleBtn.setAttribute('aria-expanded', 'false');
|
|
|
|
var arrow = document.createElement('span');
|
|
arrow.className = 'toc-contained-arrow';
|
|
arrow.textContent = '▶';
|
|
toggleBtn.appendChild(arrow);
|
|
toggleBtn.appendChild(document.createTextNode(' Contained Herein'));
|
|
contained.appendChild(toggleBtn);
|
|
|
|
var ul = document.createElement('ul');
|
|
ul.className = 'toc-contained-list';
|
|
|
|
exhibits.forEach(function (entry) {
|
|
var li = document.createElement('li');
|
|
var a = document.createElement('a');
|
|
a.href = '#' + entry.id;
|
|
|
|
var badge = document.createElement('span');
|
|
badge.className = 'toc-exhibit-type-badge';
|
|
badge.textContent = entry.type;
|
|
a.appendChild(badge);
|
|
a.appendChild(document.createTextNode(entry.name));
|
|
li.appendChild(a);
|
|
ul.appendChild(li);
|
|
});
|
|
|
|
contained.appendChild(ul);
|
|
tocNav.appendChild(contained);
|
|
|
|
toggleBtn.addEventListener('click', function () {
|
|
var open = contained.classList.toggle('is-open');
|
|
toggleBtn.setAttribute('aria-expanded', String(open));
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
ANNOTATIONS (collapsible .annotation--collapsible boxes)
|
|
============================================================ */
|
|
|
|
function initAnnotations() {
|
|
document.querySelectorAll('.annotation--collapsible').forEach(function (el) {
|
|
var toggle = el.querySelector('.annotation-toggle');
|
|
var body = el.querySelector('.annotation-body');
|
|
if (!toggle || !body) return;
|
|
|
|
function setOpen(open) {
|
|
el.classList.toggle('is-open', open);
|
|
toggle.setAttribute('aria-expanded', String(open));
|
|
toggle.textContent = open ? '▾ collapse' : '▸ expand';
|
|
body.style.maxHeight = open ? body.scrollHeight + 'px' : '0';
|
|
}
|
|
|
|
setOpen(false);
|
|
toggle.addEventListener('click', function () {
|
|
setOpen(!el.classList.contains('is-open'));
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
INIT
|
|
============================================================ */
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
var markdownBody = document.getElementById('markdownBody');
|
|
|
|
discoverExhibits();
|
|
exhibits.forEach(function (entry) {
|
|
if (entry.type === 'proof') initProofExhibit(entry);
|
|
});
|
|
|
|
if (markdownBody) {
|
|
discoverFocusableMath(markdownBody);
|
|
discoverFocusableScores(markdownBody);
|
|
if (focusables.length > 0) buildOverlay();
|
|
}
|
|
|
|
if (exhibits.length > 0) patchTOC();
|
|
|
|
initAnnotations();
|
|
});
|
|
|
|
})();
|