From 5c26166db9d01da740cfa8ffba0cba653072a32c Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 11 Sep 2026 16:43:01 +0200 Subject: [PATCH 1/3] style: clean up script --- assets/env.d.ts | 21 ++++- assets/main.ts | 159 ++++++++++++++++++++----------------- layouts/partials/head.html | 5 +- 3 files changed, 109 insertions(+), 76 deletions(-) diff --git a/assets/env.d.ts b/assets/env.d.ts index 497b8bc..2a2007f 100644 --- a/assets/env.d.ts +++ b/assets/env.d.ts @@ -1,3 +1,22 @@ -// Not on disk: resolved at runtime by the importmap in layouts/partials/head.html. +// Neither module is on disk: both are resolved at runtime by the importmap in +// layouts/partials/head.html, and listed as externals in its js.Build call. + // Untyped on purpose — the import is only there for bootstrap’s side effects. declare module "bootstrap" + +// Written by `npx pagefind` after the Hugo build. Only the surface main.ts uses. +declare module "pagefind" { + export function options(options: { excerptLength?: number }): Promise + export function init(): Promise + export function search(term: string): Promise<{ results: PagefindResult[] }> + + export interface PagefindResult { + data(): Promise + } + + export interface PagefindDocument { + url: string + excerpt: string + meta: { title?: string } + } +} diff --git a/assets/main.ts b/assets/main.ts index f5b8663..9d49247 100644 --- a/assets/main.ts +++ b/assets/main.ts @@ -1,4 +1,7 @@ import "bootstrap" // see env.d.ts +import type { PagefindDocument } from "pagefind" + +type Pagefind = typeof import("pagefind") // Ecosystem package registry: free-text search, one active category, one active tag. // Categories and tags both come from the controlled vocabulary in the registry schema. @@ -263,17 +266,17 @@ const filterTutorials = () => { // Initialize interactive UMAP visualization const initInteractiveViz = () => { //const container = document.getElementById('interactive-container') - const card = document.getElementById("interactive-card") - const visualization = document.getElementById("visualization") + const card = document.querySelector("#interactive-card")! + const visualization = document.querySelector("#visualization")! // Exit early if visualization elements don't exist on this page if (!visualization || !card) return - const runCmd1 = document.getElementById("run-cmd1")! - const runCmd2 = document.getElementById("run-cmd2")! - const statusCmd1 = document.getElementById("status-cmd1")! - const statusCmd2 = document.getElementById("status-cmd2")! - const execAnim1 = document.getElementById("exec-anim-1")! - const execAnim2 = document.getElementById("exec-anim-2")! + const runCmd1 = document.querySelector("#run-cmd1")! + const runCmd2 = document.querySelector("#run-cmd2")! + const statusCmd1 = document.querySelector("#status-cmd1")! + const statusCmd2 = document.querySelector("#status-cmd2")! + const execAnim1 = document.querySelector("#exec-anim-1")! + const execAnim2 = document.querySelector("#exec-anim-2")! // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. const colorClusters = [ @@ -328,12 +331,12 @@ const initInteractiveViz = () => { }) function generateUMAP() { - for (const dot of visualization!.querySelectorAll(".dot")) { + for (const dot of visualization.querySelectorAll(".dot")) { dot.remove() } - const width = visualization!.clientWidth - const height = visualization!.clientHeight + const width = visualization.clientWidth + const height = visualization.clientHeight for (const cluster of colorClusters) { const centerX = Math.random() * 0.6 * width + 0.2 * width @@ -371,7 +374,7 @@ const initInteractiveViz = () => { dot.style.top = `${safeY}px` dot.style.backgroundColor = cluster.color - visualization!.appendChild(dot) + visualization.appendChild(dot) const dataPoint = Math.floor(Math.random() * 1000) dot.dataset.id = `point-${dataPoint}` @@ -437,16 +440,6 @@ const initInteractiveViz = () => { } } - // These are divs, not buttons, so the keyboard behaviour has to be added by hand. - for (const control of [runCmd1, runCmd2]) { - control.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault() - control.click() - } - }) - } - runCmd1.addEventListener("click", () => { statusCmd1.style.width = "0" execAnim1.style.width = "0" @@ -501,6 +494,16 @@ const initInteractiveViz = () => { }) } +const el = ( + tag: K, + props: Partial, + ...children: (Node | string)[] +): HTMLElementTagNameMap[K] => { + const element = Object.assign(document.createElement(tag), props) + element.append(...children) + return element +} + interface Entity { name: string kind: string @@ -509,17 +512,17 @@ interface Entity { } function initSearch() { - const openButton = document.getElementById("search-open") - const overlay = document.getElementById("search-overlay") + const openButton = document.querySelector("#search-open")! + const overlay = document.querySelector("#search-overlay")! if (!openButton || !overlay) return - const dialog = document.getElementById("search-dialog")! - const input = document.getElementById("search-input") as HTMLInputElement - const status = document.getElementById("search-status")! - const results = document.getElementById("search-results")! - const closeButton = document.getElementById("search-close")! + const dialog = document.querySelector("#search-dialog")! + const input = document.querySelector("#search-input")! + const status = document.querySelector("#search-status")! + const results = document.querySelector("#search-results")! + const closeButton = document.querySelector("#search-close")! - let pagefindPromise: Promise | null = null + let pagefindPromise: Promise | null = null let lastQuery = "" let selected = -1 let debounce: ReturnType @@ -558,8 +561,7 @@ function initSearch() { function loadPagefind() { if (!pagefindPromise) { pagefindPromise = (async () => { - // @ts-expect-error not on disk: written by `npx pagefind` after the Hugo build - const engine = await import("/pagefind/pagefind.js") + const engine = await import("pagefind") await engine.options({ excerptLength: 25 }) await engine.init() return engine @@ -573,7 +575,7 @@ function initSearch() { } function open() { - overlay!.hidden = false + overlay.hidden = false document.body.style.overflow = "hidden" input.focus() input.select() @@ -581,9 +583,9 @@ function initSearch() { } function close() { - overlay!.hidden = true + overlay.hidden = true document.body.style.overflow = "" - openButton!.focus() + openButton.focus() } function setSelected(next: number) { @@ -597,7 +599,7 @@ function initSearch() { // Pagefind matches when an indexed word is a prefix of the search term, so // "xylophone" comes back matching "x" on seventeen pages. - function relevant(hit: { excerpt: string }) { + function relevant(hit: PagefindDocument) { const terms = normalise(lastQuery) if (!terms.length) return true const matched = [...hit.excerpt.matchAll(/(.*?)<\/mark>/g)].flatMap((m) => normalise(m[1])) @@ -612,13 +614,36 @@ function initSearch() { .filter((word) => word.length > 1) } + const span = (className: string, props: Partial) => + el("span", { className: `search-result-${className}`, ...props }) + + const resultRow = (row: { + link: Partial + title: string + labelClass: "kind" | "url" + label: string + excerpt: Partial + }) => + el( + "li", + {}, + el( + "a", + row.link, + span("title", { textContent: row.title }), + " ", + span(row.labelClass, { textContent: row.label }), + span("excerpt", row.excerpt), + ), + ) + async function render(query: string) { if (query === lastQuery) return lastQuery = query selected = -1 if (query.length < 2) { - results.innerHTML = "" + results.replaceChildren() status.textContent = "" return } @@ -633,49 +658,37 @@ function initSearch() { const search = await engine.search(query) if (query !== lastQuery) return - const candidates = await Promise.all(search.results.slice(0, 30).map((result: any) => result.data())) + const candidates = await Promise.all(search.results.slice(0, 30).map((result) => result.data())) if (query !== lastQuery) return const top = candidates.filter(relevant).slice(0, 12) - results.innerHTML = "" - if (!top.length && !pinned.length) { + results.replaceChildren( + ...pinned.map((entity) => + resultRow({ + link: { href: entity.url, target: "_blank", rel: "noopener", className: "search-result-entity" }, + title: entity.name, + labelClass: "kind", + label: entity.kind, + excerpt: { textContent: entity.detail }, + }), + ), + ...top.map((hit) => + resultRow({ + link: { href: hit.url }, + title: hit.meta.title || hit.url, + labelClass: "url", + label: hit.url, + excerpt: { innerHTML: hit.excerpt }, + }), + ), + ) + + if (!results.childNodes.length) { status.textContent = `No results for “${query}”` return } - const total = top.length + pinned.length - status.textContent = `${total} result${total === 1 ? "" : "s"}` - - for (const entity of pinned) { - const item = document.createElement("li") - const link = document.createElement("a") - link.href = entity.url - link.target = "_blank" - link.rel = "noopener" - link.className = "search-result-entity" - link.innerHTML = - ' ' + - '' - link.querySelector(".search-result-title")!.textContent = entity.name - link.querySelector(".search-result-kind")!.textContent = entity.kind - link.querySelector(".search-result-excerpt")!.textContent = entity.detail - item.appendChild(link) - results.appendChild(item) - } - - for (const hit of top) { - const item = document.createElement("li") - const link = document.createElement("a") - link.href = hit.url - link.innerHTML = - ' ' + - '' - link.querySelector(".search-result-title")!.textContent = hit.meta.title || hit.url - link.querySelector(".search-result-url")!.textContent = hit.url - link.querySelector(".search-result-excerpt")!.innerHTML = hit.excerpt - item.appendChild(link) - results.appendChild(item) - } + status.textContent = `${results.childNodes.length} result${results.childNodes.length === 1 ? "" : "s"}` } openButton.addEventListener("click", open) diff --git a/layouts/partials/head.html b/layouts/partials/head.html index 374f1b5..428f961 100644 --- a/layouts/partials/head.html +++ b/layouts/partials/head.html @@ -8,7 +8,7 @@ {{ $styles := resources.Get "main.scss" | toCSS | minify }} -{{ $script := resources.Get "main.ts" | js.Build (dict "format" "esm" "minify" true "externals" (slice "bootstrap" "/pagefind/*")) }} +{{ $script := resources.Get "main.ts" | js.Build (dict "format" "esm" "minify" true "externals" (slice "bootstrap" "pagefind")) }} @@ -47,7 +47,8 @@ { "imports": { "@popperjs/core": "/bootstrap/js/popper.esm.min.js", - "bootstrap": "/bootstrap/js/bootstrap.esm.min.js" + "bootstrap": "/bootstrap/js/bootstrap.esm.min.js", + "pagefind": "/pagefind/pagefind.js" } } From 4912fc70d4255feee07cb4b0e4022c20a1b5eb7b Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 11 Sep 2026 16:59:23 +0200 Subject: [PATCH 2/3] stop blasting the CPU --- assets/main.scss | 54 ++++++++++++----- assets/main.ts | 154 +++++++++++++++-------------------------------- 2 files changed, 86 insertions(+), 122 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 59f80f2..1bf079b 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -3373,24 +3373,46 @@ body { height: 400px; padding: 20px; } -} -.dot { - position: absolute; - border-radius: 50%; - opacity: 0; - transform: scale(0); - transition: - transform 0.5s cubic-bezier(0.17, 0.67, 0.83, 0.67), - opacity 0.5s ease, - box-shadow 0.3s ease; - will-change: transform, opacity; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12); - cursor: pointer; -} + .dot { + position: absolute; + border-radius: 50%; + opacity: 0; + transform: scale(0); + transition: + transform 0.5s cubic-bezier(0.17, 0.67, 0.83, 0.67), + opacity 0.5s ease, + box-shadow 0.3s ease; + will-change: transform, opacity; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12); + cursor: pointer; -.animation-pulse { - animation: pulse 1.5s infinite; + &.is-visible { + opacity: 1; + transform: scale(1); + } + } + + // main.ts wraps each cluster in a .cluster group, which is what hovering and clicking a dot act on: + // the group is a zero-height box, but :hover and :focus still reach it from the dot inside. + .cluster { + outline: none; + + &:hover .dot.is-visible { + transform: scale(1.4); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25); + z-index: 5; + } + + .visualization:has(&:hover) &:not(:hover) .dot { + opacity: 0.4; + } + + // A click focuses the group (main.ts), so the cluster pulses once. + &:focus .dot { + animation: pulse 1.5s; + } + } } @keyframes pulse { diff --git a/assets/main.ts b/assets/main.ts index 9d49247..78acc9e 100644 --- a/assets/main.ts +++ b/assets/main.ts @@ -331,115 +331,61 @@ const initInteractiveViz = () => { }) function generateUMAP() { - for (const dot of visualization.querySelectorAll(".dot")) { - dot.remove() - } - const width = visualization.clientWidth const height = visualization.clientHeight - for (const cluster of colorClusters) { - const centerX = Math.random() * 0.6 * width + 0.2 * width - const centerY = Math.random() * 0.6 * height + 0.2 * height - - for (let i = 0; i < cluster.count; i++) { - const dot = document.createElement("div") - dot.className = "dot" - dot.dataset.cluster = cluster.name - dot.dataset.color = cluster.color - - const size = Math.floor(Math.random() * 8) + 5 - dot.style.width = `${size}px` - dot.style.height = `${size}px` - - let u = 0, - v = 0 - for (let j = 0; j < 6; j++) { - u += Math.random() - v += Math.random() - } - u = u / 6 - 0.5 - v = v / 6 - 0.5 - - const distance = Math.random() * 130 + 20 - const dx = u * distance * 2 - const dy = v * distance * 2 - const x = centerX + dx - const y = centerY + dy - - const safeX = Math.min(Math.max(size, x), width - size) - const safeY = Math.min(Math.max(size, y), height - size) - - dot.style.left = `${safeX}px` - dot.style.top = `${safeY}px` - dot.style.backgroundColor = cluster.color - - visualization.appendChild(dot) - - const dataPoint = Math.floor(Math.random() * 1000) - dot.dataset.id = `point-${dataPoint}` - - setTimeout( - () => { - dot.style.transform = "scale(1)" - dot.style.opacity = "1" - }, - i * 18 + Math.random() * 150, - ) - } - } - - setupDotInteractions() - } - - function setupDotInteractions() { - const dots = document.querySelectorAll(".dot") - - for (const dot of dots) { - dot.addEventListener("mouseenter", () => { - const thisColor = dot.dataset.color - - for (const otherDot of dots) { - if (otherDot.dataset.color === thisColor) { - otherDot.style.transform = "scale(1.4)" - otherDot.style.boxShadow = "0 6px 18px rgba(0,0,0,0.25)" - otherDot.style.zIndex = "5" - } else { - otherDot.style.opacity = "0.4" + // One wrapper per cluster: hover, dimming and the click pulse are all .cluster rules in + // main.scss, so the only thing left to do here is place the dots. + visualization.replaceChildren( + ...colorClusters.map((cluster) => { + const centerX = Math.random() * 0.6 * width + 0.2 * width + const centerY = Math.random() * 0.6 * height + 0.2 * height + + const dots = Array.from({ length: cluster.count }, (_unused, i) => { + const dot = document.createElement("div") + dot.className = "dot" + + const size = Math.floor(Math.random() * 8) + 5 + dot.style.width = `${size}px` + dot.style.height = `${size}px` + + let u = 0, + v = 0 + for (let j = 0; j < 6; j++) { + u += Math.random() + v += Math.random() } - } - }) + u = u / 6 - 0.5 + v = v / 6 - 0.5 - dot.addEventListener("mouseleave", () => { - for (const otherDot of dots) { - otherDot.style.transform = "scale(1)" - otherDot.style.opacity = "1" - otherDot.style.boxShadow = "0 4px 8px rgba(0,0,0,0.12)" - otherDot.style.zIndex = "1" - } - }) + const distance = Math.random() * 130 + 20 + const dx = u * distance * 2 + const dy = v * distance * 2 + const x = centerX + dx + const y = centerY + dy - dot.addEventListener("click", () => { - const thisColor = dot.dataset.color - const clusterDots = [] + const safeX = Math.min(Math.max(size, x), width - size) + const safeY = Math.min(Math.max(size, y), height - size) - for (const otherDot of dots) { - if (otherDot.dataset.color === thisColor) { - clusterDots.push(otherDot) - } - } + dot.style.left = `${safeX}px` + dot.style.top = `${safeY}px` + dot.style.backgroundColor = cluster.color - for (const otherDot of clusterDots) { - otherDot.classList.add("animation-pulse") + setTimeout(() => dot.classList.add("is-visible"), i * 18 + Math.random() * 150) + return dot + }) - setTimeout(() => { - otherDot.classList.remove("animation-pulse") - }, 1500) - } - }) - } + // tabIndex -1 keeps the group out of the tab order — the whole card is aria-hidden. + return el("div", { className: "cluster", tabIndex: -1 }, ...dots) + }), + ) } + // Clicking a dot focuses its cluster, which pulses it via :focus in main.scss. + visualization.addEventListener("click", (event) => { + ;(event.target as HTMLElement).closest(".cluster")?.focus() + }) + runCmd1.addEventListener("click", () => { statusCmd1.style.width = "0" execAnim1.style.width = "0" @@ -466,9 +412,8 @@ const initInteractiveViz = () => { execAnim2.style.width = "100%" }, 50) - for (const dot of visualization.querySelectorAll(".dot")) { - dot.style.opacity = "0" - dot.style.transform = "scale(0)" + for (const dot of visualization.querySelectorAll(".dot")) { + dot.classList.remove("is-visible") } setTimeout(() => { @@ -684,11 +629,8 @@ function initSearch() { ), ) - if (!results.childNodes.length) { - status.textContent = `No results for “${query}”` - return - } - status.textContent = `${results.childNodes.length} result${results.childNodes.length === 1 ? "" : "s"}` + const n = results.childNodes.length + status.textContent = n ? `${n} result${n === 1 ? "" : "s"}` : `No results for “${query}”` } openButton.addEventListener("click", open) From d289ed40e2df6f5e6832732c24f13db6641d6466 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 11 Sep 2026 17:14:57 +0200 Subject: [PATCH 3/3] fix pulse --- assets/main.scss | 18 +++++++----------- assets/main.ts | 9 +++++---- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 1bf079b..0700e5c 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -3404,26 +3404,22 @@ body { z-index: 5; } - .visualization:has(&:hover) &:not(:hover) .dot { - opacity: 0.4; - } - // A click focuses the group (main.ts), so the cluster pulses once. &:focus .dot { animation: pulse 1.5s; } } + + &:has(.cluster:hover) .cluster:not(:hover) .dot { + opacity: 0.4; + } } +// Animates `scale`, not `transform`: the clicked cluster is always hovered too, and an individual +// transform property multiplies with the `transform: scale(1.4)` hover instead of replacing it. @keyframes pulse { - 0% { - transform: scale(1); - } 50% { - transform: scale(1.3); - } - 100% { - transform: scale(1); + scale: 1.3; } } diff --git a/assets/main.ts b/assets/main.ts index 78acc9e..2267579 100644 --- a/assets/main.ts +++ b/assets/main.ts @@ -381,10 +381,11 @@ const initInteractiveViz = () => { ) } - // Clicking a dot focuses its cluster, which pulses it via :focus in main.scss. - visualization.addEventListener("click", (event) => { - ;(event.target as HTMLElement).closest(".cluster")?.focus() - }) + // Clicking a dot focuses its cluster, which pulses it via :focus in main.scss. Dropping the focus + // again once the pulse has run is what makes a second click on the same cluster pulse it again. + const cluster = (event: Event) => (event.target as HTMLElement).closest(".cluster") + visualization.addEventListener("click", (event) => cluster(event)?.focus()) + visualization.addEventListener("animationend", (event) => cluster(event)?.blur()) runCmd1.addEventListener("click", () => { statusCmd1.style.width = "0"