diff --git a/EXPLORER_STATE.md b/EXPLORER_STATE.md
index 177df95..874d66a 100644
--- a/EXPLORER_STATE.md
+++ b/EXPLORER_STATE.md
@@ -92,18 +92,22 @@ predates the store-on-`viewer` pattern and isn't worth migrating.
| ~~`document.body.classList['table-view-active']`~~ | _removed in mockup-v1 (#200)_ | — | — | The view marker class is gone with the Globe/Table toggle. The samples table is permanent below the globe; `isTableViewActive()` and `setView()` were deleted |
| `data-facet`, `data-value` on `.facet-row` and `.facet-count` | facet selectors for in-place count mutation | rendered in `renderFilter` and the static source legend | `applyFacetCounts()` | rebuilding the HTML would lose mid-interaction selections; `data-*` attrs are why we mutate counts in place |
| `data-lat`, `data-lng`, `data-pid` on `.sample-row` | click-to-fly payload for search/nearby results | rendered in `doSearch()` | search-row click handler | the nearby-samples list does not have data-* today; click-to-fly only works from the search list |
-| `data-pid` on `.samples-table tbody tr` | click-to-select payload for the permanent table | rendered in `tableView` `renderTable()` | table-row click handler (same ceremony as the search-row click — see §6 mockup-v1 addendum) | per-PID lookup uses `rowsByPid: Map` cached at `refreshTable()` time; not the DOM |
-| `tr.selected` on `.samples-table` | "this row is the current sample selection" visual marker | table-row click handler (`add`); next click on a different row (`remove`); next `renderTable()` reflects current `viewer._globeState.selectedPid` | CSS only | derived; do not read. The globe → table direction is *not* live (only repaints on the next refresh) |
+| `data-pid` on `.samples-table tbody tr` | click-to-select payload for the permanent table | rendered in `tableView` `renderTable()` | table-row click handler (same ceremony as the search-row click — see §6 mockup-v1 addendum) | per-PID lookup uses `pageRowsByPid: Map` cached per page at `loadPage()` time (table v2); only the current page is in memory |
+| `tr.selected` on `.samples-table` | "this row is the current sample selection" visual marker | table-row click handler (`add`); next click on a different row (`remove`); next `renderTable()` reflects current `viewer._globeState.selectedPid` | CSS only | derived; do not read. The globe → table direction is *not* live (only repaints on the next page load) |
+| `#tableContainer.is-loading` + `aria-busy="true"` | "table query in flight" marker | `setLoading(true/false)` in `tableView` (table v2) | CSS dim (`.samples-table` opacity 0.6); screen readers | added in table v2 follow-up to PR #200 for stale-while-loading UX |
| `.recomputing` on `.facet-count` | transient "loading" styling during cross-filter recompute | `markFacetCountsRecomputing()` (`:570`) | `applyFacetCounts()` clears it (`:562`) | UI-only; not state in the persistence sense |
| `.zero` on `.facet-row` | "value has zero count under current filters" styling | `applyFacetCounts()` (`:565`) | CSS only | derived; do not read |
| `.disabled` on `#sourceFilter .legend-item` | unchecked source visual | `updateSourceLegendState()` (`:395-400`) | CSS only | derived from checkbox `checked`; do not read |
DOM input elements (the four facet checkbox bodies + `#sampleSearch` +
-`#sampleSearchSidebar` + `#maxSamples`) are the **source of truth** for
-`getActiveSources()`, `getCheckedValues()`, `getTableMaxSamples()`, and
-the search input. SQL builders read `#sampleSearch` directly each call.
-`#sampleSearchSidebar` is kept in lock-step with `#sampleSearch` via a
-two-way `input`-event mirror (see §6 mockup-v1 addendum).
+`#sampleSearchSidebar`) are the **source of truth** for
+`getActiveSources()`, `getCheckedValues()`, and the search input. SQL
+builders read `#sampleSearch` directly each call. `#sampleSearchSidebar`
+is kept in lock-step with `#sampleSearch` via a two-way `input`-event
+mirror (see §6 mockup-v1 addendum). The `#maxSamples` input and the
+`getTableMaxSamples()` / `clampTableMaxSamples()` helpers were removed
+in the table v2 follow-up — the samples table now paginates server-side
+via DuckDB `LIMIT/OFFSET` instead of fetching up to 25K rows up-front.
---
@@ -454,6 +458,63 @@ The biggest delta to this contract:
struck), and §5 (OJS cell graph: `tableView` no longer writes `view`
to URL; `tableView` does write `#pid` to hash via `buildHash`).
+### Table v2 addendum: server-side pagination ([#218](https://github.com/isamplesorg/isamplesorg.github.io/issues/218), 2026-05)
+
+Follow-up to the mockup-v1 PR. The samples table no longer fetches up
+to 25,000-100,000 rows up-front and paginates client-side. Each page
+is now its own DuckDB `LIMIT TABLE_PAGE_SIZE OFFSET page*size` query,
+plus one `COUNT(*)` query per filter change. Removes the `#maxSamples`
+input + `getTableMaxSamples()` / `clampTableMaxSamples()` helpers
+entirely.
+
+**Determinism.** `ORDER BY pid` plus `WHERE pid IS NOT NULL` on
+**both** the page query and the `COUNT(*)` query makes "Page N is
+the same N rows" actually true, and keeps the count consistent with
+what's pageable. Defensive null filter even though `pid` is the
+canonical identifier and should never be null — ORDER BY a column
+that contains nulls is only deterministic by accident on a read-only
+parquet snapshot, and an unfiltered count could over-enable
+pagination past the last non-null page.
+
+**Stale-while-loading.** When filters change or the user pages, the
+existing rendered rows stay visible (dimmed to 60% opacity via
+`#tableContainer.is-loading .samples-table`) while the new page+count
+queries run in the DuckDB Web Worker. A CSS-only spinner appears in
+`#tableMeta`. `#tableContainer[aria-busy="true"]` exposes the state
+to screen readers. The pager-info text is cleared during load to
+avoid showing stale "Page 3 of 12 (200-300 of 1,200)" against an
+incoming filter set. `prefers-reduced-motion` is honored.
+
+**Race protection.** A `pageGen` integer is bumped on every refresh.
+Inner queries (`loadCount`, `loadPage`) compare `gen === pageGen`
+BEFORE mutating `pageRows`, `pageRowsByPid`, `totalRows`, or
+`currentPage`. `refreshAll` / `refreshPage` re-check the same gen
+before clearing the loading state, so a faster newer load can win
+the visible UI even if an older load resolves last.
+
+**Error handling.** `loadCount` and `loadPage` both return
+`true`/`false` to the orchestrator. Three distinct error surfaces:
+
+- **Page load failed:** meta shows the error, `lastPageFailed` flag
+ flips on, and `renderTable()` swaps the table body for an explicit
+ "Page query failed. Adjust filters or click Previous/Next to retry."
+ sentinel row (rather than leaving the old, now-inert rows visible
+ with a cleared `pageRowsByPid`). Pager text is cleared.
+- **Count failed but page succeeded:** rows render, but `totalRows`
+ stays `null`. Pager text shows "Page N" without the total. The
+ Next button is disabled while `totalRows == null` (so a user can't
+ click it into a no-op handler).
+- **Both failed:** generic error meta; sentinel table state.
+
+This replaces the round-1 codex finding where the error meta was
+being overwritten by the success summary, and the round-2 finding
+where a failed page left old DOM visible but pageRowsByPid empty.
+
+**Click handler unchanged.** Table-row click uses
+`pageRowsByPid: Map` (renamed from `rowsByPid`) which is now scoped
+to the current page only — sufficient since only the visible page has
+clickable rows.
+
---
## 7. Facet-count contract
diff --git a/explorer.qmd b/explorer.qmd
index cb6e3ac..db1be7d 100644
--- a/explorer.qmd
+++ b/explorer.qmd
@@ -258,24 +258,32 @@ format:
overflow-y: auto;
line-height: 1.35;
}
- .table-controls {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 12px;
- color: #555;
- margin-bottom: 8px;
- }
- .table-controls input {
- width: 92px;
- padding: 5px 8px;
- border: 1px solid #b8c7d9;
- border-radius: 4px;
- font-size: 13px;
- }
#tableContainer {
margin-bottom: 16px;
}
+ /* Stale-while-loading: dim the existing table to ~60% opacity while
+ a new page or filter result is being fetched, so the user can see
+ that the visible rows are not yet current. The CSS-animated spinner
+ in #tableMeta provides the explicit "working" affordance. */
+ #tableContainer.is-loading .samples-table {
+ opacity: 0.6;
+ }
+ .samples-table { transition: opacity 0.15s ease; }
+ .table-loading-spinner {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 2px solid #cfd8e3;
+ border-top-color: #1565c0;
+ border-radius: 50%;
+ animation: tableSpin 0.8s linear infinite;
+ vertical-align: -2px;
+ margin-right: 6px;
+ }
+ @keyframes tableSpin { to { transform: rotate(360deg); } }
+ @media (prefers-reduced-motion: reduce) {
+ .table-loading-spinner { animation: none; border-top-color: #1565c0; }
+ }
.samples-section-heading {
font-size: 15px;
font-weight: 600;
@@ -477,11 +485,7 @@ Loading H3 global overview...
Samples table
-
-
-
-
-
+
Loading samples matching the current filters...
@@ -944,10 +948,12 @@ function updateSamples(samples) {
}
// === Samples table (permanent below globe; M-5) ===
+// Server-side pagination via DuckDB LIMIT/OFFSET (table v2, #200 follow-up).
+// Previously the table loaded up to 25,000-100,000 rows up-front and sliced
+// client-side; now each page is a fresh DuckDB query, and a COUNT(*) is
+// issued on filter change so the pager knows the total. The deterministic
+// ORDER BY pid makes "Page N is the same N rows" actually true.
TABLE_PAGE_SIZE = 100
-TABLE_DEFAULT_MAX = 25000
-TABLE_MIN_MAX = 1000
-TABLE_MAX_MAX = 100000
function escapeHtml(value) {
return String(value ?? '')
@@ -958,19 +964,6 @@ function escapeHtml(value) {
.replace(/'/g, ''');
}
-function clampTableMaxSamples(value) {
- const n = parseInt(value, 10);
- if (!Number.isFinite(n)) return TABLE_DEFAULT_MAX;
- return Math.min(TABLE_MAX_MAX, Math.max(TABLE_MIN_MAX, n));
-}
-
-function getTableMaxSamples() {
- const el = document.getElementById('maxSamples');
- const value = clampTableMaxSamples(el ? el.value : TABLE_DEFAULT_MAX);
- if (el && String(value) !== String(el.value)) el.value = value;
- return value;
-}
-
```
```{ojs}
@@ -1306,28 +1299,81 @@ facetFilters = {
//| echo: false
//| output: false
-// === Table view: paginated sample rows matching current filters ===
+// === Table view: server-side paginated sample rows (table v2) ===
+//
+// Architecture:
+// - One DuckDB query per page (LIMIT TABLE_PAGE_SIZE OFFSET page*size).
+// - One COUNT(*) query per filter change (cached until filters change again).
+// - ORDER BY pid for deterministic page contents.
+// - Stale-while-loading: old rows stay visible (dimmed via .is-loading class)
+// while the new page/count is fetched in the DuckDB Web Worker. aria-busy
+// on #tableContainer signals to screen readers.
+// - pageGen invalidates in-flight queries when a newer one starts.
tableView = {
if (!facetFilters) return;
- let rows = [];
- let rowsByPid = new Map();
- let page = 0;
- let requestId = 0;
- let loadedMax = 0;
- let hitHardCap = false;
+ let totalRows = null; // null = "unknown / fetching"; set by loadCount()
+ let pageRows = []; // rows visible right now
+ let pageRowsByPid = new Map(); // pid → row lookup for row-click handler
+ let currentPage = 0;
+ let pageGen = 0; // bumped on any new load; in-flight callbacks check this
+ let lastPageFailed = false; // surfaces a sentinel table state when loadPage errors
- const maxInput = document.getElementById('maxSamples');
const prevBtn = document.getElementById('tablePrev');
const nextBtn = document.getElementById('tableNext');
const metaEl = document.getElementById('tableMeta');
const pageInfoEl = document.getElementById('tablePageInfo');
const tableEl = document.getElementById('samplesTable');
+ const containerEl = document.getElementById('tableContainer');
+
+ function totalPagesFor(total) {
+ return Math.max(1, Math.ceil(total / TABLE_PAGE_SIZE));
+ }
+
+ function setLoading(loading) {
+ if (containerEl) {
+ containerEl.classList.toggle('is-loading', loading);
+ containerEl.setAttribute('aria-busy', String(loading));
+ }
+ // Disable pager during load so a user can't queue a second click while
+ // a query is in flight. updatePagerEdges() re-enables based on edge.
+ if (loading) {
+ if (prevBtn) prevBtn.disabled = true;
+ if (nextBtn) nextBtn.disabled = true;
+ } else {
+ updatePagerEdges();
+ }
+ }
+
+ function updatePagerEdges() {
+ if (prevBtn) prevBtn.disabled = currentPage <= 0;
+ if (nextBtn) {
+ // Next is disabled when totalRows is unknown (count query
+ // failed or hasn't returned) — otherwise the button looks
+ // active but its click handler bails, which is confusing.
+ // It's also disabled once we're at the last page.
+ if (totalRows == null) {
+ nextBtn.disabled = true;
+ } else {
+ nextBtn.disabled = currentPage >= totalPagesFor(totalRows) - 1;
+ }
+ }
+ }
- function setMeta(text, loading) {
+ function setMeta(text, isError) {
if (!metaEl) return;
metaEl.textContent = text;
- metaEl.style.color = loading ? '#1565c0' : '#555';
+ metaEl.style.color = isError ? '#c62828' : '#555';
+ }
+
+ function setMetaLoading(text) {
+ if (!metaEl) return;
+ metaEl.innerHTML = `${escapeHtml(text)}`;
+ metaEl.style.color = '#1565c0';
+ // Clear the pager-info text while loading so it doesn't show
+ // stale "Page 3 of 12 (200-300 of 1,200)" against an incoming
+ // filter change. renderTable() repopulates it after data arrives.
+ if (pageInfoEl) pageInfoEl.textContent = '';
}
function tableSourceBadge(source) {
@@ -1337,18 +1383,20 @@ tableView = {
}
function renderTable() {
- const totalPages = Math.max(1, Math.ceil(rows.length / TABLE_PAGE_SIZE));
- page = Math.min(page, totalPages - 1);
- const start = page * TABLE_PAGE_SIZE;
- const visible = rows.slice(start, start + TABLE_PAGE_SIZE);
const selectedPid = (typeof viewer !== 'undefined' && viewer._globeState)
? viewer._globeState.selectedPid : null;
if (!tableEl) return;
- if (visible.length === 0) {
+ if (lastPageFailed) {
+ // Page query failed — show an explicit sentinel rather than
+ // leaving the old, now-inert rows visible (they'd look
+ // clickable but pageRowsByPid is empty, so clicks would
+ // silently no-op).
+ tableEl.innerHTML = '
Page query failed. Adjust filters or click Previous/Next to retry.