From e600da89d8b88585530ec186d27728a3bc4a078a Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Fri, 28 Aug 2026 09:29:48 -0400 Subject: [PATCH 1/3] #351: fetch samples_map_lite once, serve every lite query from an in-memory copy At boot the samples table fires COUNT(*) and the first-page query over samples_map_lite_v3.parquet (62.9 MB) concurrently; with #345's range requests working, each concurrent scan re-fetches the whole file through DuckDB-WASM's per-read ranged GETs (measured 132.8 MB in 65 GETs, 54 % overlap). Later consumers (#300 aggregation, search JOINs) scan it again. - liteFile cell: after facetIndexReady settles (never competes with the boot-critical facet chain), stream-fetch the file once with a 30 s no-bytes watchdog, check Content-Length, registerFileBuffer() it, and validate the parquet footer before declaring it ready. Any failure drops the virtual file and leaves the URL path (today's ranged reads). - db.query wrapper: resolve read_parquet('') to the in-memory name; a lite query waits at most 120 s (one shared deadline) for the buffer, then reads the URL. Metadata probes on the URL pass through. - #300 preflight becomes a footer-only parquet_schema() probe, so readiness flips at ~10 s as before; the filtered-cluster load waits for the buffer before its idle wait so the heavy aggregation runs after the boot scans. - tests/playwright/measure_parquet_ranges.py: the measurement method behind #345/#351 (every .parquet GET incl. Web-Worker fetches, range coverage and overlap per file, time-to-table, warm reload). Measured (local render -> production data, cold, 200 s): lite 132.8 MB -> 62.9 MB in one GET; all parquet 156 MB -> 86 MB; samples table at 63 s instead of 122 s. filtered-clusters-300 spec vs production data: passes on this branch, fails on main. Codex review x6 -> LGTM-with-nits (applied). Fixes #351 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LtTxB4jfTZgaTR7CK4zKqy --- explorer.qmd | 172 +++++++++++++++++- .../playwright/filtered-clusters-300.spec.js | 10 +- tests/playwright/measure_parquet_ranges.py | 150 +++++++++++++++ 3 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 tests/playwright/measure_parquet_ranges.py diff --git a/explorer.qmd b/explorer.qmd index 08d3e39b..34ad3246 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -846,6 +846,8 @@ h3_res8_url = `${R2_BASE}/isamples_202608_h3_summary_res8.parquet` // min/max-pid verified identical against the live wide before rebuilding), // so this is a pure column-content fix, not a data-vintage change. Same // immutable-cache reasoning as _v2: new filename, never overwrite. +// #351: queries name this URL, but the db cell's query wrapper serves them from +// an in-memory copy fetched once by the liteFile cell (see both). lite_url = `${R2_BASE}/isamples_202608_samples_map_lite_v3.parquet` // Explicit versioned wide (#272: OC concept-enriched — popups read material/ // object-type from this file). The stable alias `current/wide.parquet` still @@ -2062,11 +2064,66 @@ db = { // issuing (see whenConnectionIdle / loadRes). _inFlight is read there. const origQuery = instance.query.bind(instance); let inFlight = 0; - instance.query = (...args) => { + // #351: samples_map_lite is the one file the explorer always reads END TO + // END (the samples table's COUNT + page scans, the #300 filtered-cluster + // aggregation, search-result JOINs), and with #345's range requests working + // each concurrent scan re-fetches the whole 63 MB through DuckDB-WASM's + // per-read ranged GETs (~120-134 MB measured for a 63 MB file). The liteFile + // cell below fetches it ONCE and registers the bytes as an in-memory DuckDB + // file; this wrapper resolves every `read_parquet('')` to that + // in-memory name. Queries that mention the lite URL wait for that fetch to + // settle (buffer registered, or fetch failed → keep the URL and range-read + // as before). Facet/summary files keep their range reads. The waiting query + // counts as in flight, and the #300 load waits for the buffer before its + // idle wait (see _liteSettled), so the heavy aggregation still runs after + // the boot scans rather than alongside them. + // + // Liveness (Codex rounds 1-2, 5): the shared liteReady gate is bounded — + // a lite query waits at most LITE_WAIT_CAP_MS from the FIRST lite demand + // (whether liteFile has not started because facetIndexReady stalled, or + // the fetch/registration itself is dragging), then reads the URL while + // the background fetch continues; queries issued after the buffer lands + // use it. + // 120 s keeps a normal slow link (facets settling at ~80 s, then a fetch of + // a minute or so) on the single-fetch path; slower than that degrades to + // today's ranged reads for the queries issued early. The cap bounds only + // this gate — the query's own execution/ranged GETs have no deadline, as + // before. + let resolveLite; + const liteReady = new Promise(r => { resolveLite = r; }); // string name | null + let liteSettled = false; + instance._resolveLiteSource = (name) => { liteSettled = true; resolveLite(name); }; + const LITE_WAIT_CAP_MS = 120000; + // One shared deadline, armed by the first lite demand: every waiter races + // the same timer, so a caller that waits, proceeds, and queries again does + // not pay the cap twice (Codex round-5 P2), and after the deadline all + // queries read the URL until the buffer lands. + let liteDeadline = null; + const liteSource = () => { + if (liteSettled) return liteReady; + if (!liteDeadline) liteDeadline = new Promise(r => setTimeout(() => r(null), LITE_WAIT_CAP_MS)); + return Promise.race([liteReady, liteDeadline]); + }; + // For callers that want to sequence AFTER the buffer lands (the #300 + // filtered-cluster load does this before its idle wait, so the heavy + // aggregation doesn't burst into DuckDB together with the lite scans that + // were released at the same instant — Codex round-4 P1). Same cap. + instance._liteSettled = () => liteSource(); + // Only table reads are redirected: metadata probes such as + // parquet_schema('') (the #300 readiness preflight) stay on the + // URL — they read the footer only and must not wait for the 63 MB fetch. + const liteLiteral = `read_parquet('${lite_url}')`; + instance.query = async (...args) => { inFlight++; - const p = origQuery(...args); - p.then(() => {}, () => {}).finally(() => { inFlight--; }); - return p; + try { + if (typeof args[0] === 'string' && args[0].includes(liteLiteral)) { + const name = await liteSource(); + if (name) args[0] = args[0].split(liteLiteral).join(`read_parquet('${name}')`); + } + return await origQuery(...args); + } finally { + inFlight--; + } }; instance._inFlight = () => inFlight; return instance; @@ -2266,6 +2323,88 @@ facetIndexReady = { } ``` +```{ojs} +//| echo: false +//| output: false +// #351: fetch samples_map_lite ONCE and register it as an in-memory DuckDB +// file. Sequenced after facetIndexReady so the boot-critical facet chain +// (#345: ~3.5 MB to a usable sidebar) never competes with this 63 MB fetch; +// facetIndexReady never rejects (its body is fully try/catch'd), so this cell +// always runs and the db wrapper's liteReady promise always settles. Measured +// (tests/playwright/measure_parquet_ranges.py, cold cache, 200 s): the lite +// file went from 132.8 MB in 65 ranged GETs to one 62.9 MB GET, total parquet +// traffic 156 MB → 86 MB, and the samples table appeared at 63 s instead of +// 122 s (two scans from memory beat two scans over ranged reads). The file is +// served immutable/1-yr, but a warm reload in headless Chromium re-fetched it +// (likely above the per-entry cache limit), so no caching win is claimed. +// Memory: steady state is one copy in +// the DuckDB WASM heap (the footprint the pre-#345 "full HTTP read" fallback +// had); transiently ~2 copies (~126 MB) while the bytes are assembled and +// handed to the worker (registerFileBuffer transfers the ArrayBuffer, the +// worker copies it into the heap). On any failure — HTTP error, short read, +// or no bytes for STALL_MS (the watchdog below aborts a stalled fetch) — +// queries keep reading the URL with ranged GETs (pre-#351 behaviour): +// slower, never wrong. Query consumers always proceed regardless: their +// shared gate in the db wrapper expires on its own after 120 s. +liteFile = { + const _ = facetIndexReady; + const name = 'samples_map_lite_v3.parquet'; // DuckDB virtual filename + const STALL_MS = 30000; + const ctrl = new AbortController(); + let watchdog; + const arm = () => { clearTimeout(watchdog); watchdog = setTimeout(() => ctrl.abort(new Error(`no bytes for ${STALL_MS} ms`)), STALL_MS); }; + try { + performance.mark('lite-fetch-start'); + arm(); + const resp = await fetch(lite_url, { signal: ctrl.signal }); + if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`); + const total = Number(resp.headers.get('content-length')) || 0; + const reader = resp.body.getReader(); + const chunks = []; + let got = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); got += value.byteLength; arm(); + } + clearTimeout(watchdog); + // The data host serves parquet unencoded, so Content-Length is the byte + // count we should have; a mismatch (short read, or an encoded response + // whose decoded size differs) is a safe failure, never a corrupt buffer. + if (total && got !== total) throw new Error(`short read: ${got} of ${total} bytes`); + const bytes = new Uint8Array(got); + let off = 0; + for (const c of chunks) { bytes.set(c, off); off += c.byteLength; } + chunks.length = 0; + await db._db.registerFileBuffer(name, bytes); // transfers bytes.buffer to the worker (detaches it) + // Validate before any query is redirected to it: a body that ended + // cleanly but early (no Content-Length to catch it) would otherwise be + // served as truth. parquet_metadata() decodes the footer (one row per + // column chunk), which fails on a body cut before the footer; on + // failure the name is dropped so the URL path is the only one left. + // (Not a content check — a smaller *valid* parquet under this URL + // would pass; that needs an expected size/hash, out of scope.) + try { + const meta = await db.query(`SELECT COUNT(*) AS n FROM parquet_metadata('${name}')`); + if (!(Number(Array.from(meta)[0]?.n) > 0)) throw new Error('empty parquet metadata'); + } catch (err) { + await db._db.dropFile(name).catch(() => {}); + throw new Error(`in-memory parquet failed validation: ${err && err.message || err}`); + } + performance.mark('lite-fetch-end'); + performance.measure('lite_fetch', 'lite-fetch-start', 'lite-fetch-end'); + db._resolveLiteSource(name); + window.__liteFile = { src: name, bytes: got }; // test/diagnostic hook + } catch (err) { + clearTimeout(watchdog); + console.warn('#351: whole-file fetch of samples_map_lite failed; falling back to ranged reads of the URL:', err); + db._resolveLiteSource(null); + window.__liteFile = { src: lite_url, error: String(err) }; + } + return window.__liteFile; +} +``` + ```{ojs} //| echo: false //| output: false @@ -2283,7 +2422,20 @@ facetIndexReady = { filteredClustersReady = { window.__filteredClustersReady = false; try { - await db.query(`SELECT h3_res4, h3_res6 FROM read_parquet('${lite_url}') LIMIT 1`); + // #351: a footer-only schema probe (parquet_schema reads the file + // metadata, a few hundred KB via ranged GETs) instead of a LIMIT 1 row + // read, so readiness flips at ~10 s as before and never waits for the + // liteFile whole-file fetch — the db wrapper only redirects + // read_parquet('') table reads. For this flat, lowercase + // schema it answers the same question as the old probe: both + // top-level columns present → true; either missing → false. (The + // name match is exact-case, and a data-page fault the old row read + // would have hit is not seen here — neither applies to v3.) + const cols = await db.query(` + SELECT name FROM parquet_schema('${lite_url}') + WHERE name IN ('h3_res4', 'h3_res6')`); + const found = new Set(Array.from(cols).map(r => r.name)); + if (!(found.has('h3_res4') && found.has('h3_res6'))) throw new Error(`lite columns present: ${[...found].join(',') || 'none'}`); window.__filteredClustersReady = true; if (typeof window.__onFilteredClustersReady === 'function') window.__onFilteredClustersReady(); return true; @@ -3600,7 +3752,15 @@ zoomWatcher = { performance.mark(`r${res}-s`); // #300: gate the heavy filtered aggregation on an idle connection // (deadlock-avoidance); no-op for the light summary path. - if (filtered) await whenConnectionIdle(); + // #351: first let the in-memory lite buffer land (or the wait cap + // expire). The boot-time table scans are released at that same + // instant, so an idle wait taken BEFORE it would have expired + // (20 s cap) during the fetch and let this aggregation burst in + // with them; taken after, it sees them executing and waits. + if (filtered) { + if (typeof db._liteSettled === 'function') await db._liteSettled(); + await whenConnectionIdle(); + } // Re-check supersession after the idle wait (a newer load or a filter // change may have landed while we waited). if (gen !== loadResGen || sig !== desiredClusterSig()) return false; diff --git a/tests/playwright/filtered-clusters-300.spec.js b/tests/playwright/filtered-clusters-300.spec.js index ae3ce6a8..444bd2ea 100644 --- a/tests/playwright/filtered-clusters-300.spec.js +++ b/tests/playwright/filtered-clusters-300.spec.js @@ -1,10 +1,12 @@ /** * #300 verification [data]: filtered H3 clusters at world zoom. * - * Runs against a LOCAL data mirror (dev_server.py on :8099) whose - * samples_map_lite carries h3_res4/h3_res6, so window.__filteredClustersReady - * becomes true and the feature ACTIVATES. (Production data lacks res4/res6, so - * this can't run against data.isamples.org yet — that's the pending republish.) + * Runs against a data host whose samples_map_lite carries h3_res4/h3_res6, so + * window.__filteredClustersReady becomes true and the feature ACTIVATES. + * Default is a LOCAL mirror (dev_server.py on :8099); since the _v3 lite + * republish production carries res4/res6 too, so + * DATA_BASE=https://data.isamples.org npx playwright test filtered-clusters-300 + * runs it against live data (used to verify #351). * * Pass DATA_BASE=http://localhost:8099 (default below). */ diff --git a/tests/playwright/measure_parquet_ranges.py b/tests/playwright/measure_parquet_ranges.py new file mode 100644 index 00000000..61aa6870 --- /dev/null +++ b/tests/playwright/measure_parquet_ranges.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Measure the explorer's parquet traffic over a cold boot (and optionally a warm reload). + +Counts EVERY .parquet GET the page makes — including DuckDB-WASM's fetches, which +run in a Web Worker and are invisible to page-level `performance` entries — and +reports, per file: number of ranged (206) GETs, bytes transferred, unique byte +coverage (Content-Range intervals merged), overlap (re-fetched bytes), and +whole-file (200) GETs with their Cache-Control. Also polls the samples table's +pager so "time to table" can be compared across builds. + +Written for #345 (range requests) and #351 (samples_map_lite read twice); the +numbers in those issues came from this method. + +Usage: + python3 tests/playwright/measure_parquet_ranges.py URL [SECONDS] [--warm] + + URL explorer page, e.g. http://localhost:5860/explorer.html or + https://isamples.org/explorer.html (append ?data_base=... to point + at a canary data host) + SECONDS how long to keep listening after `load` (default 60; a full cold + boot needs 150-240 on a slow link) + --warm after the cold run, reload in the SAME browser context and report + again — shows what the HTTP cache absorbs + +If the page was built with the `?sqllog=1` diagnostic (not in production), SQL +statements are attributed per file too. + +Requires: playwright (python) with chromium installed. +""" +import collections +import re +import sys +import time + +from playwright.sync_api import sync_playwright + + +def summarise(label, ranges, whole, whole_hdr, fallbacks, sql, perf, table_at): + print(f"===== {label}") + print(f" fallbacks ('falling back to full HTTP read'): {fallbacks}") + print(f" time to samples table ('Page 1 of'): {table_at:.1f}s" if table_at else " time to samples table: not seen") + if perf: + print(f" page hooks: {perf}") + for f, rs in sorted(ranges.items(), key=lambda kv: -sum(e - s + 1 for s, e, _, _ in kv[1])): + size = rs[0][2] + tx = sum(e - s + 1 for s, e, _, _ in rs) + merged = [] + for s, e in sorted((s, e) for s, e, _, _ in rs): + if merged and s <= merged[-1][1] + 1: + merged[-1] = (merged[-1][0], max(merged[-1][1], e)) + else: + merged.append((s, e)) + uniq = sum(e - s + 1 for s, e in merged) + if tx < 2e5: + continue # metadata-sized files: noise + print(f" {f}: file {size/1e6:.1f} MB | {len(rs)} ranged GETs, transferred {tx/1e6:.1f} MB, " + f"unique {uniq/1e6:.1f} MB ({100*uniq/size:.0f}%), overlap {100*(tx-uniq)/max(tx,1):.0f}% | " + f"first {rs[0][3]:.1f}s last {rs[-1][3]:.1f}s") + for f, n in whole.most_common(): + if n >= 2e5: + print(f" {f}: whole-file GET {n/1e6:.1f} MB (status, cache-control, at): {whole_hdr[f]}") + tot_r = sum(e - s + 1 for rs in ranges.values() for s, e, _, _ in rs) + print(f" TOTAL parquet bytes: ranged {tot_r/1e6:.1f} MB + whole {sum(whole.values())/1e6:.1f} MB " + f"= {(tot_r + sum(whole.values()))/1e6:.1f} MB") + if sql: + byfile = collections.Counter() + first = {} + for t, q in sql: + for f in re.findall(r"read_parquet\('([^']+)'\)", q): + f = f.split('/')[-1] + byfile[f] += 1 + first.setdefault(f, t) + print(" SQL statements by file (count@first):", + ", ".join(f"{f}={n}@{first[f]:.0f}s" for f, n in byfile.most_common())) + + +def run(page, url, wait, label): + ranges = collections.defaultdict(list) + whole = collections.Counter() + whole_hdr = {} + fallbacks = [0] + sql = [] + t0 = time.time() + + def on_resp(r): + if '.parquet' not in r.url or r.request.method != 'GET': + return + f = r.url.split('/')[-1].split('?')[0] + m = re.match(r'bytes (\d+)-(\d+)/(\d+)', r.headers.get('content-range') or '') + if r.status == 206 and m: + ranges[f].append((int(m.group(1)), int(m.group(2)), int(m.group(3)), time.time() - t0)) + else: + cl = r.headers.get('content-length') + whole[f] += int(cl) if cl else 0 + h = r.headers + whole_hdr[f] = {'status': r.status, 'at_s': round(time.time() - t0, 1), + **{k: h.get(k) for k in ('cache-control', 'vary', 'content-encoding', 'cf-cache-status', 'age') if h.get(k)}} + + def on_console(m): + if 'falling back to full HTTP read' in m.text: + fallbacks[0] += 1 + if m.text.startswith('[sql]'): + sql.append((time.time() - t0, m.text[6:])) + + page.on('response', on_resp) + page.on('console', on_console) + page.goto(url, wait_until='load') + table_at = None + deadline = time.time() + wait + while time.time() < deadline: + page.wait_for_timeout(1000) + if table_at is None: + try: + txt = page.locator('#tablePageInfo').text_content(timeout=200) or '' + if 'Page 1 of' in txt: + table_at = time.time() - t0 + except Exception: + pass + page.remove_listener('response', on_resp) + page.remove_listener('console', on_console) + perf = page.evaluate("""() => { + const lf = performance.getEntriesByName('lite_fetch')[0]; + // Resource timing for the page-initiated whole-file fetch: transferSize 0 == served from the HTTP cache. + const rt = performance.getEntriesByType('resource').filter(e => e.name.includes('samples_map_lite')).map(e => + ({ transferSize: e.transferSize, encodedBodySize: e.encodedBodySize, dur_s: +(e.duration/1000).toFixed(1) })); + return { lite_fetch: lf ? {start_s: +(lf.startTime/1000).toFixed(1), dur_s: +(lf.duration/1000).toFixed(1)} : null, + lite_resource_timing: rt, liteFile: window.__liteFile || null, facetIndexStatus: window.__facetIndexStatus || null }; + }""") + summarise(label, ranges, whole, whole_hdr, fallbacks[0], sql, perf, table_at) + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith('--')] + if not args: + print(__doc__) + sys.exit(2) + url = args[0] + wait = int(args[1]) if len(args) > 1 else 60 + with sync_playwright() as p: + b = p.chromium.launch() + ctx = b.new_context() + page = ctx.new_page() + run(page, url, wait, f"COLD {url} ({wait}s)") + if '--warm' in sys.argv: + run(page, url, wait, f"WARM reload, same context {url} ({wait}s)") + b.close() + + +if __name__ == '__main__': + main() From 6a063ee8ab11572e9f66aaf886ccc840510da5cf Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Fri, 28 Aug 2026 14:22:39 -0400 Subject: [PATCH 2/3] measure_parquet_ranges.py: record boot milestones (facet index, #300 readiness, lite buffer) Polls window.__facetIndexStatus / __filteredClustersReady / __liteFile each second and reports the first time each was observed, so readiness timing can be compared across builds (used to show #351 leaves #300 readiness unchanged: 5.1 s vs 5.2 s on the spec's URL). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LtTxB4jfTZgaTR7CK4zKqy --- tests/playwright/measure_parquet_ranges.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/playwright/measure_parquet_ranges.py b/tests/playwright/measure_parquet_ranges.py index 61aa6870..c50abab2 100644 --- a/tests/playwright/measure_parquet_ranges.py +++ b/tests/playwright/measure_parquet_ranges.py @@ -106,6 +106,7 @@ def on_console(m): page.on('console', on_console) page.goto(url, wait_until='load') table_at = None + marks = {} # first time each boot milestone was observed deadline = time.time() + wait while time.time() < deadline: page.wait_for_timeout(1000) @@ -116,6 +117,13 @@ def on_console(m): table_at = time.time() - t0 except Exception: pass + try: + st = page.evaluate("() => ({facet: window.__facetIndexStatus, ready: window.__filteredClustersReady, lite: !!window.__liteFile})") + if st.get('facet') in ('ready', 'failed'): marks.setdefault('facetIndex_' + st['facet'], round(time.time() - t0, 1)) + if st.get('ready') is True: marks.setdefault('filteredClustersReady', round(time.time() - t0, 1)) + if st.get('lite'): marks.setdefault('liteFile_settled', round(time.time() - t0, 1)) + except Exception: + pass page.remove_listener('response', on_resp) page.remove_listener('console', on_console) perf = page.evaluate("""() => { @@ -126,6 +134,7 @@ def on_console(m): return { lite_fetch: lf ? {start_s: +(lf.startTime/1000).toFixed(1), dur_s: +(lf.duration/1000).toFixed(1)} : null, lite_resource_timing: rt, liteFile: window.__liteFile || null, facetIndexStatus: window.__facetIndexStatus || null }; }""") + perf['milestones_s'] = marks summarise(label, ranges, whole, whole_hdr, fallbacks[0], sql, perf, table_at) From a46edfc28c4dbc9f095f74a17a1bad3cf85f7f98 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Fri, 28 Aug 2026 14:31:33 -0400 Subject: [PATCH 3/3] filtered-clusters-300 spec: build its URL with explorerUrl() so fork staging works A bare '/explorer.html' replaces TEST_URL's sub-path, so against https://rdhyee.github.io/isamplesorg.github.io the spec was loading a 404 page and 'filteredClustersReady' could never flip. helpers/url.js exists for exactly this (PR #238); use it. Verified 2/2 against staging. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LtTxB4jfTZgaTR7CK4zKqy --- tests/playwright/filtered-clusters-300.spec.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/playwright/filtered-clusters-300.spec.js b/tests/playwright/filtered-clusters-300.spec.js index 444bd2ea..cdfc2a1d 100644 --- a/tests/playwright/filtered-clusters-300.spec.js +++ b/tests/playwright/filtered-clusters-300.spec.js @@ -11,6 +11,7 @@ * Pass DATA_BASE=http://localhost:8099 (default below). */ const { test, expect } = require('@playwright/test'); +const { explorerUrl } = require('./helpers/url'); const DATA_BASE = process.env.DATA_BASE || 'http://localhost:8099'; const MATERIAL = 'https://w3id.org/isample/vocabulary/material/1.0/anyanthropogenicmaterial'; @@ -18,7 +19,10 @@ const WORLD_ALT = 18000000; // world zoom, well above EXIT_POINT_ALT function url(extraHash = '') { const qs = new URLSearchParams({ data_base: DATA_BASE, material: MATERIAL }).toString(); - return `/explorer.html?${qs}#v=1&lat=10.0000&lng=0.0000&alt=${WORLD_ALT}${extraHash}`; + // explorerUrl() keeps TEST_URL's sub-path (fork staging lives under + // /isamplesorg.github.io/); a bare '/explorer.html' would replace it and + // silently test a 404 page. See helpers/url.js. + return explorerUrl(`?${qs}#v=1&lat=10.0000&lng=0.0000&alt=${WORLD_ALT}${extraHash}`); } // Read an OJS cell value (viewer, db, lite_url, facetFilterSQL, ...) the same way