parent
bb8ce65ec9
commit
218a0547e3
File diff suppressed because one or more lines are too long
@ -0,0 +1,190 @@ |
||||
--- |
||||
title: "Mapping Dutch Democracy: Building a Political Compass from 29,000+ Parliamentary Votes" |
||||
author: "Stemwijzer Analysis" |
||||
date: today |
||||
format: html |
||||
--- |
||||
|
||||
*What if you could take every motion voted on in the Dutch Parliament over the past decade and automatically plot parties and MPs on a political map — with zero manual labeling?* |
||||
|
||||
That's exactly what this project does. Here's how I built it, what I had to solve along the way, and what it revealed about Dutch political dynamics. |
||||
|
||||
--- |
||||
|
||||
## The Starting Point: Open Data, Hidden Structure |
||||
|
||||
The Dutch Parliament publishes every vote — every *motie*, every *amendement*, every *besluit* — in an open OData API. We're talking over **29,500 motions** spanning 2016 to 2026, with a record of how every individual MP voted: *voor* (for), *tegen* (against), *onthouden* (abstained), or *afwezig* (absent). That's 531,000 individual vote records. |
||||
|
||||
This is an extraordinary dataset. But in raw form it's just a table of votes. The interesting question is: can we extract *structure* — left vs. right, progressive vs. conservative, governing vs. opposition — purely from the pattern of who votes with whom? |
||||
|
||||
The answer is yes, and the method is surprisingly elegant. |
||||
|
||||
--- |
||||
|
||||
## Step 1: Turning Votes into Geometry |
||||
|
||||
Each motion is a snapshot of political alignment. For each motion, we know which MPs voted together and which voted apart. If every PvdA and GroenLinks MP votes the same way almost every time, that tells us something. If PVV and CDA MPs diverge consistently, that tells us something too. |
||||
|
||||
I represent this with **Singular Value Decomposition (SVD)** on the MP × motion matrix: |
||||
|
||||
- Rows: individual MPs (and party actors for collective votes) |
||||
- Columns: motions |
||||
- Values: +1 (voor), -1 (tegen), 0 (absent/abstain) |
||||
|
||||
SVD finds the dominant axes of variation — the directions along which the chamber disagrees most. The first component almost always corresponds to a left-right axis. The second typically captures something like progressive-traditionalist or libertarian-authoritarian. The key point: **the axes emerge from the math, not from any labeling on my part.** |
||||
|
||||
I request 50 SVD dimensions per window — but the actual dimensionality is constrained by `min(n_MPs, n_motions) - 1`. Sparse windows (early years, partial quarters) produce fewer meaningful dimensions. The pipeline handles this gracefully, storing whatever `k_used` is for each window so downstream fusion always works with the actual vector length. |
||||
|
||||
### Making Windows Comparable: Procrustes Alignment |
||||
|
||||
Running SVD independently per window creates a subtle problem: SVD axes are **arbitrarily oriented**. The "left-right" axis from 2020-Q3 and the "left-right" axis from 2021-Q1 might point in completely different directions — even if the underlying politics barely changed. You can't just stack the coordinates and call it a trajectory. |
||||
|
||||
The fix is **Procrustes alignment**: given two sets of party/MP positions across consecutive windows, find the rotation matrix R that best maps one onto the other (minimizing the Frobenius norm of the difference), using MPs who appear in both windows as anchors: |
||||
|
||||
``` |
||||
R = argmin_R ||A - B @ R||_F, subject to R'R = I |
||||
``` |
||||
|
||||
This is solved cleanly via SVD of the cross-covariance matrix (a nice piece of mathematical symmetry — SVD to build the space, SVD to align it). The result: a continuous track for every party from 2019 to 2026, where position changes reflect genuine political movement rather than axis flips. |
||||
|
||||
High Procrustes disparity between consecutive windows — where alignment is poor even with the best rotation — is itself a signal: it suggests a structural political shift, not just individual drift. |
||||
|
||||
--- |
||||
|
||||
## Step 2: What Each Motion Is Actually About |
||||
|
||||
Voting patterns tell us *who* agrees, but not *why*. For that, I add **text embeddings** — dense vector representations of each motion's content using a language model. |
||||
|
||||
I use **`qwen/qwen3-embedding-4b`** via OpenRouter — a 4-billion parameter multilingual model that produces 2560-dimensional vectors with strong Dutch-language support. For each motion, I embed the richest text available: full parliamentary body text when we have it (94% of the 29,570 motions after an enrichment pass against the Tweede Kamer API), falling back to the summary description or title otherwise. |
||||
|
||||
This lets us do something powerful: find motions that are genuinely similar in *topic*, not just in voting pattern. Two motions about nitrogen policy from 2020 and 2023 might have very different vote splits (different coalitions, different political moment) but near-identical text embeddings. That's a meaningful connection. |
||||
|
||||
--- |
||||
|
||||
## Step 3: Fused Embeddings — The Best of Both Worlds |
||||
|
||||
SVD gives the political-structural signal: *how does this motion split the chamber?* Text embeddings give the semantic signal: *what is this motion about?* |
||||
|
||||
I concatenate both into a **fused vector** per motion per window: |
||||
|
||||
``` |
||||
fused = [svd_dims (typically 50)] + [text_dims (2560)] = typically 2610 dimensions |
||||
``` |
||||
|
||||
The actual dimension varies slightly because SVD dimensionality adapts to window density — the code stores `svd_dims` and `text_dims` per row so nothing downstream has to assume a fixed size. |
||||
|
||||
This fused representation powers the similarity search. Two motions are "close" only if they're about a similar *topic* **and** they produce a similar *political split*. This filters out spurious matches — two motions might both be controversial (close 50/50 votes) but on completely unrelated things, and the text component separates them. |
||||
|
||||
--- |
||||
|
||||
## The Numbers: What We're Working With |
||||
|
||||
After the full pipeline run: |
||||
|
||||
| Year | Motions | |
||||
|------|---------| |
||||
| 2016 | 162 | |
||||
| 2017 | 126 | |
||||
| 2018 | 124 | |
||||
| 2019 | 3,374 | |
||||
| 2020 | 4,223 | |
||||
| 2021 | 4,283 | |
||||
| 2022 | 4,115 | |
||||
| 2023 | 3,272 | |
||||
| 2024 | 3,965 | |
||||
| 2025 | 3,712 | |
||||
| 2026 | 2,214 | |
||||
| **Total** | **29,570** | |
||||
|
||||
The 2022 spike is striking — over 4,000 motions in a single year. This was the year the Rutte IV coalition took office amid intense debates on energy prices, housing, the war in Ukraine, and the ongoing nitrogen crisis. 2023 is similarly dense at 3,272 motions, culminating in the November election that brought PVV to its historic first-place finish. |
||||
|
||||
Early years (2016–2018) use annual windows because the data is too sparse for meaningful quarterly SVD. From 2019 onwards, everything runs quarterly, giving us 41 windows in total. |
||||
|
||||
The similarity cache holds **409,938 precomputed pairs** — top 10 neighbors per motion per window — making lookup instant at query time. |
||||
|
||||
--- |
||||
|
||||
## Interesting Findings |
||||
|
||||
### The 2022–2023 Polarization Surge |
||||
|
||||
2022 and 2023 together account for more than a quarter of all motions in the dataset. In the SVD positions for 2022, the distance between the governing coalition (VVD, D66, CDA, CU) and the opposition (PVV, SP, FvD) is near its maximum. The nitrogen crisis and energy policy debates forced unusually sharp coalition discipline — which shows up geometrically as well-separated clusters. |
||||
|
||||
2023 continued the intensity, and the Procrustes-aligned trajectory shows the party positions in 2023-Q4 and 2024-Q1 shifting noticeably as the new coalition began to form. |
||||
|
||||
### BBB's Geometric Arrival |
||||
|
||||
When BBB (BoerBurgerBeweging) entered parliament in 2023 with a historic 16 seats, their SVD position placed them between PVV and CDA — exactly matching their policy profile: agrarian-nationalist populism with Catholic-provincial roots. The model found this without being told. That's a good sanity check that the geometry is capturing something real. |
||||
|
||||
### The Strange Case of "Verworpen." |
||||
|
||||
Motions rejected without debate are recorded with the title "Verworpen." (Rejected.). There are hundreds of these. Because they share a 9-character title, their text embeddings are **identical** — cosine similarity 1.0 to every other "Verworpen." in the cache. Technically correct; semantically meaningless. The UI layer filters these out. |
||||
|
||||
It's a reminder that **data quality surprises emerge at scale**. I found three or four similar pathologies (motions withdrawn mid-session, duplicate API records) that required explicit handling. |
||||
|
||||
### Party Cohesion as a Signal |
||||
|
||||
Party cohesion — how often all MPs of a party vote identically — varies enormously. SGP and CU are near-perfect blocs. PvdA/GroenLinks (post-2023 merger) is similarly tight. VVD shows the most internal variation, which tracks with what you'd expect from a governing party managing coalition discipline across conflicting wings. |
||||
|
||||
In earlier years (2019–2020), before the GroenLinks-PvdA merger, GroenLinks occasionally splits on security and defense policy — visible in the SVD as individual MP positions diverging from the party centroid. |
||||
|
||||
--- |
||||
|
||||
## The Pipeline Architecture |
||||
|
||||
Single DuckDB database, modular Python pipeline, no cloud infrastructure: |
||||
|
||||
``` |
||||
API (Tweede Kamer OData) |
||||
→ download_past_year.py |
||||
→ motions table (29,570 rows) |
||||
|
||||
motions |
||||
→ extract_mp_votes.py → mp_votes table (531,869 rows) |
||||
→ sync_motion_content.py → body_text enrichment (~94%) |
||||
→ text_pipeline.py → embeddings table (28,680 rows, qwen3-embedding-4b via OpenRouter) |
||||
→ svd_pipeline.py → svd_vectors table (73,172 rows, 41 windows) |
||||
|
||||
svd_vectors + embeddings |
||||
→ fusion.py → fused_embeddings table (41,422 rows) |
||||
|
||||
fused_embeddings |
||||
→ similarity/compute.py → similarity_cache table (409,938 rows, top-10 per window) |
||||
``` |
||||
|
||||
The similarity computation is pure NumPy: load all fused vectors for a window, pad to uniform length, L2-normalize, compute the full `N×N` cosine similarity matrix via a single matrix multiply (`normalized @ normalized.T`), then extract top-k neighbors per row with `np.argpartition`. For a 4,000-motion quarter, that's a 4000×4000 matrix operation — fast enough that it's not worth batching. |
||||
|
||||
The database sits at 18 GB on disk — up from ~3 GB before body text enrichment. The full parliamentary text for 28,000+ motions accounts for most of that growth. |
||||
|
||||
--- |
||||
|
||||
## What I Built On Top |
||||
|
||||
The pipeline above is the foundation. Here's what it now powers: |
||||
|
||||
**Overton Window analysis**: Using the SVD compass and vote records, I tested whether the Dutch Overton window shifted after PVV's November 2023 election victory. The answer: it widened, but through right-wing moderation rather than centrist conversion. Centrist support for right-wing motions rose from 25% to 51%, while centrists actually moved *left* on the SVD compass. The full analysis covers 3,030 classified right-wing motions, 2D extremity scoring, quarterly trajectories, and mechanism classification. [Read the full report →](../reports/overton_window/overton_report.html) |
||||
|
||||
**2D extremity scoring**: Every motion in the database has been scored by an LLM on two independent dimensions: stylistic extremity (rhetorical hostility) and material impact (policy consequence). They're only moderately correlated (r = 0.43), which matters: right-wing motions post-2024 became milder on *both* dimensions, not just in tone. |
||||
|
||||
**Streamlit Explorer**: An interactive dashboard where you can browse the SVD compass, trace party trajectories over time, explore centrist support trends, and browse individual motions with their extremity scores and similarity matches. The same data and methods that drive the analysis reports power the live exploration interface. |
||||
|
||||
--- |
||||
|
||||
## Reproducibility |
||||
|
||||
```bash |
||||
# Download historical data |
||||
python scripts/download_past_year.py --start-date 2016-01-01 --end-date 2026-01-01 |
||||
|
||||
# Run full pipeline (SVD, text embeddings, fusion, similarity cache) |
||||
python -m pipeline.run_pipeline --db-path data/motions.db \ |
||||
--start-date 2016-01-01 --end-date 2026-01-01 \ |
||||
--window-size quarterly --text-batch-size 200 |
||||
|
||||
# Enrich with full motion body text |
||||
python scripts/sync_motion_content.py --db-path data/motions.db |
||||
``` |
||||
|
||||
The DB grows to ~18 GB for the full dataset including body text. All computation — SVD, fusion, similarity — runs locally on a single machine. |
||||
|
||||
Democracy is more legible than it looks. |
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,236 @@ |
||||
/* quarto syntax highlight colors */ |
||||
:root { |
||||
--quarto-hl-ot-color: #003B4F; |
||||
--quarto-hl-at-color: #657422; |
||||
--quarto-hl-ss-color: #20794D; |
||||
--quarto-hl-an-color: #5E5E5E; |
||||
--quarto-hl-fu-color: #4758AB; |
||||
--quarto-hl-st-color: #20794D; |
||||
--quarto-hl-cf-color: #003B4F; |
||||
--quarto-hl-op-color: #5E5E5E; |
||||
--quarto-hl-er-color: #AD0000; |
||||
--quarto-hl-bn-color: #AD0000; |
||||
--quarto-hl-al-color: #AD0000; |
||||
--quarto-hl-va-color: #111111; |
||||
--quarto-hl-bu-color: inherit; |
||||
--quarto-hl-ex-color: inherit; |
||||
--quarto-hl-pp-color: #AD0000; |
||||
--quarto-hl-in-color: #5E5E5E; |
||||
--quarto-hl-vs-color: #20794D; |
||||
--quarto-hl-wa-color: #5E5E5E; |
||||
--quarto-hl-do-color: #5E5E5E; |
||||
--quarto-hl-im-color: #00769E; |
||||
--quarto-hl-ch-color: #20794D; |
||||
--quarto-hl-dt-color: #AD0000; |
||||
--quarto-hl-fl-color: #AD0000; |
||||
--quarto-hl-co-color: #5E5E5E; |
||||
--quarto-hl-cv-color: #5E5E5E; |
||||
--quarto-hl-cn-color: #8f5902; |
||||
--quarto-hl-sc-color: #5E5E5E; |
||||
--quarto-hl-dv-color: #AD0000; |
||||
--quarto-hl-kw-color: #003B4F; |
||||
} |
||||
|
||||
/* other quarto variables */ |
||||
:root { |
||||
--quarto-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; |
||||
} |
||||
|
||||
/* syntax highlight based on Pandoc's rules */ |
||||
pre > code.sourceCode > span { |
||||
color: #003B4F; |
||||
} |
||||
|
||||
code.sourceCode > span { |
||||
color: #003B4F; |
||||
} |
||||
|
||||
div.sourceCode, |
||||
div.sourceCode pre.sourceCode { |
||||
color: #003B4F; |
||||
} |
||||
|
||||
/* Normal */ |
||||
code span { |
||||
color: #003B4F; |
||||
} |
||||
|
||||
/* Alert */ |
||||
code span.al { |
||||
color: #AD0000; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Annotation */ |
||||
code span.an { |
||||
color: #5E5E5E; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Attribute */ |
||||
code span.at { |
||||
color: #657422; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* BaseN */ |
||||
code span.bn { |
||||
color: #AD0000; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* BuiltIn */ |
||||
code span.bu { |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* ControlFlow */ |
||||
code span.cf { |
||||
color: #003B4F; |
||||
font-weight: bold; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Char */ |
||||
code span.ch { |
||||
color: #20794D; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Constant */ |
||||
code span.cn { |
||||
color: #8f5902; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Comment */ |
||||
code span.co { |
||||
color: #5E5E5E; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* CommentVar */ |
||||
code span.cv { |
||||
color: #5E5E5E; |
||||
font-style: italic; |
||||
} |
||||
|
||||
/* Documentation */ |
||||
code span.do { |
||||
color: #5E5E5E; |
||||
font-style: italic; |
||||
} |
||||
|
||||
/* DataType */ |
||||
code span.dt { |
||||
color: #AD0000; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* DecVal */ |
||||
code span.dv { |
||||
color: #AD0000; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Error */ |
||||
code span.er { |
||||
color: #AD0000; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Extension */ |
||||
code span.ex { |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Float */ |
||||
code span.fl { |
||||
color: #AD0000; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Function */ |
||||
code span.fu { |
||||
color: #4758AB; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Import */ |
||||
code span.im { |
||||
color: #00769E; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Information */ |
||||
code span.in { |
||||
color: #5E5E5E; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Keyword */ |
||||
code span.kw { |
||||
color: #003B4F; |
||||
font-weight: bold; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Operator */ |
||||
code span.op { |
||||
color: #5E5E5E; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Other */ |
||||
code span.ot { |
||||
color: #003B4F; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Preprocessor */ |
||||
code span.pp { |
||||
color: #AD0000; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* SpecialChar */ |
||||
code span.sc { |
||||
color: #5E5E5E; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* SpecialString */ |
||||
code span.ss { |
||||
color: #20794D; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* String */ |
||||
code span.st { |
||||
color: #20794D; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Variable */ |
||||
code span.va { |
||||
color: #111111; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* VerbatimString */ |
||||
code span.vs { |
||||
color: #20794D; |
||||
font-style: inherit; |
||||
} |
||||
|
||||
/* Warning */ |
||||
code span.wa { |
||||
color: #5E5E5E; |
||||
font-style: italic; |
||||
} |
||||
|
||||
.prevent-inlining { |
||||
content: "</"; |
||||
} |
||||
|
||||
/*# sourceMappingURL=f23921c56f73e400b49028c9186a1aa0.css.map */ |
||||
@ -0,0 +1,845 @@ |
||||
import * as tabsets from "./tabsets/tabsets.js"; |
||||
|
||||
const sectionChanged = new CustomEvent("quarto-sectionChanged", { |
||||
detail: {}, |
||||
bubbles: true, |
||||
cancelable: false, |
||||
composed: false, |
||||
}); |
||||
|
||||
const layoutMarginEls = () => { |
||||
// Find any conflicting margin elements and add margins to the
|
||||
// top to prevent overlap
|
||||
const marginChildren = window.document.querySelectorAll( |
||||
".column-margin.column-container > *, .margin-caption, .aside" |
||||
); |
||||
|
||||
let lastBottom = 0; |
||||
for (const marginChild of marginChildren) { |
||||
if (marginChild.offsetParent !== null) { |
||||
// clear the top margin so we recompute it
|
||||
marginChild.style.marginTop = null; |
||||
const top = marginChild.getBoundingClientRect().top + window.scrollY; |
||||
if (top < lastBottom) { |
||||
const marginChildStyle = window.getComputedStyle(marginChild); |
||||
const marginBottom = parseFloat(marginChildStyle["marginBottom"]); |
||||
const margin = lastBottom - top + marginBottom; |
||||
marginChild.style.marginTop = `${margin}px`; |
||||
} |
||||
const styles = window.getComputedStyle(marginChild); |
||||
const marginTop = parseFloat(styles["marginTop"]); |
||||
lastBottom = top + marginChild.getBoundingClientRect().height + marginTop; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
window.document.addEventListener("DOMContentLoaded", function (_event) { |
||||
// Recompute the position of margin elements anytime the body size changes
|
||||
if (window.ResizeObserver) { |
||||
const resizeObserver = new window.ResizeObserver( |
||||
throttle(() => { |
||||
layoutMarginEls(); |
||||
if ( |
||||
window.document.body.getBoundingClientRect().width < 990 && |
||||
isReaderMode() |
||||
) { |
||||
quartoToggleReader(); |
||||
} |
||||
}, 50) |
||||
); |
||||
resizeObserver.observe(window.document.body); |
||||
} |
||||
|
||||
const tocEl = window.document.querySelector('nav.toc-active[role="doc-toc"]'); |
||||
const sidebarEl = window.document.getElementById("quarto-sidebar"); |
||||
const leftTocEl = window.document.getElementById("quarto-sidebar-toc-left"); |
||||
const marginSidebarEl = window.document.getElementById( |
||||
"quarto-margin-sidebar" |
||||
); |
||||
// function to determine whether the element has a previous sibling that is active
|
||||
const prevSiblingIsActiveLink = (el) => { |
||||
const sibling = el.previousElementSibling; |
||||
if (sibling && sibling.tagName === "A") { |
||||
return sibling.classList.contains("active"); |
||||
} else { |
||||
return false; |
||||
} |
||||
}; |
||||
|
||||
// dispatch for htmlwidgets
|
||||
// they use slideenter event to trigger resize
|
||||
function fireSlideEnter() { |
||||
const event = window.document.createEvent("Event"); |
||||
event.initEvent("slideenter", true, true); |
||||
window.document.dispatchEvent(event); |
||||
} |
||||
|
||||
const tabs = window.document.querySelectorAll('a[data-bs-toggle="tab"]'); |
||||
tabs.forEach((tab) => { |
||||
tab.addEventListener("shown.bs.tab", fireSlideEnter); |
||||
}); |
||||
|
||||
// dispatch for shiny
|
||||
// they use BS shown and hidden events to trigger rendering
|
||||
function distpatchShinyEvents(previous, current) { |
||||
if (window.jQuery) { |
||||
if (previous) { |
||||
window.jQuery(previous).trigger("hidden"); |
||||
} |
||||
if (current) { |
||||
window.jQuery(current).trigger("shown"); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// tabby.js listener: Trigger event for htmlwidget and shiny
|
||||
document.addEventListener( |
||||
"tabby", |
||||
function (event) { |
||||
fireSlideEnter(); |
||||
distpatchShinyEvents(event.detail.previousTab, event.detail.tab); |
||||
}, |
||||
false |
||||
); |
||||
|
||||
// Track scrolling and mark TOC links as active
|
||||
// get table of contents and sidebar (bail if we don't have at least one)
|
||||
const tocLinks = tocEl |
||||
? [...tocEl.querySelectorAll("a[data-scroll-target]")] |
||||
: []; |
||||
const makeActive = (link) => tocLinks[link].classList.add("active"); |
||||
const removeActive = (link) => tocLinks[link].classList.remove("active"); |
||||
const removeAllActive = () => |
||||
[...Array(tocLinks.length).keys()].forEach((link) => removeActive(link)); |
||||
|
||||
// activate the anchor for a section associated with this TOC entry
|
||||
tocLinks.forEach((link) => { |
||||
link.addEventListener("click", () => { |
||||
if (link.href.indexOf("#") !== -1) { |
||||
const anchor = link.href.split("#")[1]; |
||||
const heading = window.document.querySelector( |
||||
`[data-anchor-id="${anchor}"]` |
||||
); |
||||
if (heading) { |
||||
// Add the class
|
||||
heading.classList.add("reveal-anchorjs-link"); |
||||
|
||||
// function to show the anchor
|
||||
const handleMouseout = () => { |
||||
heading.classList.remove("reveal-anchorjs-link"); |
||||
heading.removeEventListener("mouseout", handleMouseout); |
||||
}; |
||||
|
||||
// add a function to clear the anchor when the user mouses out of it
|
||||
heading.addEventListener("mouseout", handleMouseout); |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
const sections = tocLinks.map((link) => { |
||||
const target = link.getAttribute("data-scroll-target"); |
||||
if (target.startsWith("#")) { |
||||
return window.document.getElementById(decodeURI(`${target.slice(1)}`)); |
||||
} else { |
||||
return window.document.querySelector(decodeURI(`${target}`)); |
||||
} |
||||
}); |
||||
|
||||
const sectionMargin = 200; |
||||
let currentActive = 0; |
||||
// track whether we've initialized state the first time
|
||||
let init = false; |
||||
|
||||
const updateActiveLink = () => { |
||||
// The index from bottom to top (e.g. reversed list)
|
||||
let sectionIndex = -1; |
||||
if ( |
||||
window.innerHeight + window.pageYOffset >= |
||||
window.document.body.offsetHeight |
||||
) { |
||||
// This is the no-scroll case where last section should be the active one
|
||||
sectionIndex = 0; |
||||
} else { |
||||
// This finds the last section visible on screen that should be made active
|
||||
sectionIndex = [...sections].reverse().findIndex((section) => { |
||||
if (section) { |
||||
return window.pageYOffset >= section.offsetTop - sectionMargin; |
||||
} else { |
||||
return false; |
||||
} |
||||
}); |
||||
} |
||||
if (sectionIndex > -1) { |
||||
const current = sections.length - sectionIndex - 1; |
||||
if (current !== currentActive) { |
||||
removeAllActive(); |
||||
currentActive = current; |
||||
makeActive(current); |
||||
if (init) { |
||||
window.dispatchEvent(sectionChanged); |
||||
} |
||||
init = true; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
const inHiddenRegion = (top, bottom, hiddenRegions) => { |
||||
for (const region of hiddenRegions) { |
||||
if (top <= region.bottom && bottom >= region.top) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
}; |
||||
|
||||
const categorySelector = "header.quarto-title-block .quarto-category"; |
||||
const activateCategories = (href) => { |
||||
// Find any categories
|
||||
// Surround them with a link pointing back to:
|
||||
// #category=Authoring
|
||||
try { |
||||
const categoryEls = window.document.querySelectorAll(categorySelector); |
||||
for (const categoryEl of categoryEls) { |
||||
const categoryText = categoryEl.textContent; |
||||
if (categoryText) { |
||||
const link = `${href}#category=${encodeURIComponent(categoryText)}`; |
||||
const linkEl = window.document.createElement("a"); |
||||
linkEl.setAttribute("href", link); |
||||
for (const child of categoryEl.childNodes) { |
||||
linkEl.append(child); |
||||
} |
||||
categoryEl.appendChild(linkEl); |
||||
} |
||||
} |
||||
} catch { |
||||
// Ignore errors
|
||||
} |
||||
}; |
||||
function hasTitleCategories() { |
||||
return window.document.querySelector(categorySelector) !== null; |
||||
} |
||||
|
||||
function offsetRelativeUrl(url) { |
||||
const offset = getMeta("quarto:offset"); |
||||
return offset ? offset + url : url; |
||||
} |
||||
|
||||
function offsetAbsoluteUrl(url) { |
||||
const offset = getMeta("quarto:offset"); |
||||
const baseUrl = new URL(offset, window.location); |
||||
|
||||
const projRelativeUrl = url.replace(baseUrl, ""); |
||||
if (projRelativeUrl.startsWith("/")) { |
||||
return projRelativeUrl; |
||||
} else { |
||||
return "/" + projRelativeUrl; |
||||
} |
||||
} |
||||
|
||||
// read a meta tag value
|
||||
function getMeta(metaName) { |
||||
const metas = window.document.getElementsByTagName("meta"); |
||||
for (let i = 0; i < metas.length; i++) { |
||||
if (metas[i].getAttribute("name") === metaName) { |
||||
return metas[i].getAttribute("content"); |
||||
} |
||||
} |
||||
return ""; |
||||
} |
||||
|
||||
async function findAndActivateCategories() { |
||||
// Categories search with listing only use path without query
|
||||
const currentPagePath = offsetAbsoluteUrl( |
||||
window.location.origin + window.location.pathname |
||||
); |
||||
const response = await fetch(offsetRelativeUrl("listings.json")); |
||||
if (response.status == 200) { |
||||
return response.json().then(function (listingPaths) { |
||||
const listingHrefs = []; |
||||
for (const listingPath of listingPaths) { |
||||
const pathWithoutLeadingSlash = listingPath.listing.substring(1); |
||||
for (const item of listingPath.items) { |
||||
const encodedItem = encodeURI(item); |
||||
if ( |
||||
encodedItem === currentPagePath || |
||||
encodedItem === currentPagePath + "index.html" |
||||
) { |
||||
// Resolve this path against the offset to be sure
|
||||
// we already are using the correct path to the listing
|
||||
// (this adjusts the listing urls to be rooted against
|
||||
// whatever root the page is actually running against)
|
||||
const relative = offsetRelativeUrl(pathWithoutLeadingSlash); |
||||
const baseUrl = window.location; |
||||
const resolvedPath = new URL(relative, baseUrl); |
||||
listingHrefs.push(resolvedPath.pathname); |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Look up the tree for a nearby linting and use that if we find one
|
||||
const nearestListing = findNearestParentListing( |
||||
offsetAbsoluteUrl(window.location.pathname), |
||||
listingHrefs |
||||
); |
||||
if (nearestListing) { |
||||
activateCategories(nearestListing); |
||||
} else { |
||||
// See if the referrer is a listing page for this item
|
||||
const referredRelativePath = offsetAbsoluteUrl(document.referrer); |
||||
const referrerListing = listingHrefs.find((listingHref) => { |
||||
const isListingReferrer = |
||||
listingHref === referredRelativePath || |
||||
listingHref === referredRelativePath + "index.html"; |
||||
return isListingReferrer; |
||||
}); |
||||
|
||||
if (referrerListing) { |
||||
// Try to use the referrer if possible
|
||||
activateCategories(referrerListing); |
||||
} else if (listingHrefs.length > 0) { |
||||
// Otherwise, just fall back to the first listing
|
||||
activateCategories(listingHrefs[0]); |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
if (hasTitleCategories()) { |
||||
findAndActivateCategories(); |
||||
} |
||||
|
||||
const findNearestParentListing = (href, listingHrefs) => { |
||||
if (!href || !listingHrefs) { |
||||
return undefined; |
||||
} |
||||
// Look up the tree for a nearby linting and use that if we find one
|
||||
const relativeParts = href.substring(1).split("/"); |
||||
while (relativeParts.length > 0) { |
||||
const path = relativeParts.join("/"); |
||||
for (const listingHref of listingHrefs) { |
||||
if (listingHref.startsWith(path)) { |
||||
return listingHref; |
||||
} |
||||
} |
||||
relativeParts.pop(); |
||||
} |
||||
|
||||
return undefined; |
||||
}; |
||||
|
||||
const manageSidebarVisiblity = (el, placeholderDescriptor) => { |
||||
let isVisible = true; |
||||
let elRect; |
||||
|
||||
return (hiddenRegions) => { |
||||
if (el === null) { |
||||
return; |
||||
} |
||||
|
||||
// Find the last element of the TOC
|
||||
const lastChildEl = el.lastElementChild; |
||||
|
||||
if (lastChildEl) { |
||||
// Converts the sidebar to a menu
|
||||
const convertToMenu = () => { |
||||
for (const child of el.children) { |
||||
child.style.opacity = 0; |
||||
child.style.overflow = "hidden"; |
||||
child.style.pointerEvents = "none"; |
||||
} |
||||
|
||||
nexttick(() => { |
||||
const toggleContainer = window.document.createElement("div"); |
||||
toggleContainer.style.width = "100%"; |
||||
toggleContainer.classList.add("zindex-over-content"); |
||||
toggleContainer.classList.add("quarto-sidebar-toggle"); |
||||
toggleContainer.classList.add("headroom-target"); // Marks this to be managed by headeroom
|
||||
toggleContainer.id = placeholderDescriptor.id; |
||||
toggleContainer.style.position = "fixed"; |
||||
|
||||
const toggleIcon = window.document.createElement("i"); |
||||
toggleIcon.classList.add("quarto-sidebar-toggle-icon"); |
||||
toggleIcon.classList.add("bi"); |
||||
toggleIcon.classList.add("bi-caret-down-fill"); |
||||
|
||||
const toggleTitle = window.document.createElement("div"); |
||||
const titleEl = window.document.body.querySelector( |
||||
placeholderDescriptor.titleSelector |
||||
); |
||||
if (titleEl) { |
||||
toggleTitle.append( |
||||
titleEl.textContent || titleEl.innerText, |
||||
toggleIcon |
||||
); |
||||
} |
||||
toggleTitle.classList.add("zindex-over-content"); |
||||
toggleTitle.classList.add("quarto-sidebar-toggle-title"); |
||||
toggleContainer.append(toggleTitle); |
||||
|
||||
const toggleContents = window.document.createElement("div"); |
||||
toggleContents.classList = el.classList; |
||||
toggleContents.classList.add("zindex-over-content"); |
||||
toggleContents.classList.add("quarto-sidebar-toggle-contents"); |
||||
for (const child of el.children) { |
||||
if (child.id === "toc-title") { |
||||
continue; |
||||
} |
||||
|
||||
const clone = child.cloneNode(true); |
||||
clone.style.opacity = 1; |
||||
clone.style.pointerEvents = null; |
||||
clone.style.display = null; |
||||
toggleContents.append(clone); |
||||
} |
||||
toggleContents.style.height = "0px"; |
||||
const positionToggle = () => { |
||||
// position the element (top left of parent, same width as parent)
|
||||
if (!elRect) { |
||||
elRect = el.getBoundingClientRect(); |
||||
} |
||||
toggleContainer.style.left = `${elRect.left}px`; |
||||
toggleContainer.style.top = `${elRect.top}px`; |
||||
toggleContainer.style.width = `${elRect.width}px`; |
||||
}; |
||||
positionToggle(); |
||||
|
||||
toggleContainer.append(toggleContents); |
||||
el.parentElement.prepend(toggleContainer); |
||||
|
||||
// Process clicks
|
||||
let tocShowing = false; |
||||
// Allow the caller to control whether this is dismissed
|
||||
// when it is clicked (e.g. sidebar navigation supports
|
||||
// opening and closing the nav tree, so don't dismiss on click)
|
||||
const clickEl = placeholderDescriptor.dismissOnClick |
||||
? toggleContainer |
||||
: toggleTitle; |
||||
|
||||
const closeToggle = () => { |
||||
if (tocShowing) { |
||||
toggleContainer.classList.remove("expanded"); |
||||
toggleContents.style.height = "0px"; |
||||
tocShowing = false; |
||||
} |
||||
}; |
||||
|
||||
// Get rid of any expanded toggle if the user scrolls
|
||||
window.document.addEventListener( |
||||
"scroll", |
||||
throttle(() => { |
||||
closeToggle(); |
||||
}, 50) |
||||
); |
||||
|
||||
// Handle positioning of the toggle
|
||||
window.addEventListener( |
||||
"resize", |
||||
throttle(() => { |
||||
elRect = undefined; |
||||
positionToggle(); |
||||
}, 50) |
||||
); |
||||
|
||||
window.addEventListener("quarto-hrChanged", () => { |
||||
elRect = undefined; |
||||
}); |
||||
|
||||
// Process the click
|
||||
clickEl.onclick = () => { |
||||
if (!tocShowing) { |
||||
toggleContainer.classList.add("expanded"); |
||||
toggleContents.style.height = null; |
||||
tocShowing = true; |
||||
} else { |
||||
closeToggle(); |
||||
} |
||||
}; |
||||
}); |
||||
}; |
||||
|
||||
// Converts a sidebar from a menu back to a sidebar
|
||||
const convertToSidebar = () => { |
||||
for (const child of el.children) { |
||||
child.style.opacity = 1; |
||||
child.style.overflow = null; |
||||
child.style.pointerEvents = null; |
||||
} |
||||
|
||||
const placeholderEl = window.document.getElementById( |
||||
placeholderDescriptor.id |
||||
); |
||||
if (placeholderEl) { |
||||
placeholderEl.remove(); |
||||
} |
||||
|
||||
el.classList.remove("rollup"); |
||||
}; |
||||
|
||||
if (isReaderMode()) { |
||||
convertToMenu(); |
||||
isVisible = false; |
||||
} else { |
||||
// Find the top and bottom o the element that is being managed
|
||||
const elTop = el.offsetTop; |
||||
const elBottom = |
||||
elTop + lastChildEl.offsetTop + lastChildEl.offsetHeight; |
||||
|
||||
if (!isVisible) { |
||||
// If the element is current not visible reveal if there are
|
||||
// no conflicts with overlay regions
|
||||
if (!inHiddenRegion(elTop, elBottom, hiddenRegions)) { |
||||
convertToSidebar(); |
||||
isVisible = true; |
||||
} |
||||
} else { |
||||
// If the element is visible, hide it if it conflicts with overlay regions
|
||||
// and insert a placeholder toggle (or if we're in reader mode)
|
||||
if (inHiddenRegion(elTop, elBottom, hiddenRegions)) { |
||||
convertToMenu(); |
||||
isVisible = false; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
}; |
||||
}; |
||||
|
||||
const tabEls = document.querySelectorAll('a[data-bs-toggle="tab"]'); |
||||
for (const tabEl of tabEls) { |
||||
const id = tabEl.getAttribute("data-bs-target"); |
||||
if (id) { |
||||
const columnEl = document.querySelector( |
||||
`${id} .column-margin, .tabset-margin-content` |
||||
); |
||||
if (columnEl) |
||||
tabEl.addEventListener("shown.bs.tab", function (event) { |
||||
const el = event.srcElement; |
||||
if (el) { |
||||
const visibleCls = `${el.id}-margin-content`; |
||||
// walk up until we find a parent tabset
|
||||
let panelTabsetEl = el.parentElement; |
||||
while (panelTabsetEl) { |
||||
if (panelTabsetEl.classList.contains("panel-tabset")) { |
||||
break; |
||||
} |
||||
panelTabsetEl = panelTabsetEl.parentElement; |
||||
} |
||||
|
||||
if (panelTabsetEl) { |
||||
const prevSib = panelTabsetEl.previousElementSibling; |
||||
if ( |
||||
prevSib && |
||||
prevSib.classList.contains("tabset-margin-container") |
||||
) { |
||||
const childNodes = prevSib.querySelectorAll( |
||||
".tabset-margin-content" |
||||
); |
||||
for (const childEl of childNodes) { |
||||
if (childEl.classList.contains(visibleCls)) { |
||||
childEl.classList.remove("collapse"); |
||||
} else { |
||||
childEl.classList.add("collapse"); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
layoutMarginEls(); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
// Manage the visibility of the toc and the sidebar
|
||||
const marginScrollVisibility = manageSidebarVisiblity(marginSidebarEl, { |
||||
id: "quarto-toc-toggle", |
||||
titleSelector: "#toc-title", |
||||
dismissOnClick: true, |
||||
}); |
||||
const sidebarScrollVisiblity = manageSidebarVisiblity(sidebarEl, { |
||||
id: "quarto-sidebarnav-toggle", |
||||
titleSelector: ".title", |
||||
dismissOnClick: false, |
||||
}); |
||||
let tocLeftScrollVisibility; |
||||
if (leftTocEl) { |
||||
tocLeftScrollVisibility = manageSidebarVisiblity(leftTocEl, { |
||||
id: "quarto-lefttoc-toggle", |
||||
titleSelector: "#toc-title", |
||||
dismissOnClick: true, |
||||
}); |
||||
} |
||||
|
||||
// Find the first element that uses formatting in special columns
|
||||
const conflictingEls = window.document.body.querySelectorAll( |
||||
'[class^="column-"], [class*=" column-"], aside, [class*="margin-caption"], [class*=" margin-caption"], [class*="margin-ref"], [class*=" margin-ref"]' |
||||
); |
||||
|
||||
// Filter all the possibly conflicting elements into ones
|
||||
// the do conflict on the left or ride side
|
||||
const arrConflictingEls = Array.from(conflictingEls); |
||||
const leftSideConflictEls = arrConflictingEls.filter((el) => { |
||||
if (el.tagName === "ASIDE") { |
||||
return false; |
||||
} |
||||
return Array.from(el.classList).find((className) => { |
||||
return ( |
||||
className !== "column-body" && |
||||
className.startsWith("column-") && |
||||
!className.endsWith("right") && |
||||
!className.endsWith("container") && |
||||
className !== "column-margin" |
||||
); |
||||
}); |
||||
}); |
||||
const rightSideConflictEls = arrConflictingEls.filter((el) => { |
||||
if (el.tagName === "ASIDE") { |
||||
return true; |
||||
} |
||||
|
||||
const hasMarginCaption = Array.from(el.classList).find((className) => { |
||||
return className == "margin-caption"; |
||||
}); |
||||
if (hasMarginCaption) { |
||||
return true; |
||||
} |
||||
|
||||
return Array.from(el.classList).find((className) => { |
||||
return ( |
||||
className !== "column-body" && |
||||
!className.endsWith("container") && |
||||
className.startsWith("column-") && |
||||
!className.endsWith("left") |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
const kOverlapPaddingSize = 10; |
||||
function toRegions(els) { |
||||
return els.map((el) => { |
||||
const boundRect = el.getBoundingClientRect(); |
||||
const top = |
||||
boundRect.top + |
||||
document.documentElement.scrollTop - |
||||
kOverlapPaddingSize; |
||||
return { |
||||
top, |
||||
bottom: top + el.scrollHeight + 2 * kOverlapPaddingSize, |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
let hasObserved = false; |
||||
const visibleItemObserver = (els) => { |
||||
let visibleElements = [...els]; |
||||
const intersectionObserver = new IntersectionObserver( |
||||
(entries, _observer) => { |
||||
entries.forEach((entry) => { |
||||
if (entry.isIntersecting) { |
||||
if (visibleElements.indexOf(entry.target) === -1) { |
||||
visibleElements.push(entry.target); |
||||
} |
||||
} else { |
||||
visibleElements = visibleElements.filter((visibleEntry) => { |
||||
return visibleEntry !== entry; |
||||
}); |
||||
} |
||||
}); |
||||
|
||||
if (!hasObserved) { |
||||
hideOverlappedSidebars(); |
||||
} |
||||
hasObserved = true; |
||||
}, |
||||
{} |
||||
); |
||||
els.forEach((el) => { |
||||
intersectionObserver.observe(el); |
||||
}); |
||||
|
||||
return { |
||||
getVisibleEntries: () => { |
||||
return visibleElements; |
||||
}, |
||||
}; |
||||
}; |
||||
|
||||
const rightElementObserver = visibleItemObserver(rightSideConflictEls); |
||||
const leftElementObserver = visibleItemObserver(leftSideConflictEls); |
||||
|
||||
const hideOverlappedSidebars = () => { |
||||
marginScrollVisibility(toRegions(rightElementObserver.getVisibleEntries())); |
||||
sidebarScrollVisiblity(toRegions(leftElementObserver.getVisibleEntries())); |
||||
if (tocLeftScrollVisibility) { |
||||
tocLeftScrollVisibility( |
||||
toRegions(leftElementObserver.getVisibleEntries()) |
||||
); |
||||
} |
||||
}; |
||||
|
||||
window.quartoToggleReader = () => { |
||||
// Applies a slow class (or removes it)
|
||||
// to update the transition speed
|
||||
const slowTransition = (slow) => { |
||||
const manageTransition = (id, slow) => { |
||||
const el = document.getElementById(id); |
||||
if (el) { |
||||
if (slow) { |
||||
el.classList.add("slow"); |
||||
} else { |
||||
el.classList.remove("slow"); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
manageTransition("TOC", slow); |
||||
manageTransition("quarto-sidebar", slow); |
||||
}; |
||||
const readerMode = !isReaderMode(); |
||||
setReaderModeValue(readerMode); |
||||
|
||||
// If we're entering reader mode, slow the transition
|
||||
if (readerMode) { |
||||
slowTransition(readerMode); |
||||
} |
||||
highlightReaderToggle(readerMode); |
||||
hideOverlappedSidebars(); |
||||
|
||||
// If we're exiting reader mode, restore the non-slow transition
|
||||
if (!readerMode) { |
||||
slowTransition(!readerMode); |
||||
} |
||||
}; |
||||
|
||||
const highlightReaderToggle = (readerMode) => { |
||||
const els = document.querySelectorAll(".quarto-reader-toggle"); |
||||
if (els) { |
||||
els.forEach((el) => { |
||||
if (readerMode) { |
||||
el.classList.add("reader"); |
||||
} else { |
||||
el.classList.remove("reader"); |
||||
} |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
const setReaderModeValue = (val) => { |
||||
if (window.location.protocol !== "file:") { |
||||
window.localStorage.setItem("quarto-reader-mode", val); |
||||
} else { |
||||
localReaderMode = val; |
||||
} |
||||
}; |
||||
|
||||
const isReaderMode = () => { |
||||
if (window.location.protocol !== "file:") { |
||||
return window.localStorage.getItem("quarto-reader-mode") === "true"; |
||||
} else { |
||||
return localReaderMode; |
||||
} |
||||
}; |
||||
let localReaderMode = null; |
||||
|
||||
const tocOpenDepthStr = tocEl?.getAttribute("data-toc-expanded"); |
||||
const tocOpenDepth = tocOpenDepthStr ? Number(tocOpenDepthStr) : 1; |
||||
|
||||
// Walk the TOC and collapse/expand nodes
|
||||
// Nodes are expanded if:
|
||||
// - they are top level
|
||||
// - they have children that are 'active' links
|
||||
// - they are directly below an link that is 'active'
|
||||
const walk = (el, depth) => { |
||||
// Tick depth when we enter a UL
|
||||
if (el.tagName === "UL") { |
||||
depth = depth + 1; |
||||
} |
||||
|
||||
// It this is active link
|
||||
let isActiveNode = false; |
||||
if (el.tagName === "A" && el.classList.contains("active")) { |
||||
isActiveNode = true; |
||||
} |
||||
|
||||
// See if there is an active child to this element
|
||||
let hasActiveChild = false; |
||||
for (const child of el.children) { |
||||
hasActiveChild = walk(child, depth) || hasActiveChild; |
||||
} |
||||
|
||||
// Process the collapse state if this is an UL
|
||||
if (el.tagName === "UL") { |
||||
if (tocOpenDepth === -1 && depth > 1) { |
||||
// toc-expand: false
|
||||
el.classList.add("collapse"); |
||||
} else if ( |
||||
depth <= tocOpenDepth || |
||||
hasActiveChild || |
||||
prevSiblingIsActiveLink(el) |
||||
) { |
||||
el.classList.remove("collapse"); |
||||
} else { |
||||
el.classList.add("collapse"); |
||||
} |
||||
|
||||
// untick depth when we leave a UL
|
||||
depth = depth - 1; |
||||
} |
||||
return hasActiveChild || isActiveNode; |
||||
}; |
||||
|
||||
// walk the TOC and expand / collapse any items that should be shown
|
||||
if (tocEl) { |
||||
updateActiveLink(); |
||||
walk(tocEl, 0); |
||||
} |
||||
|
||||
// Throttle the scroll event and walk peridiocally
|
||||
window.document.addEventListener( |
||||
"scroll", |
||||
throttle(() => { |
||||
if (tocEl) { |
||||
updateActiveLink(); |
||||
walk(tocEl, 0); |
||||
} |
||||
if (!isReaderMode()) { |
||||
hideOverlappedSidebars(); |
||||
} |
||||
}, 5) |
||||
); |
||||
window.addEventListener( |
||||
"resize", |
||||
throttle(() => { |
||||
if (tocEl) { |
||||
updateActiveLink(); |
||||
walk(tocEl, 0); |
||||
} |
||||
if (!isReaderMode()) { |
||||
hideOverlappedSidebars(); |
||||
} |
||||
}, 10) |
||||
); |
||||
hideOverlappedSidebars(); |
||||
highlightReaderToggle(isReaderMode()); |
||||
}); |
||||
|
||||
tabsets.init(); |
||||
|
||||
function throttle(func, wait) { |
||||
let waiting = false; |
||||
return function () { |
||||
if (!waiting) { |
||||
func.apply(this, arguments); |
||||
waiting = true; |
||||
setTimeout(function () { |
||||
waiting = false; |
||||
}, wait); |
||||
} |
||||
}; |
||||
} |
||||
|
||||
function nexttick(func) { |
||||
return setTimeout(func, 0); |
||||
} |
||||
@ -0,0 +1,95 @@ |
||||
// grouped tabsets
|
||||
|
||||
export function init() { |
||||
window.addEventListener("pageshow", (_event) => { |
||||
function getTabSettings() { |
||||
const data = localStorage.getItem("quarto-persistent-tabsets-data"); |
||||
if (!data) { |
||||
localStorage.setItem("quarto-persistent-tabsets-data", "{}"); |
||||
return {}; |
||||
} |
||||
if (data) { |
||||
return JSON.parse(data); |
||||
} |
||||
} |
||||
|
||||
function setTabSettings(data) { |
||||
localStorage.setItem( |
||||
"quarto-persistent-tabsets-data", |
||||
JSON.stringify(data) |
||||
); |
||||
} |
||||
|
||||
function setTabState(groupName, groupValue) { |
||||
const data = getTabSettings(); |
||||
data[groupName] = groupValue; |
||||
setTabSettings(data); |
||||
} |
||||
|
||||
function toggleTab(tab, active) { |
||||
const tabPanelId = tab.getAttribute("aria-controls"); |
||||
const tabPanel = document.getElementById(tabPanelId); |
||||
if (active) { |
||||
tab.classList.add("active"); |
||||
tabPanel.classList.add("active"); |
||||
} else { |
||||
tab.classList.remove("active"); |
||||
tabPanel.classList.remove("active"); |
||||
} |
||||
} |
||||
|
||||
function toggleAll(selectedGroup, selectorsToSync) { |
||||
for (const [thisGroup, tabs] of Object.entries(selectorsToSync)) { |
||||
const active = selectedGroup === thisGroup; |
||||
for (const tab of tabs) { |
||||
toggleTab(tab, active); |
||||
} |
||||
} |
||||
} |
||||
|
||||
function findSelectorsToSyncByLanguage() { |
||||
const result = {}; |
||||
const tabs = Array.from( |
||||
document.querySelectorAll(`div[data-group] a[id^='tabset-']`) |
||||
); |
||||
for (const item of tabs) { |
||||
const div = item.parentElement.parentElement.parentElement; |
||||
const group = div.getAttribute("data-group"); |
||||
if (!result[group]) { |
||||
result[group] = {}; |
||||
} |
||||
const selectorsToSync = result[group]; |
||||
const value = item.innerHTML; |
||||
if (!selectorsToSync[value]) { |
||||
selectorsToSync[value] = []; |
||||
} |
||||
selectorsToSync[value].push(item); |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
function setupSelectorSync() { |
||||
const selectorsToSync = findSelectorsToSyncByLanguage(); |
||||
Object.entries(selectorsToSync).forEach(([group, tabSetsByValue]) => { |
||||
Object.entries(tabSetsByValue).forEach(([value, items]) => { |
||||
items.forEach((item) => { |
||||
item.addEventListener("click", (_event) => { |
||||
setTabState(group, value); |
||||
toggleAll(value, selectorsToSync[group]); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
return selectorsToSync; |
||||
} |
||||
|
||||
const selectorsToSync = setupSelectorSync(); |
||||
for (const [group, selectedName] of Object.entries(getTabSettings())) { |
||||
const selectors = selectorsToSync[group]; |
||||
// it's possible that stale state gives us empty selections, so we explicitly check here.
|
||||
if (selectors) { |
||||
toggleAll(selectedName, selectors); |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
@ -0,0 +1 @@ |
||||
.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1} |
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in new issue