|
8 | 8 | {{- $scrollLabel := "Scroll" }} |
9 | 9 | {{- if eq $lang "es" }}{{ $scrollLabel = "Bajar" }}{{ end }} |
10 | 10 | {{- if eq $lang "cn" }}{{ $scrollLabel = "向下" }}{{ end }} |
| 11 | +{{- $tocLabel := "Table of contents" }} |
| 12 | +{{- if eq $lang "es" }}{{ $tocLabel = "Índice" }}{{ end }} |
| 13 | +{{- if eq $lang "cn" }}{{ $tocLabel = "目录" }}{{ end }} |
11 | 14 | <html lang="{{ $htmlLang }}"> |
12 | 15 | <head> |
13 | 16 | <meta charset="UTF-8" /> |
|
378 | 381 | /* Back-reference arrow */ |
379 | 382 | .article-inner .footnote-backref { margin-left: 4px; } |
380 | 383 |
|
| 384 | + /* ───────────────────────────────────────────────────────────── |
| 385 | + TABLE OF CONTENTS — fixed left gutter, non-intrusive |
| 386 | + Visible only on viewports wide enough (≥ 1180px) to have |
| 387 | + left gutter space beside the 720px centred article column. |
| 388 | + ───────────────────────────────────────────────────────────── */ |
| 389 | + #toc-nav { |
| 390 | + position: fixed; |
| 391 | + /* Vertically centred in viewport */ |
| 392 | + top: 50%; |
| 393 | + transform: translateY(-50%); |
| 394 | + /* Left-gutter: gap from article left edge ≈ 16px */ |
| 395 | + left: max(12px, calc(50vw - 576px)); |
| 396 | + width: 200px; |
| 397 | + max-height: 76vh; |
| 398 | + overflow-y: auto; |
| 399 | + overflow-x: hidden; |
| 400 | + scrollbar-width: none; |
| 401 | + z-index: 49; |
| 402 | + /* Hidden until article is fully in view */ |
| 403 | + opacity: 0; |
| 404 | + pointer-events: none; |
| 405 | + transition: opacity 0.55s ease; |
| 406 | + } |
| 407 | + #toc-nav::-webkit-scrollbar { display: none; } |
| 408 | + |
| 409 | + /* Revealed by JS once article is fully scrolled into view */ |
| 410 | + #toc-nav.toc-visible { |
| 411 | + opacity: 1; |
| 412 | + pointer-events: auto; |
| 413 | + } |
| 414 | + |
| 415 | + #toc-nav ul { |
| 416 | + list-style: none; |
| 417 | + margin: 0; |
| 418 | + padding: 0; |
| 419 | + } |
| 420 | + #toc-nav li { |
| 421 | + margin: 0; |
| 422 | + padding: 0; |
| 423 | + } |
| 424 | + |
| 425 | + /* Base link style — h2 chapters (primary nav items) */ |
| 426 | + .toc-link { |
| 427 | + display: block; |
| 428 | + font-family: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; |
| 429 | + font-size: 11px; |
| 430 | + line-height: 1.45; |
| 431 | + font-weight: 400; |
| 432 | + color: rgba(230, 237, 243, 0.28); |
| 433 | + text-decoration: none; |
| 434 | + padding: 2.5px 4px 2.5px 11px; |
| 435 | + border-left: 1.5px solid transparent; |
| 436 | + /* Single-line with ellipsis for chapters */ |
| 437 | + white-space: nowrap; |
| 438 | + overflow: hidden; |
| 439 | + text-overflow: ellipsis; |
| 440 | + transition: color 0.18s ease, border-color 0.18s ease; |
| 441 | + } |
| 442 | + .toc-link:hover { |
| 443 | + color: rgba(230, 237, 243, 0.78); |
| 444 | + text-decoration: none; |
| 445 | + } |
| 446 | + .toc-link.toc-active { |
| 447 | + color: rgba(154, 180, 255, 0.92); |
| 448 | + border-left-color: rgba(154, 180, 255, 0.48); |
| 449 | + font-weight: 500; |
| 450 | + } |
| 451 | + |
| 452 | + /* h1 PART headings — dim uppercase section-divider labels */ |
| 453 | + .toc-link.toc-h1 { |
| 454 | + font-size: 9px; |
| 455 | + font-weight: 700; |
| 456 | + letter-spacing: 0.09em; |
| 457 | + text-transform: uppercase; |
| 458 | + color: rgba(154, 180, 255, 0.20); |
| 459 | + padding-left: 0; |
| 460 | + border-left: none; |
| 461 | + margin-top: 10px; |
| 462 | + /* Allow wrapping so long translated PART titles fit */ |
| 463 | + white-space: normal; |
| 464 | + overflow: visible; |
| 465 | + text-overflow: clip; |
| 466 | + line-height: 1.3; |
| 467 | + } |
| 468 | + .toc-link.toc-h1:hover { |
| 469 | + color: rgba(154, 180, 255, 0.52); |
| 470 | + } |
| 471 | + .toc-link.toc-h1.toc-active { |
| 472 | + color: rgba(154, 180, 255, 0.52); |
| 473 | + border-left: none; |
| 474 | + } |
| 475 | + |
| 476 | + /* Hide entirely on viewports too narrow for the left gutter */ |
| 477 | + @media (max-width: 1180px) { |
| 478 | + #toc-nav { display: none; } |
| 479 | + } |
| 480 | + |
381 | 481 | /* ───────────────────────────────────────────────────────────── |
382 | 482 | KEYFRAMES |
383 | 483 | ───────────────────────────────────────────────────────────── */ |
@@ -461,6 +561,12 @@ <h1>{{ .Title }}</h1> |
461 | 561 | </div> |
462 | 562 | </div> |
463 | 563 |
|
| 564 | + <!-- ═══════════════════════════════════════════════════════════ |
| 565 | + TABLE OF CONTENTS — built by JS, fixed to the left gutter. |
| 566 | + Language-agnostic: reads headings from the rendered DOM. |
| 567 | + ═══════════════════════════════════════════════════════════ --> |
| 568 | + <nav id="toc-nav" role="navigation" aria-label="{{ $tocLabel }}"></nav> |
| 569 | + |
464 | 570 |
|
465 | 571 | <!-- ═══════════════════════════════════════════════════════════ |
466 | 572 | CANVAS ANIMATION — particle swarm visualization |
@@ -988,5 +1094,121 @@ <h1>{{ .Title }}</h1> |
988 | 1094 | })(); |
989 | 1095 | </script> |
990 | 1096 |
|
| 1097 | + |
| 1098 | + <!-- ═══════════════════════════════════════════════════════════ |
| 1099 | + TABLE OF CONTENTS — scroll-driven, language-agnostic. |
| 1100 | + Builds itself from the rendered heading elements so it works |
| 1101 | + for English, Spanish, and Chinese with zero extra markup. |
| 1102 | + ═══════════════════════════════════════════════════════════ --> |
| 1103 | + <script> |
| 1104 | + (() => { |
| 1105 | + 'use strict'; |
| 1106 | + |
| 1107 | + const tocNav = document.getElementById('toc-nav'); |
| 1108 | + const articleInner = document.querySelector('#article-wrap .article-inner'); |
| 1109 | + if (!tocNav || !articleInner) return; |
| 1110 | + |
| 1111 | + /* ── Build TOC list from h1 + h2 headings ──────────────── */ |
| 1112 | + const ul = document.createElement('ul'); |
| 1113 | + tocNav.appendChild(ul); |
| 1114 | + |
| 1115 | + // Collect every h1 and h2 in document order |
| 1116 | + const allHeadings = [...articleInner.querySelectorAll('h1, h2')]; |
| 1117 | + const items = []; // { el, id, link } |
| 1118 | + let firstH1Skipped = false; |
| 1119 | + |
| 1120 | + allHeadings.forEach(el => { |
| 1121 | + // Hugo goldmark always adds an id to headings; skip any that don't have one |
| 1122 | + if (!el.id) return; |
| 1123 | + |
| 1124 | + const tag = el.tagName.toLowerCase(); // 'h1' or 'h2' |
| 1125 | + const text = el.textContent.trim(); |
| 1126 | + |
| 1127 | + // Skip the document-title h1 (first h1 in .article-inner) |
| 1128 | + // It duplicates the title already shown in the fullscreen viz |
| 1129 | + if (tag === 'h1' && !firstH1Skipped) { |
| 1130 | + firstH1Skipped = true; |
| 1131 | + return; |
| 1132 | + } |
| 1133 | + |
| 1134 | + const a = document.createElement('a'); |
| 1135 | + a.href = '#' + el.id; |
| 1136 | + a.textContent = text; |
| 1137 | + a.title = text; // full text tooltip when truncated |
| 1138 | + a.className = 'toc-link toc-' + tag; |
| 1139 | + a.dataset.tocId = el.id; |
| 1140 | + |
| 1141 | + // Smooth-scroll on click without changing the URL hash abruptly |
| 1142 | + a.addEventListener('click', e => { |
| 1143 | + e.preventDefault(); |
| 1144 | + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| 1145 | + }); |
| 1146 | + |
| 1147 | + const li = document.createElement('li'); |
| 1148 | + li.appendChild(a); |
| 1149 | + ul.appendChild(li); |
| 1150 | + items.push({ el, id: el.id, link: a }); |
| 1151 | + }); |
| 1152 | + |
| 1153 | + /* ── Active-section tracking ───────────────────────────── */ |
| 1154 | + let rafPending = false; |
| 1155 | + let lastActiveId = null; |
| 1156 | + |
| 1157 | + function updateToc() { |
| 1158 | + rafPending = false; |
| 1159 | + |
| 1160 | + const scrollY = window.scrollY; |
| 1161 | + const vh = window.innerHeight; |
| 1162 | + |
| 1163 | + // TOC is only shown after the viz has fully scrolled away |
| 1164 | + if (scrollY >= vh) { |
| 1165 | + tocNav.classList.add('toc-visible'); |
| 1166 | + } else { |
| 1167 | + tocNav.classList.remove('toc-visible'); |
| 1168 | + return; // nothing more to do while in the viz |
| 1169 | + } |
| 1170 | + |
| 1171 | + // Active item = the last heading whose top edge is at or above |
| 1172 | + // 15% down the viewport (a comfortable reading-ahead threshold) |
| 1173 | + const threshold = Math.min(120, vh * 0.15); |
| 1174 | + let activeId = null; |
| 1175 | + |
| 1176 | + for (const { el, id } of items) { |
| 1177 | + if (el.getBoundingClientRect().top <= threshold) activeId = id; |
| 1178 | + } |
| 1179 | + |
| 1180 | + // Only update DOM if the active section actually changed |
| 1181 | + if (activeId === lastActiveId) return; |
| 1182 | + lastActiveId = activeId; |
| 1183 | + |
| 1184 | + let activeLink = null; |
| 1185 | + items.forEach(({ id, link }) => { |
| 1186 | + const on = (id === activeId); |
| 1187 | + link.classList.toggle('toc-active', on); |
| 1188 | + if (on) activeLink = link; |
| 1189 | + }); |
| 1190 | + |
| 1191 | + // Auto-scroll the TOC panel so the active item stays visible |
| 1192 | + if (activeLink) { |
| 1193 | + const navBox = tocNav.getBoundingClientRect(); |
| 1194 | + const lnkBox = activeLink.getBoundingClientRect(); |
| 1195 | + if (lnkBox.top < navBox.top + 20 || lnkBox.bottom > navBox.bottom - 20) { |
| 1196 | + activeLink.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); |
| 1197 | + } |
| 1198 | + } |
| 1199 | + } |
| 1200 | + |
| 1201 | + window.addEventListener('scroll', () => { |
| 1202 | + if (!rafPending) { |
| 1203 | + rafPending = true; |
| 1204 | + requestAnimationFrame(updateToc); |
| 1205 | + } |
| 1206 | + }, { passive: true }); |
| 1207 | + |
| 1208 | + // Handle the case where the page is loaded/refreshed mid-scroll |
| 1209 | + updateToc(); |
| 1210 | + })(); |
| 1211 | + </script> |
| 1212 | + |
991 | 1213 | </body> |
992 | 1214 | </html> |
0 commit comments