From 4ae381fdc0868c14e7417df7ea8c7e9cf95a1f5c Mon Sep 17 00:00:00 2001 From: Munir Hiabu Date: Thu, 12 Nov 2026 15:05:47 +0100 Subject: [PATCH] Website: the articles as a book, with a chapter panel on every article The articles read as numbered chapters in three parts, in the order of `articles:` in _pkgdown.yml (Get started is chapter 1). On an article page the right sidebar shows the chapters, with the current chapter's sections nested and numbered (5.1, 5.1.1) and the section in view highlighted; the other chapters' sections unfold on demand. The headings carry the same numbers, and each article ends with links to the previous and the next chapter. On a phone the panel is a collapsed box below the title. The articles index numbers its entries the same way. pkgdown/extra.js reads the chapters from the navbar that pkgdown builds from `articles:`, so a new article is one line in that list. --- _pkgdown.yml | 10 ++ pkgdown/extra.css | 117 +++++++++++++ pkgdown/extra.js | 271 ++++++++++++++++++++++++++++++ vignettes/randomPlantedForest.Rmd | 3 + 4 files changed, 401 insertions(+) create mode 100644 pkgdown/extra.js diff --git a/_pkgdown.yml b/_pkgdown.yml index 78bbe7c..4d9aca7 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -15,7 +15,17 @@ navbar: text: News href: news/index.html +# The articles are read as a book: chapter 1 is Get started, then the articles +# below in this order, grouped into parts by `navbar`. The chapter panel, the +# numbers and the previous / next links follow this list (pkgdown/extra.js). +# A new chapter is one line in `contents`; a new part is one more entry with +# `title`, `navbar`, `desc` and `contents`. articles: +- title: Get started + navbar: ~ + contents: + - randomPlantedForest + - title: Understanding the model navbar: Understanding the model desc: What a fitted model is made of, and how the forest behind it is grown. diff --git a/pkgdown/extra.css b/pkgdown/extra.css index 8433173..63ed0ae 100644 --- a/pkgdown/extra.css +++ b/pkgdown/extra.css @@ -7,3 +7,120 @@ hyphens: none; } } + +/* The articles as a book (see extra.js): section numbers, the chapter panel, + previous / next chapter. Colours come from Bootstrap's variables, so light + and dark mode both work. */ +.section-number { + margin-right: 0.5em; + color: var(--bs-secondary-color); + font-variant-numeric: tabular-nums; +} +.book-panel h2 { margin-top: 1.5rem; font-size: 1.25rem; } +.book-panel ol { list-style: none; margin: 0; padding: 0; } +.book-panel .book-part { + margin: 0.9rem 0 0.2rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--bs-secondary-color); +} +.book-panel .book-row { display: flex; align-items: flex-start; } +.book-panel a { + display: flex; + flex: 1 1 auto; + gap: 0.5em; + padding: 0.25rem 0.5rem; + margin-bottom: 2px; + border-radius: var(--bs-border-radius); + color: inherit; + text-decoration: none; + line-height: 1.3; +} +.book-panel a:hover, .book-panel a:focus { + background-color: rgba(var(--bs-primary-rgb), 0.1); +} +.book-panel .book-num { + flex: 0 0 auto; + min-width: 1.2em; + color: var(--bs-secondary-color); + font-variant-numeric: tabular-nums; +} +.book-panel .book-chapter.current > .book-row > a { + font-weight: 600; + color: var(--bs-link-color); + background-color: rgba(var(--bs-primary-rgb), 0.08); +} +.book-panel code { font-size: 0.9em; padding: 0; background: none; color: inherit; } +.book-panel .book-sections { margin-left: 0.75rem; font-size: 0.9em; } +.book-panel .book-sections .book-sections { margin-left: 1rem; } +.book-panel .book-sections .book-num { min-width: 2em; } +.book-panel .book-sections .book-sections .book-num { min-width: 2.8em; } +.book-panel .book-sections a.active { + background-color: rgba(var(--bs-body-color-rgb), 0.1); + font-weight: 600; +} +.book-panel .book-unfold { + flex: 0 0 auto; + width: 1.6rem; + height: 1.6rem; + margin-top: 0.1rem; + border: 0; + border-radius: var(--bs-border-radius); + background: none; + color: var(--bs-secondary-color); +} +.book-panel .book-unfold::before { + content: ""; + display: inline-block; + border: solid currentColor; + border-width: 0 1.5px 1.5px 0; + padding: 2.5px; + transform: rotate(-45deg); + transition: transform 0.15s; +} +.book-panel .book-unfold[aria-expanded="true"]::before { transform: rotate(45deg); } +.book-panel .book-unfold:hover { background-color: rgba(var(--bs-primary-rgb), 0.1); } + +/* Wide screens: the panel stays in view in the sidebar. */ +@media (min-width: 768px) { + .book-panel-side { + position: sticky; + top: 56px; + max-height: calc(100vh - 56px - 1rem); + overflow-y: auto; + } + .book-panel-phone { display: none; } +} +/* Phones: a collapsed "Chapters" box below the title, no sidebar. */ +@media (max-width: 767.98px) { + .row > aside.book-aside { display: none; } + .book-panel-phone { + margin: 0 0 1.5rem; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + background-color: rgba(var(--bs-body-color-rgb), 0.04); + } + .book-panel-phone > summary { padding: 0.5rem 0.75rem; font-weight: 600; } + .book-panel-phone .book-panel { padding: 0 0.5rem 0.5rem; } + .book-panel-phone .book-panel h2 { display: none; } +} + +.book-pager { + display: flex; + justify-content: space-between; + gap: 1rem; + margin: 3rem 0 1rem; + padding-top: 1rem; + border-top: 1px solid var(--bs-border-color); +} +.book-pager a { + display: flex; + flex-direction: column; + max-width: 48%; + text-decoration: none; +} +.book-pager .book-pager-next { margin-left: auto; text-align: right; } +.book-pager-label { font-size: 0.85em; color: var(--bs-secondary-color); } +.book-pager-title { font-weight: 600; } diff --git a/pkgdown/extra.js b/pkgdown/extra.js new file mode 100644 index 0000000..7c84d26 --- /dev/null +++ b/pkgdown/extra.js @@ -0,0 +1,271 @@ +/* The articles as a book. + + The chapters, their order and the parts are read from the navbar, which + pkgdown builds from `articles:` in _pkgdown.yml: chapter 1 is "Get started", + then every article of the Articles menu in order, grouped by the menu's + headings (the parts). Adding an article to `articles:` adds a chapter and + renumbers the ones after it; nothing here needs to change. + + On a chapter page this script + - numbers the title and the section headings (5, 5.1, 5.1.1), + - shows the chapters in the right sidebar in place of "On this page", with + the current chapter's sections nested under it and the section in view + highlighted (on a phone, a collapsed "Chapters" box below the title), + - adds links to the previous and the next chapter at the bottom. + On the articles index it numbers the entries the same way. */ +(function () { + "use strict"; + + function pathOf(href) { + var p = new URL(href, document.baseURI).pathname; + return p.replace(/\/index\.html$/, "/").replace(/\.html$/, ""); + } + + function el(tag, attrs, children) { + var e = document.createElement(tag); + Object.keys(attrs || {}).forEach(function (k) { + if (k === "text") e.textContent = attrs[k]; + else e.setAttribute(k, attrs[k]); + }); + (children || []).forEach(function (c) { if (c) e.appendChild(c); }); + return e; + } + + /* The book: [{number, title, href, path, part}] in reading order. */ + function readBook() { + var chapters = []; + var add = function (a, part) { + if (/\/articles\/(index\.html)?$/.test(new URL(a.href).pathname)) return; + chapters.push({ title: a.textContent.trim(), href: a.href, path: pathOf(a.href), part: part }); + }; + document.querySelectorAll("#navbar .navbar-nav > li > a.nav-link").forEach(function (a) { + if (/\/articles\/[^/]+\.html$/.test(new URL(a.href).pathname)) add(a, null); + }); + var menu = document.querySelector('#dropdown-articles + .dropdown-menu'); + var part = null; + if (menu) { + menu.querySelectorAll(".dropdown-header, a.dropdown-item").forEach(function (x) { + if (x.classList.contains("dropdown-header")) part = x.textContent.trim(); + else add(x, part); + }); + } + chapters.forEach(function (c, i) { c.number = i + 1; }); + return chapters; + } + + /* Number the section headings of a chapter page (or of a fetched one) and + return them as a tree [{number, title, id, children}]. */ + function numberSections(root, chapter, edit) { + var tree = []; + var walk = function (container, level, prefix, into) { + var k = 0; + container.querySelectorAll(":scope > .section.level" + level).forEach(function (sec) { + var h = sec.querySelector(":scope > h" + level); + if (!h || h.hasAttribute("data-toc-skip")) return; + k += 1; + var number = prefix + "." + k; + var copy = h.cloneNode(true); + copy.querySelectorAll(".anchor, .section-number").forEach(function (x) { x.remove(); }); + var node = { number: number, title: copy.innerHTML.trim(), id: sec.id || h.id, heading: h, children: [] }; + if (edit) h.insertBefore(el("span", { class: "section-number", text: number }), h.firstChild); + into.push(node); + if (level < 3) walk(sec, level + 1, number, node.children); + }); + }; + var main = root.querySelector("main"); + if (main) { + // the sections are children of main, or of a wrapper div + var top = main.querySelector(".section.level2"); + if (top) walk(top.parentElement, 2, String(chapter.number), tree); + } + return tree; + } + + function sectionList(nodes, href) { + if (!nodes.length) return null; + return el("ol", { class: "book-sections" }, nodes.map(function (n) { + var a = el("a", { href: href + "#" + n.id, "data-target": n.id }, [ + el("span", { class: "book-num", text: n.number }), + el("span", { class: "book-title" }) + ]); + a.lastChild.innerHTML = n.title; // the heading's own markup, e.g. + return el("li", {}, [a, sectionList(n.children, href)]); + })); + } + + function chapterLink(c, current) { + var a = el("a", { href: c.href, class: "book-chapter-link" }, [ + el("span", { class: "book-num", text: String(c.number) }), + el("span", { class: "book-title", text: c.title }) + ]); + if (current) a.setAttribute("aria-current", "page"); + return a; + } + + /* The other chapters' sections are read from their pages when asked for. */ + var fetched = {}; + function unfoldButton(c, li) { + var b = el("button", { type: "button", class: "book-unfold", "aria-expanded": "false", + "aria-label": "Sections of chapter " + c.number }); + b.addEventListener("click", function () { + var open = b.getAttribute("aria-expanded") === "true"; + var list = li.querySelector(":scope > .book-sections"); + if (open) { + b.setAttribute("aria-expanded", "false"); + if (list) list.hidden = true; + return; + } + b.setAttribute("aria-expanded", "true"); + if (list) { list.hidden = false; return; } + (fetched[c.path] = fetched[c.path] || fetch(c.href).then(function (r) { + if (!r.ok) throw new Error(r.status); + return r.text(); + }).then(function (html) { + var doc = new DOMParser().parseFromString(html, "text/html"); + return numberSections(doc, c, false); + })).then(function (tree) { + var ol = sectionList(tree, c.href); + if (ol) li.appendChild(ol); + }).catch(function () { + b.hidden = true; + }); + }); + return b; + } + + function buildPanel(book, current, tree, idSuffix) { + var nav = el("nav", { class: "book-panel", "aria-label": "Chapters" }); + var list = el("ol", { class: "book-chapters" }); + var canFetch = location.protocol !== "file:"; + var part; + book.forEach(function (c) { + if (c.part !== null && c.part !== part) { + list.appendChild(el("li", { class: "book-part", text: c.part })); + } + part = c.part; + var isCurrent = current && c.path === current.path; + var li = el("li", { class: "book-chapter" + (isCurrent ? " current" : "") }); + var row = el("div", { class: "book-row" }, [chapterLink(c, isCurrent)]); + li.appendChild(row); + if (isCurrent) { + var ol = sectionList(tree, ""); + if (ol) li.appendChild(ol); + } else if (canFetch) { + row.appendChild(unfoldButton(c, li)); + } + list.appendChild(li); + }); + nav.appendChild(el("h2", { text: "Chapters" })); + nav.appendChild(list); + return nav; + } + + function pager(book, i) { + var prev = book[i - 1], next = book[i + 1]; + var side = function (c, rel, label) { + if (!c) return el("span"); + return el("a", { href: c.href, rel: rel, class: "book-pager-" + rel }, [ + el("span", { class: "book-pager-label", text: label }), + el("span", { class: "book-pager-title", text: c.number + " " + c.title }) + ]); + }; + return el("nav", { class: "book-pager", "aria-label": "Previous and next chapter" }, [ + side(prev, "prev", "← Previous chapter"), + side(next, "next", "Next chapter →") + ]); + } + + /* Highlight the section in view, in every panel on the page. */ + function spy(tree, panels) { + var flat = []; + (function f(ns) { ns.forEach(function (n) { flat.push(n); f(n.children); }); })(tree); + if (!flat.length) return; + var last = null; + var update = function () { + var cut = Math.max(100, window.innerHeight * 0.25); + var active = null; + flat.forEach(function (n) { + if (n.heading.getBoundingClientRect().top <= cut) active = n; + }); + // at the end of the page, the last section counts as in view + var end = document.documentElement; + if (window.innerHeight + window.scrollY >= end.scrollHeight - 2) active = flat[flat.length - 1]; + var id = active ? active.id : null; + if (id === last) return; + last = id; + panels.forEach(function (p) { + p.querySelectorAll("a[data-target]").forEach(function (a) { + var on = a.getAttribute("data-target") === id; + a.classList.toggle("active", on); + if (on && p.offsetParent !== null && p.scrollHeight > p.clientHeight) { + var top = a.offsetTop - p.offsetTop; + if (top < p.scrollTop || top > p.scrollTop + p.clientHeight - 40) { + p.scrollTop = top - p.clientHeight / 3; + } + } + }); + }); + }; + var queued = false; + window.addEventListener("scroll", function () { + if (queued) return; + queued = true; + requestAnimationFrame(function () { queued = false; update(); }); + }, { passive: true }); + update(); + } + + function chapterPage(book, i) { + var c = book[i]; + var main = document.querySelector("main#main"); + var header = main.querySelector(".page-header"); + var h1 = header && header.querySelector("h1"); + if (h1) h1.insertBefore(el("span", { class: "section-number", text: String(c.number) }), h1.firstChild); + var tree = numberSections(document, c, true); + + var aside = main.parentElement.querySelector(":scope > aside"); + var panels = []; + if (aside) { + var toc = aside.querySelector("#toc"); + if (toc) toc.hidden = true; + var side = buildPanel(book, c, tree); + side.classList.add("book-panel-side"); + aside.insertBefore(side, aside.firstChild); + aside.classList.add("book-aside"); + panels.push(side); + } + var box = el("details", { class: "book-panel-phone" }, [el("summary", { text: "Chapters" })]); + var phone = buildPanel(book, c, tree); + box.appendChild(phone); + box.addEventListener("click", function (e) { + if (e.target.closest && e.target.closest("a")) box.open = false; + }); + if (header) header.after(box); + panels.push(phone); + + main.appendChild(pager(book, i)); + spy(tree, panels); + } + + function indexPage(book) { + var byPath = {}; + book.forEach(function (c) { byPath[c.path] = c; }); + document.querySelectorAll("main#main dl dt a").forEach(function (a) { + var c = byPath[pathOf(a.href)]; + if (!c) return; + var dt = a.closest("dt"); + dt.insertBefore(el("span", { class: "section-number", text: String(c.number) }), dt.firstChild); + dt.classList.add("book-entry"); + }); + } + + document.addEventListener("DOMContentLoaded", function () { + var book = readBook(); + if (!book.length) return; + var here = pathOf(location.href); + for (var i = 0; i < book.length; i++) { + if (book[i].path === here) return chapterPage(book, i); + } + if (document.querySelector(".template-article-index")) indexPage(book); + }); +})(); diff --git a/vignettes/randomPlantedForest.Rmd b/vignettes/randomPlantedForest.Rmd index aaf6deb..901777c 100644 --- a/vignettes/randomPlantedForest.Rmd +++ b/vignettes/randomPlantedForest.Rmd @@ -1,5 +1,8 @@ --- title: "Getting Started with randomPlantedForest" +description: > + A first model, its predictions and components, classification, and the + settings you may want. output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Getting Started with randomPlantedForest}