1/* Page minimap: floating table of contents with scroll tracking (desktop only). */ 2(function () { 3 function make(tag, cls, text) { 4 const n = document.createElement(tag); 5 if (cls) n.className = cls; 6 if (text != null) n.textContent = text; 7 return n; 8 } 9 10 function storage(key, value) { 11 try { 12 if (value === undefined) return localStorage.getItem(key); 13 localStorage.setItem(key, value); 14 } catch (e) { return null; } 15 } 16 17 window.buildMinimap = function (entries, opts) { 18 opts = opts || {}; 19 entries = entries.filter(e => e && e.el); 20 if (!entries.length || document.querySelector('.mm')) return; 21 const offset = opts.offset || 72; 22 23 const root = make('nav', 'mm'); 24 if (opts.top) root.style.setProperty('--mm-top', opts.top); 25 if (opts.accent) root.style.setProperty('--mm-accent', opts.accent); 26 root.setAttribute('aria-label', 'Page minimap'); 27 const head = make('div', 'mm-head'); 28 head.appendChild(make('span', 'mm-title', opts.title || 'on this page')); 29 const toggle = make('button', 'mm-toggle'); 30 toggle.type = 'button'; 31 head.appendChild(toggle); 32 root.appendChild(head); 33 34 const body = make('div', 'mm-body'); 35 const rail = make('div', 'mm-rail-track'); 36 const fill = make('span', 'mm-rail-fill'); 37 rail.appendChild(fill); 38 body.appendChild(rail); 39 const list = make('ol', 'mm-list'); 40 body.appendChild(list); 41 root.appendChild(body); 42 43 const flat = []; 44 let locked = null; 45 function addLink(parentList, entry, level, parent) { 46 const li = make('li', 'mm-item mm-level-' + level); 47 const a = make('a', 'mm-link'); 48 a.href = '#'; 49 a.title = entry.label; 50 a.appendChild(make('span', 'mm-dot')); 51 a.appendChild(make('span', 'mm-label', entry.label)); 52 li.appendChild(a); 53 parentList.appendChild(li); 54 const rec = { a, li, el: entry.el, parent }; 55 flat.push(rec); 56 a.addEventListener('click', ev => { 57 ev.preventDefault(); 58 const details = entry.el.closest && entry.el.closest('details'); 59 if (details && !details.open) details.open = true; 60 const y = entry.el.getBoundingClientRect().top + window.scrollY - offset; 61 locked = rec; 62 setActive(rec); 63 window.scrollTo({ top: y, behavior: 'smooth' }); 64 }); 65 if (entry.children && entry.children.length) { 66 const sub = make('ol', 'mm-sub'); 67 li.appendChild(sub); 68 entry.children.filter(c => c && c.el).forEach(c => addLink(sub, c, level + 1, rec)); 69 } 70 } 71 entries.forEach(e => addLink(list, e, 0, null)); 72 document.body.appendChild(root); 73 74 function setCollapsed(c) { 75 root.classList.toggle('mm-collapsed', c); 76 toggle.textContent = c ? '+' : 'â'; 77 toggle.setAttribute('aria-label', c ? 'Show minimap' : 'Hide minimap'); 78 toggle.setAttribute('aria-expanded', String(!c)); 79 storage('mm-collapsed', c ? '1' : '0'); 80 } 81 toggle.addEventListener('click', () => setCollapsed(!root.classList.contains('mm-collapsed'))); 82 setCollapsed(storage('mm-collapsed') === '1'); 83 84 function fit() {
85 const ref = opts.contentEl && opts.contentEl(); 86 if (!ref) return; 87 const free = (window.innerWidth - ref.getBoundingClientRect().width) / 2; 88 root.classList.toggle('mm-slim', free < 226); 89 } 90 91 let current = null; 92 function setActive(active) { 93 if (active !== current) { 94 flat.forEach(r => r.li.classList.remove('mm-active', 'mm-active-parent')); 95 active.li.classList.add('mm-active'); 96 for (let p = active.parent; p; p = p.parent) p.li.classList.add('mm-active-parent'); 97 current = active; 98 const lt = active.li.offsetTop, lh = active.li.offsetHeight; 99 if (lt < list.scrollTop || lt + lh > list.scrollTop + list.clientHeight) { 100 list.scrollTo({ top: lt - list.clientHeight / 3, behavior: 'smooth' }); 101 } 102 } 103 } 104 function spy() { 105 if (!locked) { 106 const line = offset + 48; 107 let active = null; 108 for (const r of flat) { 109 if (r.el.offsetParent === null && r.el.tagName !== 'DETAILS') continue; 110 if (r.el.getBoundingClientRect().top <= line) active = r; 111 } 112 setActive(active || flat[0]); 113 } 114 const max = document.documentElement.scrollHeight - window.innerHeight; 115 fill.style.height = (max > 0 ? Math.min(1, window.scrollY / max) * 100 : 0) + '%'; 116 } 117 let ticking = false; 118 const onScroll = () => { 119 if (ticking) return; 120 ticking = true; 121 requestAnimationFrame(() => { ticking = false; spy(); }); 122 }; 123 window.addEventListener('scroll', onScroll, { passive: true }); 124 const unlock = () => { if (locked) { locked = null; onScroll(); } }; 125 ['wheel', 'touchstart', 'keydown', 'mousedown'].forEach(t => window.addEventListener(t, e => { 126 if (!root.contains(e.target)) unlock(); 127 }, { passive: true })); 128 window.addEventListener('resize', () => { fit(); spy(); }); 129 fit(); 130 spy(); 131 }; 132 133 /* Project pages: numbered <section>s with figure blocks underneath. */ 134 window.minimapFromSections = function () { 135 const clean = t => t.replace(/\s+/g, ' ').replace(/^Fig\.\s*[â-]\s*/i, '').trim(); 136 const entries = []; 137 document.querySelectorAll('main > section[id]').forEach(sec => { 138 if (sec.id === 'home') return; 139 const h2 = sec.querySelector('h2'); 140 if (!h2) return; 141 const children = []; 142 sec.querySelectorAll('.figure-block').forEach(b => { 143 const lab = b.querySelector('.figure-label'); 144 if (lab) children.push({ label: clean(lab.textContent), el: b }); 145 }); 146 entries.push({ label: clean(h2.textContent), el: sec, children }); 147 }); 148 buildMinimap(entries, { offset: 76, contentEl: () => document.querySelector('main > section:not(#home) .container') }); 149 }; 150})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.