diff --git a/_data/learning-materials.yml b/_data/learning-materials.yml new file mode 100644 index 00000000..e539a0b9 --- /dev/null +++ b/_data/learning-materials.yml @@ -0,0 +1,186 @@ +# Learning materials shown on /learning-materials.html +# +# text: Title of the material (required) +# url: Link to the repository or guide (required) +# icon: Either a Bootstrap Icons class ("bi bi-github") or a path/URL to +# an image to use as the icon ("/images/logos/rstudio.png") +# description: Short summary, rendered as markdown +# tags: List of tags; these populate the sidebar tag selector +# last updated: YYYY-MM-DD, used by the default "Recently updated" sort +# featured: true to highlight the material and pin it to the top of the +# default listing (the pin is dropped once a visitor sorts) + +- text: Build a Conda-based Container for Bioinformatics + url: https://github.com/CHTC/biocafe-containers + icon: "bi bi-github" + description: | + This tutorial covers the basics of how to use containers to deploy software. + While this example is bioinformatics-flavored, the concepts can be applied + to any software stack. + tags: + - conda + - HTC + - bioinformatics + - CHTC-created + - container + last updated: 2026-05-11 + +- text: Learn How to Deploy R Calculations + url: https://github.com/chtc/tutorial-rstudio-to-chtc + icon: "bi bi-github" + description: | + A step-by-step example of transitioning from running R calculations + on your computer using RStudio, to running many such calculations + on a HTC system. + tags: + - R + - CHTC-created + - beginner-friendly + - HTC + last updated: 2026-04-02 + +- text: Predict Protein Structures with Alphafold3 + url: https://github.com/CHTC/tutorial-CHTC-AF3 + icon: "bi bi-github" + description: | + AlphaFold3 (AF3) predicts atomic-resolution biomolecular structures + for proteins, nucleic acids, and complexes. + tags: + - bioinformatics + - CHTC-created + - HTC + - GPU + last updated: 2026-06-24 + +- text: Machine Learning on GPUs with PyTorch + url: https://github.com/CHTC/tutorial-pytorch-gpu + icon: "bi bi-github" + description: | + Train and evaluate a PyTorch model on GPU capacity, including how to + request GPUs, check for CUDA availability, and checkpoint long-running + training jobs. + tags: + - machine-learning + - GPU + - python + - CHTC-created + - HTC + last updated: 2026-07-15 + +- text: Package Python Environments for Distributed Jobs + url: https://github.com/CHTC/tutorial-python-environments + icon: "bi bi-github" + description: | + Compare the ways of bringing a Python environment along with your jobs, + from `conda-pack` tarballs to purpose-built Apptainer containers. + tags: + - python + - conda + - container + - beginner-friendly + - CHTC-created + - HTC + last updated: 2026-08-19 + +- text: Run MPI Simulations on the HPC Cluster + url: https://github.com/CHTC/tutorial-hpc-mpi + icon: "bi bi-github" + description: | + An introduction to compiling and running multi-node MPI codes with SLURM, + including how to scale up gradually and measure parallel efficiency. + tags: + - HPC + - MPI + - CHTC-created + last updated: 2026-03-08 + +- text: Introduction to High Throughput Computing + url: https://github.com/OSGConsortium/osg-school-materials + icon: "bi bi-github" + description: | + Lessons and exercises from OSG School covering the ideas behind + high throughput computing and the mechanics of submitting your first + workloads. + featured: true + tags: + - HTC + - beginner-friendly + - workshop + last updated: 2026-02-20 + +- text: Manage Multi-Step Workflows with DAGMan + url: https://github.com/CHTC/tutorial-dagman + icon: "bi bi-github" + description: | + Chain jobs together into a workflow so that pre-processing, analysis, and + aggregation steps run in the right order and recover from failures. + featured: true + tags: + - workflows + - HTC + - CHTC-created + last updated: 2026-01-27 + +- text: Analyze Large Datasets with Dask + url: https://github.com/CHTC/tutorial-dask + icon: "bi bi-github" + description: | + Scale pandas-style analysis beyond a single machine, and learn when a + Dask cluster is the right tool versus many independent HTC jobs. + tags: + - python + - data-analysis + - HTC + last updated: 2025-11-06 + +- text: Get Started with HTC in Ten Jobs + url: https://github.com/CHTC/tutorial-first-jobs + icon: "bi bi-github" + featured: true + description: | + The place to start if you have never submitted a job before: ten small + exercises that build up from a single "hello world" job to a batch of + hundreds. + tags: + - HTC + - beginner-friendly + - CHTC-created + - workshop + last updated: 2025-12-15 + +- text: Run MATLAB Jobs Without a License Server + url: https://github.com/CHTC/tutorial-matlab-compiler + icon: "/images/Matlab_Logo.png" + description: | + Compile MATLAB code into a standalone executable so that your jobs can run + anywhere in the pool, without needing to check out a license. + tags: + - MATLAB + - HTC + - CHTC-created + last updated: 2026-07-02 + +- text: Checkpoint Long-Running Jobs + url: https://github.com/CHTC/tutorial-self-checkpointing + icon: "bi bi-github" + description: | + Break a multi-day computation into restartable pieces so it survives + interruptions and makes steady progress on opportunistic capacity. + tags: + - HTC + - workflows + - CHTC-created + last updated: 2026-06-05 + +- text: Move Large Datasets In and Out of CHTC + url: https://github.com/CHTC/tutorial-data-transfer + icon: "bi bi-github" + description: | + Choose between HTCondor file transfer, staging space, and Globus for the + inputs and outputs your jobs produce, with examples of each. + tags: + - data-transfer + - HTC + - beginner-friendly + - CHTC-created + last updated: 2026-05-28 diff --git a/_uw-research-computing/learning-materials.html b/_uw-research-computing/learning-materials.html new file mode 100644 index 00000000..ce981c82 --- /dev/null +++ b/_uw-research-computing/learning-materials.html @@ -0,0 +1,163 @@ +--- +layout: default +title: Learning Materials +description: A collection of tutorials, workshops, and example repositories for learning high throughput and high performance computing. +js_extension: +- src: "/assets/js/pages/learning-materials.js" + loading: defer + type: module +--- + +{% assign by_date = site.data.learning-materials | sort: "last updated" | reverse %} +{% assign featured_materials = by_date | where: "featured", true %} +{% assign other_materials = by_date | where_exp: "material", "material.featured != true" %} +{% comment %} Featured materials lead the default listing; JS drops the pin once a visitor sorts. {% endcomment %} +{% assign materials = featured_materials | concat: other_materials %} +{% assign all_tags = materials | map: "tags" | join: "," | split: "," | uniq | sort_natural %} + +
+ {% include page-title.html title=page.title %} + +

+ Tutorials, workshop materials, and example repositories for learning how to run + your research computing work at scale. Filter by topic using the tags, or search + for a keyword. +

+ +
+ + +
+ + +
+
+
+

Tags

+ +
+ +
    + {% for tag in all_tags %} + {% assign tagged = materials | where_exp: "material", "material.tags contains tag" %} +
  • + +
  • + {% endfor %} +
+
+
+
+ + +
+ +
+ + +
+ + +
+
+ +
+

+ Showing {{ materials.size }} of {{ materials.size }} guides +

+
+
+ +
+ {% for material in materials %} + + {% assign updated = material.last_updated | default: material["last updated"] %} + {% assign icon = material.icon | default: "bi bi-github" %} + {% assign tag_string = material.tags | join: " " %} + +
+
+ {% if material.featured %} + + Featured + + {% endif %} + + +
+ {{ material.description | markdownify }} +
+ +
+ {% for tag in material.tags %} + + {% endfor %} +
+ + +
+
+ {% endfor %} +
+ + + + {% comment %} + Every material is in the HTML above so search engines and no-JS visitors see the + full list; learning-materials.js pages the grid 10 at a time and fills this in. + {% endcomment %} + + +
+
+
diff --git a/assets/css/style-v12.scss b/assets/css/style-v12.scss index 3e3486a9..b3831161 100644 --- a/assets/css/style-v12.scss +++ b/assets/css/style-v12.scss @@ -368,3 +368,378 @@ h6:hover .header-link { text-decoration: none; color: inherit; } + +// Learning materials page (/learning-materials.html) +#learning-materials { + + .lm-intro { + max-width: 60rem; + } + + // -- Sidebar tag selector -- + .lm-sidebar { + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0.25rem; + background-color: #fffcfc; + padding: 0.75rem; + @include media-breakpoint-up(lg) { + position: sticky; + top: 1rem; + max-height: calc(100vh - 2rem); + overflow-y: auto; + } + } + + .lm-sidebar-header { + display: flex; + align-items: baseline; + justify-content: space-between; + border-bottom: 1px solid rgba(0, 0, 0, 0.15); + padding-bottom: 0.5rem; + margin-bottom: 0.5rem; + } + + .lm-clear-tags { + text-decoration: none; + color: $primary; + &:hover { + text-decoration: underline; + } + } + + // The UW theme gives every li a 1.5rem bottom margin; keep the tag list compact. + .lm-tag-list li { + margin: 0; + font-size: inherit; + line-height: 1.3; + } + + .lm-tag-option { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + margin-bottom: 0; + padding: 0.1rem 0.35rem; + border-radius: 0.2rem; + font-size: 0.85rem; + font-weight: 400; + cursor: pointer; + &:hover { + background-color: #f9e6e7; + } + .form-check-input { + margin: 0; + flex: 0 0 auto; + cursor: pointer; + } + .lm-tag-name { + flex: 1 1 auto; + overflow-wrap: anywhere; + } + } + + .lm-tag-count { + flex: 0 0 auto; + min-width: 1.6rem; + text-align: center; + padding: 0 0.3rem; + border-radius: 1rem; + background-color: #eceeef; + color: #495057; + font-size: 0.75rem; + line-height: 1.4; + } + + // -- Filter and sort toolbar -- + .lm-toolbar { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.75rem; + margin-bottom: 0.75rem; + } + + .lm-search { + position: relative; + flex: 1 1 18rem; + min-width: 0; + .form-control { + width: 100%; + padding-left: 2.1rem; + } + } + + .lm-search-icon { + position: absolute; + top: 50%; + left: 0.75rem; + transform: translateY(-50%); + color: #6c757d; + pointer-events: none; + } + + .lm-sort { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 0 1 auto; + .form-label { + white-space: nowrap; + font-weight: 400; + color: #495057; + } + .form-select { + width: auto; + } + } + + .lm-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + font-size: 0.85rem; + } + + .lm-active-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + } + + .lm-active-tag { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid $primary; + background-color: #f9e6e7; + color: #7a0307; + border-radius: 1rem; + padding: 0.05rem 0.6rem; + font-size: 0.75rem; + i { + font-size: 0.6rem; + } + &:hover { + background-color: $primary; + color: $white; + } + } + + // -- Cards -- + .lm-card { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0.25rem; + background-color: $white; + padding: 1rem; + transition: box-shadow 0.15s ease-in-out, border-color 0.15s ease-in-out; + // The title link is a .stretched-link, so the whole card is clickable. + &:hover { + border-color: #999; + box-shadow: 0 0.35rem 0.75rem rgba(0, 0, 0, 0.1); + .lm-card-title a { + color: $primary; + text-decoration: underline; + } + } + } + + // Featured materials: gold trim and a starred label in the footer. + .lm-card-featured { + border-color: #c9a227; + background-color: #fffdf4; + box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.35); + &:hover { + border-color: #a9861a; + box-shadow: 0 0.35rem 0.75rem rgba(139, 105, 20, 0.2); + } + .lm-tag-badge { + border-color: #e4d5a1; + background-color: #fbf5e2; + } + .lm-card-footer { + border-top-color: rgba(201, 162, 39, 0.4); + } + } + + // Gold star straddling the top right corner, out of the document flow so it + // never displaces the title. Sits above the card's stretched link so it receives hover. + .lm-featured-label { + position: absolute; + top: -0.55rem; + right: -0.55rem; + z-index: 2; + display: inline-flex; + align-items: center; + height: 1.5rem; + padding: 0 0.4rem; + border: 1px solid #e3cf83; + border-radius: 1rem; + background-color: #fbf3d8; + color: #8a6a06; + font-size: 0.65rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: default; + transition: padding 0.2s ease-in-out; + i { + font-size: 0.75rem; + color: #d9a406; + } + .lm-featured-text { + display: inline-block; + max-width: 0; + overflow: hidden; + opacity: 0; + transition: max-width 0.2s ease-in-out, opacity 0.15s ease-in-out, margin 0.2s ease-in-out; + } + &:hover, + &:focus-visible { + padding-right: 0.55rem; + .lm-featured-text { + max-width: 5rem; + margin-left: 0.3rem; + opacity: 1; + } + } + } + + .lm-card-head { + margin-bottom: 0.5rem; + } + + .lm-card-icon { + font-size: 0.9rem; + line-height: 1; + color: #212529; + } + + .lm-card-icon-img { + width: 1rem; + height: 1rem; + object-fit: contain; + } + + .lm-card-title { + margin: 0; + font-size: 1.05rem; + line-height: 1.3; + a { + color: #212529; + text-decoration: none; + &:hover { + color: $primary; + text-decoration: underline; + } + } + } + + .lm-card-description { + font-size: 0.9rem; + line-height: 1.45; + color: #495057; + // Override the UW theme's global paragraph size so the card size applies. + p { + font-size: inherit; + line-height: inherit; + margin-bottom: 0.5rem; + } + } + + .lm-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-top: auto; + padding-top: 0.5rem; + } + + // Raised above the card's stretched link so the tags stay clickable. + .lm-tag-badge { + position: relative; + z-index: 2; + border: 1px solid #d5d5d5; + background-color: #f1f1f1; + color: #495057; + border-radius: 1rem; + padding: 0.05rem 0.55rem; + font-size: 0.7rem; + &:hover { + border-color: $primary; + color: #7a0307; + } + &.active { + border-color: $primary; + background-color: #f9e6e7; + color: #7a0307; + } + } + + .lm-card-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.25rem 0.75rem; + margin-top: 0.6rem; + padding-top: 0.5rem; + border-top: 1px solid rgba(0, 0, 0, 0.1); + font-size: 0.7rem; + color: #6c757d; + } + + // Source icon (GitHub etc.) and host on the left; featured label and date on the right. + .lm-card-source, + .lm-card-meta { + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-width: 0; + } + + .lm-card-meta { + margin-left: auto; + } + + + // -- Pagination (rendered by JS; all cards are in the HTML for no-JS visitors and SEO) -- + .lm-pagination { + margin-top: 0.5rem; + .pagination { + margin-bottom: 0.5rem; + } + .page-link { + color: $primary; + &:hover { + background-color: #f9e6e7; + color: #7a0307; + } + } + .page-item.active .page-link { + background-color: $primary; + border-color: $primary; + color: $white; + } + } + + .lm-pagination-summary { + text-align: center; + font-size: 0.8rem; + color: #6c757d; + } + + .lm-no-results { + border: 1px dashed rgba(0, 0, 0, 0.25); + border-radius: 0.25rem; + padding: 1.5rem; + text-align: center; + color: #495057; + } +} diff --git a/assets/fonts.zip b/assets/fonts.zip new file mode 100644 index 00000000..6cb06962 Binary files /dev/null and b/assets/fonts.zip differ diff --git a/assets/js/pages/learning-materials.js b/assets/js/pages/learning-materials.js new file mode 100644 index 00000000..49fad757 --- /dev/null +++ b/assets/js/pages/learning-materials.js @@ -0,0 +1,315 @@ +/** + * Learning materials page: tag filtering (sidebar), keyword search, sorting, + * and pagination. + * + * All materials are rendered by Jekyll and filtered/sorted/paged client side, + * so the page still works without JavaScript (default order is "recently + * updated" with every material listed) and search engines see the full list. + */ +const PAGE_SIZE = 12; + +class LearningMaterialsBrowser { + + constructor(root) { + this.root = root; + this.grid = root.querySelector("#lm-grid"); + this.cards = Array.from(root.querySelectorAll(".lm-card-col")); + this.tagCheckboxes = Array.from(root.querySelectorAll("input[data-lm-tag]")); + this.searchInput = root.querySelector("#lm-search-input"); + this.sortSelect = root.querySelector("#lm-sort-select"); + this.countText = root.querySelector("#lm-count"); + this.activeTagContainer = root.querySelector("#lm-active-tags"); + this.clearTagsButton = root.querySelector("#lm-clear-tags"); + this.resetButton = root.querySelector("#lm-reset-all"); + this.noResults = root.querySelector("#lm-no-results"); + this.pagination = root.querySelector("#lm-pagination"); + + this.selectedTags = new Set(); + this.searchTerm = ""; + this.page = 1; + // Featured materials lead the list until the visitor picks a sort order. + this.featuredFirst = true; + + // Cache the values we filter and sort on. + this.cards.forEach((card) => { + card.lmTags = (card.dataset.tags || "").split("|").filter(Boolean); + card.lmSearch = (card.dataset.search || "").toLowerCase(); + card.lmTitle = (card.dataset.title || "").toLowerCase(); + card.lmUpdated = card.dataset.updated || ""; + card.lmFeatured = card.dataset.featured === "true"; + }); + } + + initialize() { + this.tagCheckboxes.forEach((checkbox) => { + checkbox.addEventListener("change", () => { + this.setTag(checkbox.dataset.lmTag, checkbox.checked); + }); + }); + + // Tags on the cards themselves double as filter shortcuts. + this.root.querySelectorAll("[data-lm-tag-toggle]").forEach((badge) => { + badge.addEventListener("click", () => { + const tag = badge.dataset.lmTagToggle; + this.setTag(tag, !this.selectedTags.has(tag)); + }); + }); + + this.searchInput.addEventListener("input", () => { + this.searchTerm = this.searchInput.value.trim().toLowerCase(); + this.page = 1; + this.apply(); + }); + + this.sortSelect.addEventListener("change", () => { + this.featuredFirst = false; + this.page = 1; + this.apply(); + }); + this.clearTagsButton.addEventListener("click", () => this.clearTags()); + this.resetButton.addEventListener("click", () => this.reset()); + + this.apply(); + } + + setTag(tag, selected) { + if (selected) { + this.selectedTags.add(tag); + } else { + this.selectedTags.delete(tag); + } + + this.tagCheckboxes.forEach((checkbox) => { + checkbox.checked = this.selectedTags.has(checkbox.dataset.lmTag); + }); + + this.page = 1; + this.apply(); + } + + clearTags() { + this.selectedTags.clear(); + this.tagCheckboxes.forEach((checkbox) => { checkbox.checked = false; }); + this.page = 1; + this.apply(); + } + + reset() { + this.selectedTags.clear(); + this.tagCheckboxes.forEach((checkbox) => { checkbox.checked = false; }); + this.searchTerm = ""; + this.searchInput.value = ""; + this.page = 1; + this.apply(); + } + + goToPage(page) { + this.page = page; + this.apply(); + // The pager sits below the grid, so bring the top of the results back into view. + this.countText.scrollIntoView({ block: "start", behavior: "smooth" }); + } + + matches(card) { + // A card matches if it carries at least one of the selected tags. + if (this.selectedTags.size > 0) { + const hasTag = card.lmTags.some((tag) => this.selectedTags.has(tag)); + if (!hasTag) return false; + } + + if (this.searchTerm && !card.lmSearch.includes(this.searchTerm)) { + return false; + } + + return true; + } + + sortCards(cards) { + const mode = this.sortSelect.value; + const featuredFirst = this.featuredFirst; + const byTitle = (a, b) => a.lmTitle.localeCompare(b.lmTitle); + // Undated materials sort last, whichever direction the dates run. + const byDate = (a, b) => { + if (a.lmUpdated === b.lmUpdated) return byTitle(a, b); + if (!a.lmUpdated) return 1; + if (!b.lmUpdated) return -1; + return a.lmUpdated < b.lmUpdated ? -1 : 1; + }; + + let compare; + switch (mode) { + case "updated-asc": compare = byDate; break; + case "title-asc": compare = byTitle; break; + case "title-desc": compare = (a, b) => byTitle(b, a); break; + case "updated-desc": + default: compare = (a, b) => byDate(b, a); break; + } + + if (featuredFirst) { + const withinGroup = compare; + compare = (a, b) => { + if (a.lmFeatured !== b.lmFeatured) return a.lmFeatured ? -1 : 1; + return withinGroup(a, b); + }; + } + + return cards.sort(compare); + } + + renderActiveTags() { + this.activeTagContainer.innerHTML = ""; + + this.selectedTags.forEach((tag) => { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "lm-active-tag"; + chip.innerHTML = `${tag}`; + chip.setAttribute("aria-label", `Remove ${tag} filter`); + chip.addEventListener("click", () => this.setTag(tag, false)); + this.activeTagContainer.appendChild(chip); + }); + + this.clearTagsButton.hidden = this.selectedTags.size === 0; + } + + /** + * Which page numbers to show as buttons: first, last, and a window around + * the current page. `null` marks a gap that renders as an ellipsis. + */ + pageNumbers(pageCount) { + if (pageCount <= 7) { + return Array.from({ length: pageCount }, (_, i) => i + 1); + } + + const pages = new Set([1, pageCount]); + for (let p = this.page - 1; p <= this.page + 1; p++) { + if (p >= 1 && p <= pageCount) pages.add(p); + } + + const sorted = Array.from(pages).sort((a, b) => a - b); + const withGaps = []; + sorted.forEach((p, i) => { + if (i > 0 && p - sorted[i - 1] > 1) withGaps.push(null); + withGaps.push(p); + }); + return withGaps; + } + + renderPagination(pageCount, matchCount) { + this.pagination.innerHTML = ""; + this.pagination.hidden = pageCount <= 1; + if (pageCount <= 1) return; + + const list = document.createElement("ul"); + list.className = "pagination pagination-sm justify-content-center flex-wrap"; + + const addItem = ({ label, html, page, disabled = false, active = false, ellipsis = false }) => { + const item = document.createElement("li"); + item.className = "page-item"; + if (disabled) item.classList.add("disabled"); + if (active) item.classList.add("active"); + + if (ellipsis) { + const span = document.createElement("span"); + span.className = "page-link"; + span.textContent = "\u2026"; + item.classList.add("disabled"); + item.appendChild(span); + } else { + const button = document.createElement("button"); + button.type = "button"; + button.className = "page-link"; + if (html) { + button.innerHTML = html; + } else { + button.textContent = String(page); + } + if (label) button.setAttribute("aria-label", label); + if (active) button.setAttribute("aria-current", "page"); + if (disabled) button.disabled = true; + button.addEventListener("click", () => this.goToPage(page)); + item.appendChild(button); + } + + list.appendChild(item); + }; + + addItem({ + label: "Previous page", + html: '', + page: this.page - 1, + disabled: this.page === 1, + }); + + this.pageNumbers(pageCount).forEach((p) => { + if (p === null) { + addItem({ ellipsis: true }); + } else { + addItem({ label: `Page ${p}`, page: p, active: p === this.page }); + } + }); + + addItem({ + label: "Next page", + html: '', + page: this.page + 1, + disabled: this.page === pageCount, + }); + + const summary = document.createElement("p"); + summary.className = "lm-pagination-summary mb-0"; + summary.textContent = `Page ${this.page} of ${pageCount} (${matchCount} guides)`; + + this.pagination.appendChild(list); + this.pagination.appendChild(summary); + } + + apply() { + const matching = []; + const hidden = []; + this.cards.forEach((card) => { + (this.matches(card) ? matching : hidden).push(card); + }); + + this.sortCards(matching); + + // Clamp the page in case a filter change shrank the result set. + const pageCount = Math.max(1, Math.ceil(matching.length / PAGE_SIZE)); + this.page = Math.min(Math.max(1, this.page), pageCount); + const start = (this.page - 1) * PAGE_SIZE; + const end = Math.min(start + PAGE_SIZE, matching.length); + + matching.forEach((card, index) => { + card.hidden = index < start || index >= end; + }); + hidden.forEach((card) => { card.hidden = true; }); + + // Re-order the matching cards; non-matching ones are parked at the end. + matching.forEach((card) => this.grid.appendChild(card)); + hidden.forEach((card) => this.grid.appendChild(card)); + + this.root.querySelectorAll("[data-lm-tag-toggle]").forEach((badge) => { + badge.classList.toggle("active", this.selectedTags.has(badge.dataset.lmTagToggle)); + }); + + if (matching.length <= PAGE_SIZE) { + this.countText.textContent = `Showing ${matching.length} of ${this.cards.length} guides`; + } else { + const filtered = this.selectedTags.size > 0 || this.searchTerm !== ""; + const noun = filtered ? "matching guides" : "guides"; + this.countText.textContent = + `Showing ${start + 1}\u2013${end} of ${matching.length} ${noun}`; + } + this.noResults.hidden = matching.length > 0; + + this.renderPagination(pageCount, matching.length); + this.renderActiveTags(); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const root = document.getElementById("learning-materials"); + if (root) { + new LearningMaterialsBrowser(root).initialize(); + } +});