Interactive Explorer
Search and explore 6.7 million material samples
parquet
spatial
h3
performance
isamples
NoteHow It Works
- Instant (<1s): Pre-aggregated H3 res4 summary (580 KB) → 38K colored circles
- Zoom in: Automatically switches to res6 (112K) then res8 (176K) clusters
- Zoom deeper (<120 km): Individual sample points from 60 MB lite parquet
- Click: Cluster info or individual sample card with full metadata
- Search: Find samples by name — results fly to the location on the globe
Circle size = log(sample count). Color = dominant data source.
SESAR
OpenContext
GEOME
Smithsonian
Enter searches the entire world. Use the in-map controls for area-limited search.
Loading...Resolution
0Clusters Loaded
0Samples Loaded
-Load Time
Material ▾
Loading...
Sampled Feature ▾
Loading...
Specimen Type ▾
Loading...
Material / feature / specimen filters apply at sample zoom level — zoom in or click a cluster.
Link copied!
Loading H3 global overview...
Click a cluster or sample on the globe
Samples table
Loading samples matching the current filters...
R2_BASE = "https://data.isamples.org"
h3_res4_url = `${R2_BASE}/isamples_202601_h3_summary_res4.parquet`
h3_res6_url = `${R2_BASE}/isamples_202601_h3_summary_res6.parquet`
h3_res8_url = `${R2_BASE}/isamples_202601_h3_summary_res8.parquet`
lite_url = `${R2_BASE}/isamples_202601_samples_map_lite.parquet`
// Stable alias that 302-redirects to the current enriched wide parquet
// (isamples_YYYYMM_wide.parquet). Gets OpenContext thumbnails populated.
wide_url = `${R2_BASE}/current/wide.parquet`
// v2 carries object_type alongside material and context (URI-string columns).
facets_url = `${R2_BASE}/isamples_202601_sample_facets_v2.parquet`
facet_summaries_url = `${R2_BASE}/isamples_202601_facet_summaries.parquet`
// Pre-aggregated single-filter cache for fast cross-filtered facet counts.
cross_filter_url = `${R2_BASE}/isamples_202601_facet_cross_filter.parquet`
// SKOS prefLabels for Material / Sampled Feature / Specimen Type URIs.
// ~60 KB lookup; falls back to URI tail if a URI isn't covered.
vocab_labels_url = `${R2_BASE}/vocab_labels.parquet`
// Canonical palette — see issue #113. Path-relative so this works under
// both isamples.org (custom domain at root) and project-pages fork
// previews (rdhyee.github.io/isamplesorg.github.io/...).
_palette = await import(new URL('assets/js/source-palette.js', document.baseURI).href)
SOURCE_COLORS = _palette.SOURCE_COLORS
SOURCE_NAMES = _palette.SOURCE_NAMES
// === Source URL: resolve pid to original repository ===
function sourceUrl(pid) {
if (!pid) return null;
// All sources resolve via n2t.net:
// ARK pids (OpenContext, GEOME, Smithsonian) → n2t.net/ark:/...
// IGSN pids (SESAR) → n2t.net/IGSN:...
return `https://n2t.net/${pid}`;
}
// === Source Filter: get active sources and build SQL clause ===
function getActiveSources() {
const checks = document.querySelectorAll('#sourceFilter input[type="checkbox"]');
return Array.from(checks).filter(c => c.checked).map(c => c.value);
}
function sourceFilterSQL(col) {
const active = getActiveSources();
if (active.length === 0) return ' AND 1=0'; // nothing checked = show nothing
if (active.length === 4) return ''; // all checked = no filter
const list = active.map(s => `'${s}'`).join(',');
return ` AND ${col} IN (${list})`;
}
SOURCE_VALUES = ['SESAR', 'OPENCONTEXT', 'GEOME', 'SMITHSONIAN']
DEFAULT_POINT_BUDGET = 5000
// Disable per-primitive depth test below this camera distance (meters).
// All cluster and sample point primitives are placed at altitude=0 (ellipsoid
// surface). With Cesium world terrain enabled, terrain mesh in hilly regions
// (e.g. Troodos mountains in Cyprus, Birmingham AL hills) rises hundreds to
// thousands of meters above ellipsoid — making sea-level primitives
// physically underground and depth-culled by the standard per-pixel depth
// test (issue #185).
//
// Bypass scope: only when the camera is closer than 2,000 km, which covers
// every realistic interactive altitude (point mode <120 km, res8 cluster
// mode up to ~300 km, res6 up to ~3,000 km — the upper range is technically
// outside the bypass but at that altitude dots are tiny anyway, so this is
// where the existing pre-issue behavior is preserved).
//
// Why bounded (2.0e6) and not POSITIVE_INFINITY: PR #181 used Infinity and
// caused back-side-of-globe primitive bleed-through (the depth test was the
// only thing keeping points on the FAR hemisphere from rendering through
// the Earth). A 2,000 km bound preserves globe-ellipsoid occlusion at
// long range while bypassing terrain occlusion at every interactive zoom.
POINT_DEPTH_TEST_DISTANCE = 2.0e6
function csvParamValues(params, key) {
if (!params.has(key)) return null;
const raw = params.get(key) || '';
if (raw.trim() === '') return [];
return raw.split(',').map(s => s.trim()).filter(Boolean);
}
function updateSourceLegendState() {
document.querySelectorAll('#sourceFilter .legend-item').forEach(li => {
const cb = li.querySelector('input');
li.classList.toggle('disabled', !cb.checked);
});
}
function applyQueryToSourceFilter() {
const params = new URLSearchParams(location.search);
const initialSources = csvParamValues(params, 'sources');
if (initialSources == null) return;
const allowed = new Set(SOURCE_VALUES);
const selected = new Set(initialSources.filter(s => allowed.has(s)));
document.querySelectorAll('#sourceFilter input[type="checkbox"]').forEach(cb => {
cb.checked = selected.has(cb.value);
});
updateSourceLegendState();
}
// URL param is `search`, not `q` — Quarto's site-wide search hijacks `?q=`
// (highlights matches and strips the param via replaceState).
// See docs/site_libs/quarto-search/quarto-search.js.
function applyQueryToSearch() {
const input = document.getElementById('sampleSearch');
const sidebarInput = document.getElementById('sampleSearchSidebar');
if (!input && !sidebarInput) return;
const params = new URLSearchParams(location.search);
const q = params.get('search');
if (q != null) {
if (input) input.value = q;
if (sidebarInput) sidebarInput.value = q;
}
}
function setCheckedValues(containerId, values) {
if (values == null) return;
const selected = new Set(values);
document.querySelectorAll(`#${containerId} input[type="checkbox"]`).forEach(cb => {
cb.checked = selected.has(cb.value);
});
}
function applyQueryToFacetFilters() {
const params = new URLSearchParams(location.search);
setCheckedValues('materialFilterBody', csvParamValues(params, 'material'));
setCheckedValues('contextFilterBody', csvParamValues(params, 'context'));
setCheckedValues('objectTypeFilterBody', csvParamValues(params, 'object_type'));
}
function writeQueryState() {
const params = new URLSearchParams(location.search);
const searchInput = document.getElementById('sampleSearch');
const q = searchInput ? searchInput.value.trim() : '';
if (q) params.set('search', q);
else params.delete('search');
const activeSources = getActiveSources();
if (activeSources.length === SOURCE_VALUES.length) params.delete('sources');
else params.set('sources', activeSources.join(','));
[
['material', 'materialFilterBody'],
['context', 'contextFilterBody'],
['object_type', 'objectTypeFilterBody'],
].forEach(([key, containerId]) => {
const values = getCheckedValues(containerId);
if (values.length > 0) params.set(key, values.join(','));
else params.delete(key);
});
// Canonicalize away the legacy `view` param (issue #200 / M-5 removed
// the Globe/Table toggle). A bookmarked `?view=table&...` URL would
// otherwise stick through filter/share flows.
params.delete('view');
const qs = params.toString();
const url = `${location.pathname}${qs ? `?${qs}` : ''}${location.hash}`;
if (url !== `${location.pathname}${location.search}${location.hash}`) {
history.replaceState(null, '', url);
}
}
function searchTerms(value) {
return String(value || '').trim().split(/\s+/).filter(Boolean);
}
function escapeIlikePattern(value) {
return escSql(value).replace(/[\\%_]/g, "\\$&");
}
function textSearchWhere(terms, columns) {
return terms.map(raw => {
const term = escapeIlikePattern(raw);
const checks = columns.map(col => `${col} ILIKE '%${term}%' ESCAPE '\\'`);
return `(${checks.join(' OR ')})`;
}).join(' AND ');
}
function textSearchScore(terms, weightedColumns) {
if (!terms.length) return '0';
return terms.map(raw => {
const term = escapeIlikePattern(raw);
return weightedColumns.map(({ col, weight }) =>
`CASE WHEN ${col} ILIKE '%${term}%' ESCAPE '\\' THEN ${weight} ELSE 0 END`
).join(' + ');
}).map(score => `(${score})`).join(' + ');
}
// === Material / Sampled Feature / Specimen Type Filters ===
// Checkbox semantics: start UNCHECKED (no filter; show everything). User
// checks items to *include only those*. Empty = no filter. Matches the
// explorer's URI-valued facet UX — with hundreds of materials, defaulting
// to "all checked" would be unusable, and "empty = no filter" is the
// natural reading. See issue #155.
function getCheckedValues(containerId) {
const checks = document.querySelectorAll(`#${containerId} input[type="checkbox"]`);
return Array.from(checks).filter(c => c.checked).map(c => c.value);
}
function hasFacetFilters() {
return getCheckedValues('materialFilterBody').length > 0
|| getCheckedValues('contextFilterBody').length > 0
|| getCheckedValues('objectTypeFilterBody').length > 0;
}
// Single source of truth for #facetNote visibility. The note ("filter
// takes effect at neighborhood zoom") explains the cluster-mode honesty
// gap: H3 summary parquets only carry `dominant_source`, so material /
// context / object_type filters cannot affect cluster counts. Invariant:
// visible ⇔ (any facet active) ∧ (mode === 'cluster')
// Call sites that mutate either side of the conjunction MUST call this
// to keep DOM and state in agreement:
// - facetFilters cell (URL deep-link restore, #234 step 1)
// - handleFacetFilterChange (user toggles a facet checkbox)
// - enterPointMode / exitPointMode (mode transitions)
// `viewer` resolves late at call time per ojs reactive scoping, so this
// helper is safe to define before the viewer cell runs.
function syncFacetNote() {
const el = document.getElementById('facetNote');
if (!el) return;
const active = hasFacetFilters();
const inCluster = (typeof viewer !== 'undefined') && viewer._globeState?.mode === 'cluster';
el.style.display = (active && inCluster) ? 'block' : 'none';
}
function escSql(value) {
return String(value).replace(/'/g, "''");
}
// Returns a portable predicate fragment (no outer-table alias dependency)
// that callers append to a WHERE: ` AND ${facetFilterSQL()}`. Uses a
// `pid IN (SELECT pid FROM facets WHERE ...)` subquery so it works
// without a JOIN and avoids duplicate rows from multi-valued facets
// (a sample with two materials would appear twice via JOIN). Required
// for Phase 4's table mode and any non-JOIN caller. See issue #156.
function facetFilterSQL() {
const mat = getCheckedValues('materialFilterBody');
const ctx = getCheckedValues('contextFilterBody');
const ot = getCheckedValues('objectTypeFilterBody');
const conds = [];
if (mat.length > 0) {
const list = mat.map(s => `'${escSql(s)}'`).join(',');
conds.push(`material IN (${list})`);
}
if (ctx.length > 0) {
const list = ctx.map(s => `'${escSql(s)}'`).join(',');
conds.push(`context IN (${list})`);
}
if (ot.length > 0) {
const list = ot.map(s => `'${escSql(s)}'`).join(',');
conds.push(`object_type IN (${list})`);
}
if (conds.length === 0) return '';
return ` AND pid IN (SELECT DISTINCT pid FROM read_parquet('${facets_url}') WHERE ${conds.join(' AND ')})`;
}
// Shared viewport-padding factor. The samples table (PR #219), the
// point-mode sample loader, and the cluster-mode "Samples in View"
// stat (issue #221 round 2) all expand the raw view rectangle by this
// factor so the three surfaces agree on a single "in view" predicate.
// Pass 0 (or undefined) for exact-viewport semantics (area search).
// OJS cell syntax (bare `name = value`); `const` here would not bind
// the name into the OJS module scope and would break every dependent
// cell (issue #223 quick-fix).
VIEWPORT_PAD_FACTOR = 0.3
// Return the viewer's current view rectangle, optionally padded in each
// direction by `padFactor × span`, normalized into [-180, 180] longitude
// (wrapping the antimeridian when needed). Returns null when the camera
// can't produce a rectangle (off-globe; rare).
//
// Dateline crossing: when the returned `west > east` the rectangle
// wraps the antimeridian; callers that filter sample longitudes must
// split on that boundary.
//
// Normalization after padding:
// - Total span >= 360 → padding consumed the globe; return west=-180,
// east=180 (no wrap).
// - west < -180 or east > 180 → rotate the endpoint by 360 so it
// lands in [-180, 180]. A non-wrapping rect like west=170, east=179
// padded 30% becomes (167.3, 181.7); east wraps to -178.3 and the
// `west > east` flag flips on.
function paddedViewportBounds(padFactor) {
if (typeof viewer === 'undefined') return null;
const rect = viewer.camera.computeViewRectangle(viewer.scene.globe.ellipsoid);
if (!rect) return null;
let south = Cesium.Math.toDegrees(rect.south);
let north = Cesium.Math.toDegrees(rect.north);
let west = Cesium.Math.toDegrees(rect.west);
let east = Cesium.Math.toDegrees(rect.east);
if (padFactor && padFactor > 0) {
const latPad = (north - south) * padFactor;
// Original longitude span. Wraps the antimeridian iff west > east.
const originalSpan = (west > east) ? (180 - west) + (east + 180) : east - west;
const lngPad = originalSpan * padFactor;
south -= latPad;
north += latPad;
west -= lngPad;
east += lngPad;
if (south < -90) south = -90;
if (north > 90) north = 90;
const totalSpan = originalSpan + 2 * lngPad;
if (totalSpan >= 360) {
west = -180; east = 180;
} else {
if (west < -180) west += 360;
if (east > 180) east -= 360;
}
}
return { south, north, west, east };
}
// Build a viewport-bbox SQL predicate for the given lat/lng column names.
// Returns null when the camera can't produce a rectangle (off-globe; rare).
// Callers must decide what to do with null — fall back to world (search)
// vs. show empty state (table). NEVER fall back to no-bbox-filter for
// surfaces that are labeled "in view" — that recreates the bug shape
// PR #219 was filed for.
//
// Used by:
// - tableView (PR #219): scopes the samples table to the current viewport
// so "samples in view" in cluster/point mode == table row count.
// - doSearch('area') (#178 light path): exact-viewport text search.
function viewerBboxSQL(latCol, lngCol, padFactor) {
const b = paddedViewportBounds(padFactor);
if (!b) return null;
const { south, north, west, east } = b;
const lngClause = (west > east)
? `(${lngCol} BETWEEN ${west} AND 180 OR ${lngCol} BETWEEN -180 AND ${east})`
: `${lngCol} BETWEEN ${west} AND ${east}`;
return ` AND ${latCol} BETWEEN ${south} AND ${north} AND ${lngClause}`;
}
// True when the current camera shows ≈the whole world (initial zoom-out
// state), or when the camera can't compute a rectangle at all (off-globe).
// In either case a viewport bbox predicate would be a no-op, so fast-paths
// that depend on "no spatial constraint" (e.g. the pre-aggregated facet
// cross-filter cube) remain valid and can be used.
//
// Used by `updateCrossFilteredCounts` (B1, issue #234 step 3) to decide
// whether to gate the cube fast-path off and JOIN to `lite_url` for a
// bbox-scoped slow-path query.
//
// Implementation note — why an altitude shortcut:
// At high altitudes Cesium's `computeViewRectangle` saturates at the
// sphere's extent for some camera angles but returns a tight bounding
// rect for others (e.g. at alt=5000 km over the equator the rect is
// ≈hemispheric, not full). The user's intuition is "max zoom-out = global
// counts," and at the explorer's default `globalRect` (which fits the
// `[-180,-60,180,80]` data extent and sits roughly at altitude ≈12000 km)
// the per-source legend totals should match the baselines, not jiggle on
// tiny camera rotations. The altitude check captures that "I'm zoomed all
// the way out" intent regardless of the exact rect Cesium reports. Below
// the altitude threshold we still let the rect vote "global" so flyTo
// to a true world-rect destination keeps working.
//
// Constants live inside the function (not top-level `const`) because
// Quarto `{ojs}` cells reject top-level `const`/`let`/`var` and a bad
// declaration cascades — breaking every dependent cell (memory note
// `feedback_qmd_ojs_top_level`).
function isGlobalView() {
const ALT_THRESHOLD_M = 1.0e7; // 10,000 km
const LNG_PAD_DEG = 2;
const LAT_PAD_DEG = 2;
if (typeof viewer === 'undefined') return true;
let h = NaN;
try { h = viewer.camera.positionCartographic.height; } catch { /* mid-init */ }
if (Number.isFinite(h) && h > ALT_THRESHOLD_M) return true;
const b = paddedViewportBounds(0);
if (!b) return true; // off-globe → no meaningful bbox
if (b.west > b.east) return false; // antimeridian-wrapping is never "global"
return b.west <= -180 + LNG_PAD_DEG
&& b.east >= 180 - LNG_PAD_DEG
&& b.south <= -90 + LAT_PAD_DEG
&& b.north >= 90 - LAT_PAD_DEG;
}
// === Cross-filter facet count UI helpers ===
function applyFacetCounts(facetKey, countsMap) {
const baseline = (viewer && viewer._baselineCounts) ? viewer._baselineCounts[facetKey] : null;
document.querySelectorAll(`.facet-count[data-facet="${facetKey}"]`).forEach(el => {
const value = el.getAttribute('data-value');
let count;
if (countsMap) {
count = countsMap.has(value) ? countsMap.get(value) : 0;
} else {
count = baseline ? (baseline.get(value) ?? 0) : 0;
}
el.textContent = `(${Number(count).toLocaleString()})`;
el.classList.remove('recomputing');
const row = document.querySelector(`.facet-row[data-facet="${facetKey}"][data-value="${CSS.escape(value)}"]`);
if (row) row.classList.toggle('zero', count === 0);
});
}
function markFacetCountsRecomputing() {
document.querySelectorAll('.facet-count').forEach(el => el.classList.add('recomputing'));
}
// === URL State: encode/decode globe state in hash fragment ===
function parseNum(val, def, min, max) {
if (val == null) return def;
const n = parseFloat(val);
if (!Number.isFinite(n)) return def;
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
}
function readHash() {
const params = new URLSearchParams(location.hash.slice(1));
return {
v: parseInt(params.get('v')) || 0,
lat: parseNum(params.get('lat'), null, -90, 90),
lng: parseNum(params.get('lng'), null, -180, 180),
alt: parseNum(params.get('alt'), null, 100, 40000000),
heading: parseNum(params.get('heading'), 0, 0, 360),
pitch: parseNum(params.get('pitch'), -90, -90, 0),
mode: params.get('mode') || null,
pid: params.get('pid') || null,
h3: params.get('h3') || null,
heatmap: params.get('heatmap') === '1',
};
}
function buildHash(v) {
const cam = v.camera;
const carto = cam.positionCartographic;
const params = new URLSearchParams();
params.set('v', '1');
params.set('lat', Cesium.Math.toDegrees(carto.latitude).toFixed(4));
params.set('lng', Cesium.Math.toDegrees(carto.longitude).toFixed(4));
params.set('alt', Math.round(carto.height).toString());
const heading = Cesium.Math.toDegrees(cam.heading) % 360;
const pitch = Cesium.Math.toDegrees(cam.pitch);
if (Math.abs(heading) > 1) params.set('heading', heading.toFixed(1));
if (Math.abs(pitch + 90) > 1) params.set('pitch', pitch.toFixed(1));
const gs = v._globeState;
if (gs.mode === 'point') params.set('mode', 'point');
// pid and h3 are mutually exclusive at runtime; emit at most one. Sample
// selection (pid) wins if somehow both are set — matches the hydration
// priority in the hashchange handler.
if (gs.selectedPid) params.set('pid', gs.selectedPid);
else if (gs.selectedH3) params.set('h3', gs.selectedH3);
// #233 phase 1: encode heatmap toggle in URL hash so "Copy Link to
// Current View" preserves the overlay state. Reported by RY 2026-05-27
// while reviewing PR #240 on staging.
if (document.getElementById('heatmapToggle')?.checked) params.set('heatmap', '1');
return '#' + params.toString();
}
// === Selection freshness primitive ===
//
// Async work that updates `viewer._globeState`, the URL hash, or the side
// panel must check freshness after every await. A user input (click,
// hashchange, source-filter toggle, boot deep-link) bumps `_selGen`; any
// in-flight async work whose generation no longer matches must NOT mutate
// anything that an interactive newer event has already moved.
//
// Usage:
// const isStale = freshSelectionToken(viewer);
// await someWork();
// if (isStale()) return;
// updateDOM();
//
// Pass `isStale` into helpers (see hydrateClusterUI) so their internal
// awaits also bail before touching DOM. Top-level so both the viewer-cell
// click handler and the zoomWatcher-cell handlers can reach it. See issue
// #187 for the post-mortem that motivated extracting this primitive.
function freshSelectionToken(v) {
v._selGen = (v._selGen || 0) + 1;
const gen = v._selGen;
return () => gen !== v._selGen;
}
// === Helpers: update DOM imperatively (no OJS reactivity) ===
function updateStats(phase, points, samples, time, pointsLabel, samplesLabel) {
const s = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; };
s('sPhase', phase);
s('sPoints', typeof points === 'string' ? points : points.toLocaleString());
s('sSamples', typeof samples === 'string' ? samples : samples.toLocaleString());
if (time != null) s('sTime', time);
if (pointsLabel) s('sPointsLbl', pointsLabel);
if (samplesLabel) s('sSamplesLbl', samplesLabel);
}
function updatePhaseMsg(text, type) {
const m = document.getElementById('phaseMsg');
if (!m) return;
m.textContent = text;
if (type === 'loading') { m.style.background = '#e3f2fd'; m.style.color = '#1565c0'; }
else { m.style.background = '#e8f5e9'; m.style.color = '#2e7d32'; }
}
function updateClusterCard(info) {
const el = document.getElementById('clusterSection');
if (!el) return;
if (!info) {
el.innerHTML = '<div class="empty-state">Click a cluster or sample on the globe</div>';
return;
}
const color = SOURCE_COLORS[info.source] || '#666';
const name = SOURCE_NAMES[info.source] || info.source;
el.innerHTML = `<h4>Selected Cluster</h4>
<div class="cluster-card" style="border-left-color: ${color}">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span class="source-badge" style="background: ${color}">${name}</span>
<span style="font-size: 11px; color: #999;">H3 res${info.resolution}</span>
</div>
<div style="font-size: 22px; font-weight: bold; margin-bottom: 4px;">
${info.count.toLocaleString()} <span style="font-size: 13px; font-weight: normal; color: #666;">samples</span>
</div>
<div style="font-size: 12px; color: #666;">
${info.lat.toFixed(4)}, ${info.lng.toFixed(4)}
</div>
</div>`;
}
function updateSampleCard(sample) {
const el = document.getElementById('clusterSection');
if (!el) return;
const color = SOURCE_COLORS[sample.source] || '#666';
const name = SOURCE_NAMES[sample.source] || sample.source;
const placeParts = sample.place_name;
const placeStr = Array.isArray(placeParts) && placeParts.length > 0
? placeParts.filter(Boolean).join(' › ')
: '';
const srcUrl = sourceUrl(sample.pid);
el.innerHTML = `<h4>Sample</h4>
<div class="cluster-card" style="border-left-color: ${color}">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span class="source-badge" style="background: ${color}">${name}</span>
</div>
<div style="font-size: 15px; font-weight: bold; margin-bottom: 4px;">
${sample.label || sample.pid || 'Unnamed'}
</div>
<div style="font-size: 12px; color: #666; margin-bottom: 4px;">
${sample.lat.toFixed(5)}, ${sample.lng.toFixed(5)}
</div>
${placeStr ? `<div style="font-size: 12px; color: #555; margin-bottom: 4px;">${placeStr}</div>` : ''}
${sample.result_time ? `<div style="font-size: 11px; color: #888;">Date: ${sample.result_time}</div>` : ''}
${srcUrl ? `<div style="margin-top: 4px;"><a class="detail-link" href="${srcUrl}" target="_blank" rel="noopener noreferrer">View at ${name} →</a></div>` : ''}
<div id="sampleDetail" class="detail-loading">Loading full details...</div>
</div>`;
}
function updateSampleDetail(detail) {
const el = document.getElementById('sampleDetail');
if (!el) return;
if (!detail) {
el.innerHTML = '<span style="color: #c62828; font-size: 12px;">Detail query failed</span>';
return;
}
const desc = detail.description
? (detail.description.length > 300 ? detail.description.slice(0, 300) + '...' : detail.description)
: '';
el.innerHTML = `${desc ? `<div style="font-size: 12px; color: #444; margin-top: 6px; line-height: 1.4;">${desc}</div>` : ''}`;
}
// === In-map detail card (Hana Figma 225:1700; issue #226) ============
// Companion floating surface to the side-panel sample card. Same data,
// anchored near the clicked dot. v1 reads the same fields that
// updateSampleCard() consumes; lazy-loaded thumbnail/feature/material
// follow the same async pattern as updateSampleDetail().
//
// Positioning: .map-wrap is position:relative; #cesiumContainer fills
// it, so Cesium canvas pixel coords (e.position from the scene click
// handler) translate 1:1 to absolute coords inside .map-wrap.
function showInMapCard(sample, screenPos) {
const el = document.getElementById('inMapCard');
if (!el) return;
const color = SOURCE_COLORS[sample.source] || '#666';
const name = SOURCE_NAMES[sample.source] || sample.source || '';
const srcUrl = sourceUrl(sample.pid);
const titleText = sample.label || sample.pid || 'Unnamed';
const lat = sample.lat != null ? Number(sample.lat).toFixed(4) : '';
const lng = sample.lng != null ? Number(sample.lng).toFixed(4) : '';
const coords = (lat && lng) ? `${lat}, ${lng}` : '';
// Codex review of #226: every interpolation below crosses an HTML
// attribute or text boundary, so each value is escaped. The href
// additionally only renders when srcUrl is a known-shape URL — we
// do not put user-controlled data into the href attribute as a
// raw string.
const safeTitle = escapeHtml(titleText);
const safeName = escapeHtml(name);
const safeColor = escapeHtml(color);
const safeCoords = escapeHtml(coords);
const titleHtml = srcUrl
? `<a class="imc-title" href="${escapeHtml(srcUrl)}" target="_blank" rel="noopener noreferrer">${safeTitle}</a>`
: `<span class="imc-title">${safeTitle}</span>`;
el.innerHTML = `
<div class="imc-head">
${titleHtml}
<button class="imc-close" type="button" aria-label="Close" title="Close">×</button>
</div>
<div class="imc-body">
<div class="imc-meta">
<span class="imc-source-badge" style="background:${safeColor}">${safeName}</span>
<div><b>Material:</b> <span id="imcMaterial">—</span></div>
<div><b>Sample Feature:</b> <span id="imcFeature">—</span></div>
<div><b>Specimen Type:</b> <span id="imcObjectType">—</span></div>
${safeCoords ? `<div class="imc-coords">${safeCoords}</div>` : ''}
</div>
<div class="imc-thumb" id="imcThumb">
<div class="imc-thumb-placeholder" aria-label="No thumbnail"></div>
</div>
</div>
`;
// Wire close button.
const closeBtn = el.querySelector('.imc-close');
if (closeBtn) closeBtn.addEventListener('click', hideInMapCard);
// Position with collision avoidance. Default: card to the right and
// below the click point with a 12px offset. Flip horizontally if it
// would overflow the map-wrap; clamp vertically to keep it on-canvas.
const wrap = el.parentElement; // .map-wrap
if (wrap && screenPos && typeof screenPos.x === 'number') {
// Show invisibly first so we can measure.
el.style.left = '0px';
el.style.top = '0px';
el.hidden = false;
const wrapRect = wrap.getBoundingClientRect();
const cardRect = el.getBoundingClientRect();
const cardW = cardRect.width || 220;
const cardH = cardRect.height || 120;
let x = screenPos.x + 12;
let y = screenPos.y + 12;
// Flip to left of click if card would overflow right edge.
if (x + cardW > wrapRect.width - 4) {
x = screenPos.x - cardW - 12;
}
// Clamp.
if (x < 4) x = 4;
if (y + cardH > wrapRect.height - 4) y = wrapRect.height - cardH - 4;
if (y < 4) y = 4;
el.style.left = `${x}px`;
el.style.top = `${y}px`;
} else {
el.hidden = false;
}
}
function hideInMapCard() {
const el = document.getElementById('inMapCard');
if (!el) return;
el.hidden = true;
}
// Populate the lazy-loaded fields (thumbnail, material, feature,
// specimen type) once the wide-parquet query returns. `detail` is the
// row from explorer.qmd's existing detail query, extended to include
// thumbnail_url / has_feature_of_interest / material_label /
// object_type_label. See updateSampleDetail() for the side-panel
// counterpart that uses the same row.
function populateInMapCardDetail(detail) {
const el = document.getElementById('inMapCard');
if (!el || el.hidden) return; // card was closed or replaced
const setText = (id, val) => {
const node = el.querySelector(`#${id}`);
if (node) node.textContent = (val && String(val).trim()) ? val : 'Not Provided';
};
if (!detail) {
setText('imcMaterial', null);
setText('imcFeature', null);
setText('imcObjectType', null);
return;
}
setText('imcMaterial', detail.material_label);
setText('imcFeature', detail.has_feature_of_interest);
setText('imcObjectType', detail.object_type_label);
if (detail.thumbnail_url) {
const thumbEl = el.querySelector('#imcThumb');
if (thumbEl) {
// DOM construction (not innerHTML) so the thumbnail URL is
// never interpolated into an HTML attribute string and the
// onerror handler is a property, not an inline-JS string.
// Codex review of #226.
thumbEl.innerHTML = '';
const img = document.createElement('img');
img.alt = '';
img.onerror = () => {
const placeholder = document.createElement('div');
placeholder.className = 'imc-thumb-placeholder';
placeholder.setAttribute('aria-label', 'Thumbnail unavailable');
thumbEl.innerHTML = '';
thumbEl.appendChild(placeholder);
};
img.src = detail.thumbnail_url;
thumbEl.appendChild(img);
}
}
}
function updateSamples(samples) {
const el = document.getElementById('samplesSection');
if (!el) return;
if (!samples || samples.length === 0) {
el.innerHTML = '';
return;
}
let h = `<h4>Nearby Samples (${samples.length})</h4>`;
for (const s of samples) {
const color = SOURCE_COLORS[s.source] || '#666';
const name = SOURCE_NAMES[s.source] || s.source;
const placeParts = s.place_name;
const desc = Array.isArray(placeParts) && placeParts.length > 0
? placeParts.filter(Boolean).join(' › ')
: '';
const sUrl = sourceUrl(s.pid);
h += `<div class="sample-row">
<div style="display: flex; align-items: center; gap: 6px;">
${sUrl ? `<a class="sample-label" href="${sUrl}" target="_blank" rel="noopener noreferrer" style="color: #1565c0; text-decoration: none;">${s.label || s.pid}</a>` : `<span class="sample-label">${s.label || s.pid}</span>`}
<span class="source-badge" style="background: ${color}; font-size: 10px;">${name}</span>
</div>
${desc ? `<div class="sample-desc">${desc}</div>` : ''}
</div>`;
}
el.innerHTML = h;
}
// === 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
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}// === Cesium Viewer (created once, never re-created) ===
viewer = {
performance.mark('viewer-init-start');
const v = new Cesium.Viewer("cesiumContainer", {
timeline: false,
animation: false,
baseLayerPicker: true,
fullscreenElement: "cesiumContainer",
terrain: Cesium.Terrain.fromWorldTerrain()
});
// URL deep-link state (must be set before globalRect/once block reads it)
v._globeState = { mode: 'cluster', selectedPid: null, selectedH3: null };
v._initialHash = readHash();
v._suppressHashWrite = true; // cleared after zoomWatcher initializes
v._suppressTimer = null;
const globalRect = Cesium.Rectangle.fromDegrees(-180, -60, 180, 80);
Cesium.Camera.DEFAULT_VIEW_RECTANGLE = globalRect;
Cesium.Camera.DEFAULT_VIEW_FACTOR = 0.5;
const ih = v._initialHash;
const once = () => {
if (ih.lat != null && ih.lng != null) {
v.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(ih.lng, ih.lat, ih.alt || 20000000),
orientation: {
heading: Cesium.Math.toRadians(ih.heading),
pitch: Cesium.Math.toRadians(ih.pitch)
}
});
} else {
v.camera.setView({ destination: globalRect });
}
// Signal to dependent cells (e.g. tableView) that the URL-hydrated
// camera position has been applied. Anything that snapshots the
// viewer bbox at boot should wait for this rather than running
// against the default-constructed camera.
v._initialCameraApplied = true;
v.scene.postRender.removeEventListener(once);
};
v.scene.postRender.addEventListener(once);
// Two separate point collections: clusters and individual samples
v.h3Points = new Cesium.PointPrimitiveCollection();
v.scene.primitives.add(v.h3Points);
v.samplePoints = new Cesium.PointPrimitiveCollection();
v.scene.primitives.add(v.samplePoints);
v.samplePoints.show = false; // hidden until point mode
// Hover tooltip — works for both clusters and samples
v.pointLabel = v.entities.add({
label: {
show: false, showBackground: true, font: "13px monospace",
horizontalOrigin: Cesium.HorizontalOrigin.LEFT,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
pixelOffset: new Cesium.Cartesian2(15, 0),
disableDepthTestDistance: Number.POSITIVE_INFINITY, text: "",
}
});
new Cesium.ScreenSpaceEventHandler(v.scene.canvas).setInputAction((movement) => {
const picked = v.scene.pick(movement.endPosition);
if (Cesium.defined(picked) && picked.primitive && picked.id) {
v.pointLabel.position = picked.primitive.position;
v.pointLabel.label.show = true;
const meta = picked.id;
if (typeof meta === 'object' && meta.type === 'sample') {
v.pointLabel.label.text = `${meta.label || meta.pid}`;
} else if (typeof meta === 'object' && meta.count) {
v.pointLabel.label.text = `${meta.source}: ${meta.count.toLocaleString()} samples`;
} else {
v.pointLabel.label.text = String(meta);
}
} else {
v.pointLabel.label.show = false;
}
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
// Click handler — routes to cluster card or sample card.
// Uses freshSelectionToken so a slow detail/nearby fetch doesn't repaint
// the side panel after the user has clicked a different sample/cluster.
new Cesium.ScreenSpaceEventHandler(v.scene.canvas).setInputAction(async (e) => {
const picked = v.scene.pick(e.position);
if (!Cesium.defined(picked) || !picked.primitive || !picked.id) {
// Click on empty space → close the in-map card (deselect).
hideInMapCard();
return;
}
const meta = picked.id;
const isStale = freshSelectionToken(v);
if (typeof meta === 'object' && meta.type === 'sample') {
// --- Individual sample click ---
updateSampleCard(meta);
// In-map floating card (issue #226). `e.position` is the
// Cesium canvas pixel coord of the click; .map-wrap is
// position:relative and #cesiumContainer fills it, so the
// same x/y serves as absolute coords inside .map-wrap.
showInMapCard(meta, { x: e.position.x, y: e.position.y });
v._globeState.selectedPid = meta.pid;
v._globeState.selectedH3 = null;
history.pushState(null, '', buildHash(v));
// Clear nearby list
const sampEl = document.getElementById('samplesSection');
if (sampEl) sampEl.innerHTML = '';
// Stage 2: lazy-load extended fields from wide parquet.
// Extended beyond the original description-only query to
// populate the in-map card (issue #226): thumbnail_url,
// has_feature_of_interest, plus IdentifiedConcept labels
// for material and specimen type via the wide row's
// p__has_material_category[] / p__has_sample_object_type[]
// arrays (DuckDB lists are 1-indexed).
try {
const pidEsc = meta.pid.replace(/'/g, "''");
const detail = await db.query(`
SELECT
s.description,
s.thumbnail_url,
s.has_feature_of_interest,
mat_lbl.pref_label AS material_label,
obj_lbl.pref_label AS object_type_label
FROM read_parquet('${wide_url}') AS s
LEFT JOIN read_parquet('${wide_url}') AS mat
ON mat.row_id = s.p__has_material_category[1]
AND mat.otype = 'IdentifiedConcept'
LEFT JOIN read_parquet('${vocab_labels_url}') AS mat_lbl
ON mat_lbl.uri = mat.pid
LEFT JOIN read_parquet('${wide_url}') AS obj
ON obj.row_id = s.p__has_sample_object_type[1]
AND obj.otype = 'IdentifiedConcept'
LEFT JOIN read_parquet('${vocab_labels_url}') AS obj_lbl
ON obj_lbl.uri = obj.pid
WHERE s.pid = '${pidEsc}'
AND s.otype = 'MaterialSampleRecord'
LIMIT 1
`);
if (isStale()) return;
if (detail && detail.length > 0) {
updateSampleDetail(detail[0]);
populateInMapCardDetail(detail[0]);
} else {
updateSampleDetail({ description: '' });
populateInMapCardDetail(null);
}
} catch(err) {
if (isStale()) return;
console.error("Detail query failed:", err);
updateSampleDetail(null);
populateInMapCardDetail(null);
}
} else if (typeof meta === 'object' && meta.count) {
// --- Cluster click ---
updateClusterCard(meta);
// Cluster click clears any prior sample selection; close
// the in-map sample card so it doesn't outlive the context
// that opened it (issue #226).
hideInMapCard();
v._globeState.selectedPid = null;
v._globeState.selectedH3 = meta.h3_cell || null;
history.pushState(null, '', buildHash(v));
const sampEl = document.getElementById('samplesSection');
if (sampEl) sampEl.innerHTML = '<div style="text-align: center; color: #999; padding: 12px;">Loading nearby samples...</div>';
const delta = meta.resolution === 4 ? 2.0 : meta.resolution === 6 ? 0.5 : 0.1;
try {
// facetFilterSQL() returns a portable `pid IN (...)` predicate,
// so the same query works whether or not facet filters are active.
const nearbyQuery = `
SELECT pid, label, source, latitude, longitude, place_name
FROM read_parquet('${lite_url}')
WHERE latitude BETWEEN ${meta.lat - delta} AND ${meta.lat + delta}
AND longitude BETWEEN ${meta.lng - delta} AND ${meta.lng + delta}
${sourceFilterSQL('source')}
${facetFilterSQL()}
LIMIT 30
`;
const samples = await db.query(nearbyQuery);
if (isStale()) return;
updateSamples(samples);
} catch(err) {
if (isStale()) return;
console.error("Sample query failed:", err);
if (sampEl) sampEl.innerHTML = '<div style="color: #c62828; padding: 12px;">Query failed — try again.</div>';
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// Timing: viewer ready (mount complete, pre-first-render)
performance.mark('viewer-init-end');
performance.measure('viewer_init', 'viewer-init-start', 'viewer-init-end');
// Timing: first Cesium frame painted (globe visible, may be pre-cluster)
const firstGlobeFrame = () => {
performance.mark('first-globe-frame');
v.scene.postRender.removeEventListener(firstGlobeFrame);
};
v.scene.postRender.addEventListener(firstGlobeFrame);
return v;
}// === PHASE 1: Load H3 res4 globally (instant) ===
phase1 = {
performance.mark('p1-start');
applyQueryToSearch();
applyQueryToSourceFilter();
const data = await db.query(`
SELECT CAST(h3_cell AS VARCHAR) AS h3_cell_dec,
sample_count, center_lat, center_lng,
dominant_source, source_count
FROM read_parquet('${h3_res4_url}')
WHERE 1=1${sourceFilterSQL('dominant_source')}
`);
const scalar = new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.5);
let totalSamples = 0;
for (const row of data) {
const count = row.sample_count;
totalSamples += count;
const size = Math.min(3 + Math.log10(count) * 4, 20);
// h3_cell is UBIGINT (>2^53 for typical H3 indices). DuckDB-WASM
// returns UBIGINT as a JS Number, losing precision in toString(16).
// SELECT casts to VARCHAR (decimal); convert to hex via BigInt here.
const h3Hex = BigInt(row.h3_cell_dec).toString(16);
viewer.h3Points.add({
id: { h3_cell: h3Hex, count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: 4 },
position: Cesium.Cartesian3.fromDegrees(row.center_lng, row.center_lat, 0),
pixelSize: size,
color: Cesium.Color.fromCssColorString(SOURCE_COLORS[row.dominant_source] || '#666').withAlpha(0.8),
outlineColor: Cesium.Color.WHITE,
outlineWidth: 1.5,
scaleByDistance: scalar,
disableDepthTestDistance: POINT_DEPTH_TEST_DISTANCE, // issue #185
});
}
// Cache cluster data for viewport counting
viewer._clusterData = Array.from(data);
viewer._clusterTotal = { clusters: data.length, samples: totalSamples };
performance.mark('p1-end');
performance.measure('p1', 'p1-start', 'p1-end');
const elapsed = performance.getEntriesByName('p1').pop().duration;
updateStats('H3 Res4', data.length, totalSamples, `${(elapsed/1000).toFixed(1)}s`, 'Clusters Loaded', 'Samples Loaded');
updatePhaseMsg(`${data.length.toLocaleString()} clusters, ${totalSamples.toLocaleString()} samples. Zoom in for finer detail.`, 'done');
console.log(`Phase 1: ${data.length} clusters in ${elapsed.toFixed(0)}ms`);
return { count: data.length, samples: totalSamples };
}// === Load facet summaries + SKOS prefLabels, populate filter checkboxes ===
//
// Checkbox value = full URI (matches the URI strings stored in
// sample_facets_v2.parquet's material / context / object_type columns).
// Display label = SKOS prefLabel (en) when available, URI tail otherwise.
// Default state: UNCHECKED — empty = no filter.
facetFilters = {
if (!phase1) return;
// Tiny URI → prefLabel lookup. ~60 KB. Best-effort: fallback to URI tail.
const vocabMap = new Map();
try {
const vocab = await db.query(
`SELECT uri, pref_label FROM read_parquet('${vocab_labels_url}') WHERE lang = 'en'`
);
for (const r of vocab) vocabMap.set(r.uri, r.pref_label);
} catch (err) {
console.warn("vocab_labels load failed; falling back to URI tails:", err);
}
const prettyLabel = (uri) => {
if (uri == null) return "";
const hit = vocabMap.get(uri);
if (hit) return hit;
const s = String(uri);
if (!/^https?:\/\//.test(s)) return s;
const parts = s.replace(/[#?].*$/, "").split("/").filter(Boolean);
return parts.length ? parts[parts.length - 1] : s;
};
try {
const summaries = await db.query(`
SELECT facet_type, facet_value, count
FROM read_parquet('${facet_summaries_url}')
ORDER BY facet_type, count DESC
`);
const grouped = { source: [], material: [], context: [], object_type: [] };
for (const row of summaries) {
if (grouped[row.facet_type]) {
grouped[row.facet_type].push({
uri: row.facet_value,
label: prettyLabel(row.facet_value),
count: row.count
});
}
}
viewer._baselineCounts = {
source: new Map(grouped.source.map(s => [s.uri, s.count])),
material: new Map(grouped.material.map(m => [m.uri, m.count])),
context: new Map(grouped.context.map(c => [c.uri, c.count])),
object_type: new Map(grouped.object_type.map(o => [o.uri, o.count])),
};
// HTML attribute / text escapers for safety when interpolating URIs.
const escAttr = (s) => String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
const escText = (s) => String(s).replace(/&/g, '&').replace(/</g, '<');
// Render checkboxes with `data-facet` / `data-value` attributes so
// Phase 2's cross-filter count updates can mutate counts in place
// without rebuilding the HTML (which would lose mid-interaction
// selections). See issue #156, Phase 2.
const renderFilter = (bodyId, facetKey, items) => {
const body = document.getElementById(bodyId);
if (!body) return;
if (items.length === 0) {
body.innerHTML = '<em style="font-size: 11px; color: #999;">No values</em>';
return;
}
body.innerHTML = items.map(it =>
`<label class="facet-row" data-facet="${facetKey}" data-value="${escAttr(it.uri)}" title="${escAttr(it.uri)}"><input type="checkbox" value="${escAttr(it.uri)}"> ${escText(it.label)} <span class="facet-count" data-facet="${facetKey}" data-value="${escAttr(it.uri)}" style="color:#999">(${Number(it.count).toLocaleString()})</span></label>`
).join('');
};
renderFilter('materialFilterBody', 'material', grouped.material);
renderFilter('contextFilterBody', 'context', grouped.context);
renderFilter('objectTypeFilterBody', 'object_type', grouped.object_type);
applyFacetCounts('source', null);
applyQueryToFacetFilters();
// Sync #facetNote visibility for URL-loaded filters. Restoring
// checkbox state from `?material=…` does NOT fire `change`, so the
// cluster-mode "filter takes effect at neighborhood zoom" note
// would otherwise stay hidden on a shared deep-link even with
// active facets. See #234 (roadmap step 1).
syncFacetNote();
console.log(`Facet filters loaded: ${grouped.material.length} materials, ${grouped.context.length} contexts, ${grouped.object_type.length} object types (vocab labels: ${vocabMap.size})`);
} catch(err) {
console.warn("Facet summaries failed to load:", err);
}
return "loaded";
}// === 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 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 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, isError) {
if (!metaEl) return;
metaEl.textContent = text;
metaEl.style.color = isError ? '#c62828' : '#555';
}
function setMetaLoading(text) {
if (!metaEl) return;
metaEl.innerHTML = `<span class="table-loading-spinner" aria-hidden="true"></span>${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) {
const color = SOURCE_COLORS[source] || '#666';
const name = SOURCE_NAMES[source] || source || '';
return `<span class="table-badge" style="background:${color}">${escapeHtml(name)}</span>`;
}
function renderTable() {
const selectedPid = (typeof viewer !== 'undefined' && viewer._globeState)
? viewer._globeState.selectedPid : null;
if (!tableEl) return;
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 = '<div class="table-scroll"><table class="samples-table"><tbody><tr><td>Page query failed. Adjust filters or click Previous/Next to retry.</td></tr></tbody></table></div>';
} else if (pageRows.length === 0 && totalRows === 0) {
tableEl.innerHTML = '<div class="table-scroll"><table class="samples-table"><tbody><tr><td>No samples match the current filters.</td></tr></tbody></table></div>';
} else if (pageRows.length > 0) {
const body = pageRows.map(r => {
const placeParts = r.place_name;
const place = Array.isArray(placeParts) && placeParts.length > 0
? placeParts.filter(Boolean).join(' › ')
: '';
const lat = r.latitude != null ? Number(r.latitude).toFixed(5) : '';
const lng = r.longitude != null ? Number(r.longitude).toFixed(5) : '';
const label = r.label || r.pid || '';
// #226: the title cell used to be an <a href={sourceUrl}>
// that opened the source record on click. The href
// contradicted the unified click-semantics goal (click
// = open card, external link lives inside card), so
// the element is now a role="button" span with the
// same visual styling. Screen readers announce it as
// a button, not as a misleading external link. The
// external link is preserved one hop away in the
// detail card's title.
const safeLabel = escapeHtml(label);
const labelHtml = `<span class="table-link" role="button" tabindex="0" aria-label="Open details for ${safeLabel}">${safeLabel}</span>`;
const pidAttr = escapeHtml(r.pid || '');
const selectedClass = (r.pid && r.pid === selectedPid) ? ' class="selected"' : '';
return `<tr data-pid="${pidAttr}"${selectedClass}>
<td>${tableSourceBadge(r.source)}</td>
<td>${labelHtml}</td>
<td>${escapeHtml(place)}</td>
<td>${escapeHtml(r.result_time || '')}</td>
<td>${escapeHtml(lat)}</td>
<td>${escapeHtml(lng)}</td>
</tr>`;
}).join('');
tableEl.innerHTML = `<div class="table-scroll">
<table class="samples-table">
<thead><tr><th>Source</th><th>Label</th><th>Place</th><th>Date</th><th>Lat</th><th>Lon</th></tr></thead>
<tbody>${body}</tbody>
</table>
</div>`;
// Wire row clicks. Each click follows the same ceremony as the
// search-row click handler (see #207 item 8): bump the freshness
// token first, set selection state, hydrate the card, fly the
// camera, then lazy-load description. Reusing the sample-mode
// selection path keeps the URL (`#pid=...`) and sidebar in sync
// regardless of which surface initiated the selection.
tableEl.querySelectorAll('tbody tr[data-pid]').forEach(tr => {
// Row activation = open detail card. Pointer click on
// any cell, Enter/Space on the role="button" title span,
// and Enter on the row itself (when tab-focused on the
// title) all route through the same handler. Issue
// #226: there is no longer an <a href> in the row, so
// the activation is uniform across surfaces.
const activateRow = async (e) => {
const pid = tr.dataset.pid;
const sample = pid ? pageRowsByPid.get(pid) : null;
if (!sample || sample.latitude == null || sample.longitude == null) return;
if (typeof viewer === 'undefined') return;
const isStale = freshSelectionToken(viewer);
viewer._globeState.selectedPid = pid;
viewer._globeState.selectedH3 = null;
const sampleMeta = {
pid: sample.pid,
label: sample.label,
source: sample.source,
lat: sample.latitude,
lng: sample.longitude,
place_name: sample.place_name,
result_time: sample.result_time
};
updateSampleCard(sampleMeta);
// Show the in-map card *immediately* (Codex review
// of #226). Previously we deferred showInMapCard()
// to flyTo.complete, but the lazy-load detail query
// could return inside the 1.5s flight window and
// populateInMapCardDetail() would either no-op
// (card still hidden) or mutate the previous-still-
// visible card. Anchoring at canvas centre is
// correct: that's where the sample lands after
// flyTo. The flight animates underneath the
// already-visible card.
const canvas = viewer.scene.canvas;
showInMapCard(sampleMeta, {
x: canvas.clientWidth / 2,
y: canvas.clientHeight / 2
});
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(sample.longitude, sample.latitude, 50000),
duration: 1.5
});
// Write the #pid hash directly here rather than relying on
// zoomWatcher's listener. zoomWatcher may not be wired yet
// at very-early-boot click time, and even when it is, the
// _suppressHashWrite gate could swallow the write.
history.replaceState(null, '', buildHash(viewer));
// Repaint to apply .selected class without a full refresh.
tableEl.querySelectorAll('tbody tr.selected').forEach(el => el.classList.remove('selected'));
tr.classList.add('selected');
try {
// Same extended lazy-load as the map-click
// path — keeps the in-map card populated when
// the click originates from the table (issue
// #226). Wide-only via self-joins for material
// and specimen-type concept labels.
const pidEsc = pid.replace(/'/g, "''");
const detail = await db.query(`
SELECT
s.description,
s.thumbnail_url,
s.has_feature_of_interest,
mat_lbl.pref_label AS material_label,
obj_lbl.pref_label AS object_type_label
FROM read_parquet('${wide_url}') AS s
LEFT JOIN read_parquet('${wide_url}') AS mat
ON mat.row_id = s.p__has_material_category[1]
AND mat.otype = 'IdentifiedConcept'
LEFT JOIN read_parquet('${vocab_labels_url}') AS mat_lbl
ON mat_lbl.uri = mat.pid
LEFT JOIN read_parquet('${wide_url}') AS obj
ON obj.row_id = s.p__has_sample_object_type[1]
AND obj.otype = 'IdentifiedConcept'
LEFT JOIN read_parquet('${vocab_labels_url}') AS obj_lbl
ON obj_lbl.uri = obj.pid
WHERE s.pid = '${pidEsc}'
AND s.otype = 'MaterialSampleRecord'
LIMIT 1
`);
if (isStale()) return;
if (detail && detail.length > 0) {
updateSampleDetail(detail[0]);
populateInMapCardDetail(detail[0]);
} else {
updateSampleDetail({ description: '' });
populateInMapCardDetail(null);
}
} catch(err) {
if (isStale()) return;
console.error('Table-row detail query failed:', err);
updateSampleDetail(null);
populateInMapCardDetail(null);
}
};
tr.addEventListener('click', activateRow);
// Keyboard parity for the role="button" title span: Enter
// and Space activate the row. Scoped to .table-link so
// Tab through other interactive cells (none today, but
// future-proofing) isn't hijacked. Space's default
// page-scroll is suppressed.
tr.addEventListener('keydown', (ev) => {
if (!ev.target.classList || !ev.target.classList.contains('table-link')) return;
if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar') {
ev.preventDefault();
activateRow(ev);
}
});
});
}
// Pager text. While totalRows is null (count still in flight after a
// filter change) we still know the current page index from the
// page-data query, so render that without the total.
if (pageInfoEl) {
if (lastPageFailed) {
pageInfoEl.textContent = '';
} else if (totalRows === 0) {
pageInfoEl.textContent = '';
} else if (totalRows == null) {
pageInfoEl.textContent = pageRows.length > 0
? `Page ${currentPage + 1}`
: '';
} else {
const totalPages = totalPagesFor(totalRows);
const first = currentPage * TABLE_PAGE_SIZE + 1;
const last = Math.min(totalRows, first + pageRows.length - 1);
pageInfoEl.textContent = `Page ${currentPage + 1} of ${totalPages} (${first.toLocaleString()}-${last.toLocaleString()} of ${totalRows.toLocaleString()})`;
}
}
}
async function loadCount(genAtStart, bboxSQL) {
try {
// pid IS NOT NULL mirrors the page query so the total counts only
// rows that are actually pageable. Without this, totalRows could
// overcount and the pager could enable Next past the last
// non-null page.
const data = await db.query(`
SELECT COUNT(*) AS n
FROM read_parquet('${lite_url}')
WHERE pid IS NOT NULL
${sourceFilterSQL('source')}
${facetFilterSQL()}
${bboxSQL}
`);
if (genAtStart !== pageGen) return true;
// DuckDB-WASM returns BigInt for COUNT(*); coerce safely.
const arr = Array.from(data);
totalRows = arr.length > 0 ? Number(arr[0].n) : 0;
renderTable();
return true;
} catch (err) {
if (genAtStart !== pageGen) return false;
console.error('Table count query failed:', err);
// Leave totalRows null; the page query may still succeed and
// render rows without a total. The meta line surfaces the error.
return false;
}
}
async function loadPage(p, genAtStart, bboxSQL) {
try {
const offset = p * TABLE_PAGE_SIZE;
// AND pid IS NOT NULL: belt-and-suspenders. pid is the primary
// identifier and should never be null, but ORDER BY a column
// that contains nulls is only deterministic by accident on a
// read-only parquet snapshot. Filter them defensively so the
// page contract holds even if a future parquet rev allows nulls.
const data = await db.query(`
SELECT pid, label, source, latitude, longitude, place_name, result_time
FROM read_parquet('${lite_url}')
WHERE pid IS NOT NULL
${sourceFilterSQL('source')}
${facetFilterSQL()}
${bboxSQL}
ORDER BY pid
LIMIT ${TABLE_PAGE_SIZE} OFFSET ${offset}
`);
if (genAtStart !== pageGen) return true;
const arr = Array.from(data);
pageRows = arr;
pageRowsByPid = new Map(arr.map(r => [r.pid, r]));
currentPage = p;
lastPageFailed = false;
renderTable();
return true;
} catch (err) {
if (genAtStart !== pageGen) return false;
console.error('Table page query failed:', err);
pageRows = [];
pageRowsByPid = new Map();
lastPageFailed = true;
renderTable();
return false;
}
}
function summaryText() {
if (totalRows == null) return 'Counting samples...';
if (totalRows === 0) return 'No samples match the current filters.';
const total = totalRows.toLocaleString();
return `${total} sample${totalRows === 1 ? '' : 's'} match the current filters.`;
}
async function refreshAll() {
const gen = ++pageGen;
currentPage = 0;
totalRows = null;
// Snapshot the viewport bbox at refresh-start time so the count and
// page queries see the same predicate. The user could pan during
// the async window; pageGen invalidates the in-flight result, and
// moveEnd will fire a fresh refreshAll() against the new bbox.
// VIEWPORT_PAD_FACTOR is shared with the point-mode loader and the
// cluster-mode "Samples in View" stat (issue #221).
const bboxSQL = viewerBboxSQL('latitude', 'longitude', VIEWPORT_PAD_FACTOR);
if (bboxSQL === null) {
// No view rectangle (rare; off-globe). Don't fall back to a
// no-bbox query — that's the bug shape that motivated PR #219.
// Clear the table and surface a status message.
pageRows = [];
pageRowsByPid = new Map();
totalRows = 0;
lastPageFailed = false;
renderTable();
setLoading(false);
setMeta('No globe area in view; pan or zoom the globe to see samples.', true);
return;
}
setLoading(true);
setMetaLoading('Loading samples in view...');
// Stale-while-loading: leave existing pageRows rendered (dimmed by
// .is-loading) so the table doesn't blink to empty.
const [countOk, pageOk] = await Promise.all([loadCount(gen, bboxSQL), loadPage(0, gen, bboxSQL)]);
if (gen !== pageGen) return;
setLoading(false);
if (pageOk && countOk) {
setMeta(summaryText());
} else if (pageOk) {
// Count failed but page succeeded: show row totals from the page
// alone (caller can still navigate; Next is enabled only if
// totalRows is known).
setMeta('Page loaded, but the total-row count failed; try again to recompute.', true);
} else {
setMeta('Table query failed; adjust filters and try again.', true);
}
}
async function refreshPage(newPage) {
const gen = ++pageGen;
// Page navigation uses the CURRENT viewport bbox. Counting was
// already done against this bbox (refreshAll caches totalRows for
// the filter+bbox combo until the next refreshAll).
const bboxSQL = viewerBboxSQL('latitude', 'longitude', VIEWPORT_PAD_FACTOR);
if (bboxSQL === null) {
// User somehow paged while the camera went off-globe; refresh
// re-scopes properly.
return refreshAll();
}
setLoading(true);
setMetaLoading(`Loading page ${newPage + 1}...`);
const ok = await loadPage(newPage, gen, bboxSQL);
if (gen !== pageGen) return;
setLoading(false);
if (ok) {
setMeta(summaryText());
} else {
setMeta('Page query failed; adjust filters and try again.', true);
}
}
if (prevBtn) prevBtn.addEventListener('click', () => {
if (currentPage <= 0) return;
refreshPage(currentPage - 1);
});
if (nextBtn) nextBtn.addEventListener('click', () => {
if (totalRows == null) return;
if (currentPage >= totalPagesFor(totalRows) - 1) return;
refreshPage(currentPage + 1);
});
document.getElementById('sourceFilter')?.addEventListener('change', refreshAll);
document.getElementById('materialFilterBody')?.addEventListener('change', refreshAll);
document.getElementById('contextFilterBody')?.addEventListener('change', refreshAll);
document.getElementById('objectTypeFilterBody')?.addEventListener('change', refreshAll);
// Re-scope the table to the viewport on every camera settle. moveEnd
// fires once per user gesture (after the pan/zoom ends), so this is
// already debounced by user input. pageGen invalidates any racing
// in-flight query. This listener also covers the boot case: when the
// viewer's once() handler does the URL-hydrated setView, moveEnd
// fires once and the initial table query runs against the correct
// bbox.
if (typeof viewer !== 'undefined') {
viewer.camera.moveEnd.addEventListener(() => {
refreshAll();
});
}
window.refreshSamplesTable = refreshAll;
// Boot: defer the initial refresh until the URL-hydrated camera has
// been applied (otherwise the first query runs against the
// default-constructed camera, briefly showing the wrong table).
// viewer.camera.moveEnd will fire when once() does setView and trigger
// the refresh via the listener above. If the camera is already
// settled (e.g. fast-path re-runs of this cell), refresh immediately.
if (typeof viewer !== 'undefined' && viewer._initialCameraApplied) {
refreshAll();
} else {
// Show the loading state so the user sees the spinner immediately
// rather than a stale "Loading samples matching the current
// filters..." default-meta line.
setLoading(true);
setMetaLoading('Loading samples in view...');
}
return "active";
}// === Zoom watcher: H3 cluster mode + individual sample point mode ===
zoomWatcher = {
if (!phase1) return;
if (!facetFilters) return; // wait for facet checkboxes
// --- State ---
// `viewer._globeState.mode` is the single source of truth for cluster/
// point mode (issue #208 smell 2). The closure-private `mode` variable
// that used to live here was removed; reads now go through the helper
// below, writes only via `setExplorerMode()` (in enterPointMode /
// exitPointMode).
const getMode = () => viewer._globeState.mode;
const setExplorerMode = (next) => { viewer._globeState.mode = next; };
let currentRes = 4;
let loading = false;
let requestId = 0; // stale-request guard
// clusterDataCache stored on viewer._clusterData (set by phase1 and loadRes)
// freshSelectionToken(viewer) is defined at top level (alongside readHash /
// buildHash) so the viewer cell's click handler and this cell's handlers
// can both reach it. See issue #187.
// Hysteresis thresholds to avoid flicker
const ENTER_POINT_ALT = 120000; // 120 km → enter point mode
const EXIT_POINT_ALT = 180000; // 180 km → exit point mode
const POINT_BUDGET = DEFAULT_POINT_BUDGET;
// No viewport cache: the samples table (PR #219) re-queries on every
// `moveEnd` against the current padded bbox, so reusing a cached
// `cachedTotalCount` here would have point-mode show a stale count
// while the table shows the fresh one (issue #221). Both surfaces
// now re-fetch in lockstep — small perf cost on rapid panning, but
// the two counts no longer diverge.
// --- H3 cluster loading (existing logic) ---
//
// `opts.loadingMsg` / `opts.errorMsg` override the default phase text so
// callers like the boot→point-mode path can show a coherent
// "Fetching sample index…" / "Failed to fetch the sample index…" pair
// (issue #190 fix 2) instead of internal "H3 res8" jargon.
//
// Return value: `true` if this call applied fresh data and `currentRes`
// is now `res`; `false` if the call was superseded by a newer one (stale
// generation) or failed. Callers that gate follow-up work on the cluster
// resolution being ready (e.g. the camera handler before `enterPointMode`)
// must use the return value rather than treating a normal `await` return
// as success.
let loadResGen = 0; // generation counter to discard stale results
const loadRes = async (res, url, opts = {}) => {
const gen = ++loadResGen; // claim a generation
loading = true;
updatePhaseMsg(opts.loadingMsg || `Loading H3 res${res}...`, 'loading');
try {
performance.mark(`r${res}-s`);
const data = await db.query(`
SELECT CAST(h3_cell AS VARCHAR) AS h3_cell_dec,
sample_count, center_lat, center_lng,
dominant_source, source_count
FROM read_parquet('${url}')
WHERE 1=1${sourceFilterSQL('dominant_source')}
`);
if (gen !== loadResGen) return false; // stale — a newer call superseded this one
viewer.h3Points.removeAll();
const scalar = new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.3);
let total = 0;
for (const row of data) {
total += row.sample_count;
const size = Math.min(3 + Math.log10(row.sample_count) * 3.5, 18);
const h3Hex = BigInt(row.h3_cell_dec).toString(16);
viewer.h3Points.add({
id: { h3_cell: h3Hex, count: row.sample_count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: res },
position: Cesium.Cartesian3.fromDegrees(row.center_lng, row.center_lat, 0),
pixelSize: size,
color: Cesium.Color.fromCssColorString(SOURCE_COLORS[row.dominant_source] || '#666').withAlpha(0.85),
outlineColor: Cesium.Color.WHITE,
outlineWidth: 1.5,
scaleByDistance: scalar,
disableDepthTestDistance: POINT_DEPTH_TEST_DISTANCE, // issue #185
});
}
// Cache for viewport counting
viewer._clusterData = Array.from(data);
viewer._clusterTotal = { clusters: data.length, samples: total };
performance.mark(`r${res}-e`);
performance.measure(`r${res}`, `r${res}-s`, `r${res}-e`);
const elapsed = performance.getEntriesByName(`r${res}`).pop().duration;
// Show viewport count immediately. Use the same padded bbox
// the samples table queries with (issue #221 round 2), so the
// cluster-mode "Samples in View" stat agrees with the table's
// row total.
const inView = countInViewport(paddedViewportBounds(VIEWPORT_PAD_FACTOR));
updateStats(`H3 Res${res}`, `${inView.clusters.toLocaleString()} / ${data.length.toLocaleString()}`, inView.samples.toLocaleString(), `${(elapsed/1000).toFixed(1)}s`, 'Clusters in View / Loaded', 'Samples in View');
// Skip the "Zoom closer for individual samples." done message when
// the caller is about to transition into point mode itself — the
// next step (loadViewportSamples) immediately overwrites it with
// its own "Loading individual samples…" loading state, so flashing
// a misleading "zoom closer" hint at a user who is already deep
// in point altitude is just noise (issue #190 fix 2).
if (!opts.suppressDoneMsg) {
updatePhaseMsg(`${data.length.toLocaleString()} clusters, ${total.toLocaleString()} samples. ${res < 8 ? 'Zoom in for finer detail.' : 'Zoom closer for individual samples.'}`, 'done');
}
currentRes = res;
console.log(`Res${res}: ${data.length} clusters in ${elapsed.toFixed(0)}ms`);
return true;
} catch(err) {
// Same generation guard as the success path — a stale failure
// must not overwrite UI state owned by a newer in-flight call.
if (gen !== loadResGen) return false;
console.error(`Failed to load res${res}:`, err);
updatePhaseMsg(opts.errorMsg || `Failed to load H3 res${res} — try zooming again.`, 'loading');
return false;
} finally {
// Only release the busy flag if we're still the current generation.
// A stale call's `finally` running after a newer call has set
// `loading = true` would otherwise clear the flag while the newer
// load is still in flight.
if (gen === loadResGen) loading = false;
}
};
// --- Get camera viewport bounds ---
function getViewportBounds() {
const rect = viewer.camera.computeViewRectangle(viewer.scene.globe.ellipsoid);
if (!rect) return null;
return {
south: Cesium.Math.toDegrees(rect.south),
north: Cesium.Math.toDegrees(rect.north),
west: Cesium.Math.toDegrees(rect.west),
east: Cesium.Math.toDegrees(rect.east)
};
}
// --- Count clusters visible in current viewport (from cached array) ---
function countInViewport(bounds) {
const cache = viewer._clusterData;
if (!bounds || !cache || cache.length === 0) return { clusters: 0, samples: 0 };
const { south, north, west, east } = bounds;
const wrapLng = west > east; // dateline crossing
let clusters = 0, samples = 0;
for (const row of cache) {
if (row.center_lat < south || row.center_lat > north) continue;
if (wrapLng ? (row.center_lng < west && row.center_lng > east) : (row.center_lng < west || row.center_lng > east)) continue;
clusters++;
samples += row.sample_count;
}
return { clusters, samples };
}
// --- Load individual samples for current viewport ---
async function loadViewportSamples() {
const myReqId = ++requestId;
const bounds = getViewportBounds();
if (!bounds) return;
// Fetch with VIEWPORT_PAD_FACTOR (30%) padding so this fetch
// covers the same bbox as the table query and the cluster-mode
// "Samples in View" count (issue #221).
const latPad = (bounds.north - bounds.south) * VIEWPORT_PAD_FACTOR;
const lngPad = (bounds.east - bounds.west) * VIEWPORT_PAD_FACTOR;
const padded = {
south: bounds.south - latPad,
north: bounds.north + latPad,
west: bounds.west - lngPad,
east: bounds.east + lngPad
};
updatePhaseMsg('Loading individual samples...', 'loading');
try {
performance.mark('sp-s');
// facetFilterSQL() returns a portable `pid IN (...)` predicate,
// so the same query works whether or not facet filters are active.
//
// ORDER BY pid (issue #206): without explicit ordering, which
// POINT_BUDGET-worth of rows the LIMIT returns is undefined and
// can differ across browsers/sessions for the same query.
const whereClause = `
WHERE latitude BETWEEN ${padded.south} AND ${padded.north}
AND longitude BETWEEN ${padded.west} AND ${padded.east}
${sourceFilterSQL('source')}
${facetFilterSQL()}
`;
const query = `
SELECT pid, label, source, latitude, longitude,
place_name, result_time
FROM read_parquet('${lite_url}')
${whereClause}
ORDER BY pid
LIMIT ${POINT_BUDGET}
`;
const data = await db.query(query);
performance.mark('sp-e');
performance.measure('sp', 'sp-s', 'sp-e');
const elapsed = performance.getEntriesByName('sp').pop().duration;
// Stale guard: discard if a newer request was issued
if (myReqId !== requestId) {
console.log(`Discarding stale sample response (req ${myReqId}, current ${requestId})`);
return;
}
// Real in-view count (issue #206). Without this, the "Samples in
// View" counter caps at POINT_BUDGET, silently understating dense
// regions (Cyprus/Polis: real count 23,421 vs displayed 5,000).
// Only query when we hit the cap — when LIMIT returned fewer
// rows than the budget, that count IS the real count and a
// second query would be wasteful.
let totalCount = data.length;
let capReached = false;
if (data.length >= POINT_BUDGET) {
try {
const countRow = await db.query(`
SELECT count(*) AS n
FROM read_parquet('${lite_url}')
${whereClause}
`);
if (myReqId !== requestId) return; // stale guard
totalCount = Number(countRow[0]?.n ?? data.length);
capReached = totalCount > data.length;
} catch(err) {
// Stale guard before any state mutation/logging:
// a newer request may have started while count was in
// flight (Codex review of PR #210).
if (myReqId !== requestId) return;
// Don't fail the whole load if the count query fails;
// just fall back to the displayed-count behavior.
console.warn("Real-count query failed; falling back to rendered count:", err);
}
}
const samples = Array.from(data);
renderSamplePoints(samples, bounds);
// Stat boxes: LEFT (sPoints) always shows rendered count under
// "Samples Rendered", RIGHT (sSamples) always shows real in-view
// total under "Samples in View". Stable labels even when both
// numbers are equal (Codex review preference: avoids label
// flipping as the user zooms across the cap boundary).
updateStats('Samples', samples.length, totalCount, `${(elapsed/1000).toFixed(1)}s`, 'Samples Rendered', 'Samples in View');
const phaseMsg = capReached
? `${totalCount.toLocaleString()} samples in view (showing ${samples.length.toLocaleString()} — zoom in for more). Click one for details.`
: `${samples.length.toLocaleString()} individual samples. Click one for details.`;
updatePhaseMsg(phaseMsg, 'done');
console.log(`Point mode: rendered ${samples.length} of ${totalCount} samples in ${elapsed.toFixed(0)}ms${capReached ? ' (cap reached)' : ''}`);
} catch(err) {
if (myReqId !== requestId) return;
console.error("Viewport sample query failed:", err);
updatePhaseMsg('Sample query failed — try again.', 'loading');
}
}
// --- Render sample points on globe ---
function renderSamplePoints(data, bounds) {
viewer.samplePoints.removeAll();
const scalar = new Cesium.NearFarScalar(1e2, 8, 2e5, 3);
for (const row of data) {
const color = SOURCE_COLORS[row.source] || '#666';
viewer.samplePoints.add({
id: {
type: 'sample',
pid: row.pid,
label: row.label,
source: row.source,
lat: row.latitude,
lng: row.longitude,
place_name: row.place_name,
result_time: row.result_time
},
position: Cesium.Cartesian3.fromDegrees(row.longitude, row.latitude, 0),
pixelSize: 6,
color: Cesium.Color.fromCssColorString(color).withAlpha(0.9),
outlineColor: Cesium.Color.WHITE,
outlineWidth: 1.5,
scaleByDistance: scalar,
disableDepthTestDistance: POINT_DEPTH_TEST_DISTANCE, // issue #185
});
}
}
// --- Mode transitions ---
function enterPointMode(pushHistory) {
setExplorerMode('point');
viewer.h3Points.show = false;
viewer.samplePoints.show = true;
if (pushHistory !== false) history.pushState(null, '', buildHash(viewer));
// #facetNote is only meaningful in cluster mode (#234 step 1).
syncFacetNote();
loadViewportSamples();
console.log('Entered point mode');
}
function exitPointMode(pushHistory) {
setExplorerMode('cluster');
viewer.samplePoints.show = false;
viewer.samplePoints.removeAll();
viewer.h3Points.show = true;
if (pushHistory !== false) history.pushState(null, '', buildHash(viewer));
// Returning to cluster mode: surface the honesty note if any
// facet filter is active (#234 step 1).
syncFacetNote();
// Invalidate any in-flight `loadViewportSamples()` so a slow sample
// query that returns after we've already restored cluster stats
// can't repaint the stat boxes / phase-msg with stale point-mode
// numbers (Codex review of #221). Without this bump, a pending
// sample query's renderSamplePoints + updateStats would still pass
// the stale-request guard on completion.
++requestId;
// Restore cluster stats with viewport count. Use the padded bbox
// shared with the samples table (issue #221 round 2).
const inView = countInViewport(paddedViewportBounds(VIEWPORT_PAD_FACTOR));
const total = viewer._clusterTotal;
if (total) {
updateStats(`H3 Res${currentRes}`, `${inView.clusters.toLocaleString()} / ${total.clusters.toLocaleString()}`, inView.samples.toLocaleString(), '—', 'Clusters in View / Loaded', 'Samples in View');
} else {
updateStats(`H3 Res${currentRes}`, viewer.h3Points.length, '—', '—', 'Clusters Loaded', 'Samples Loaded');
}
updatePhaseMsg(`${inView.clusters.toLocaleString()} clusters in view. Zoom closer for individual samples.`, 'done');
console.log('Exited point mode');
}
// --- Boot→point-mode transition (issue #190 fix 2) ---
//
// Idempotent helper that runs the cluster→point-mode transition iff the
// camera is currently at point-mode altitude. Called from three paths:
//
// 1. The camera-changed handler, when `targetMode === 'point' && mode
// !== 'point'` — the normal cold-cache deep-link path.
// 2. The source-filter handler's `mode === 'cluster'` branch, after its
// own `loadRes(currentRes, ...)` settles. Without this second call,
// a source-filter toggle during the 60-90s cold-cache wait would
// supersede the camera handler's pending res8 load (`loadResGen`++),
// the camera handler's post-await re-check would correctly refuse
// to enter point mode, and then no camera event would necessarily
// fire to retry — leaving the user in cluster mode at point altitude
// until they nudged the camera (issue #190 round-2 review).
// 3. The camera handler's cluster-resolution reload branches, after
// their `loadRes(target, ...)` settles. This covers the same liveness
// shape when a camera-initiated cluster load was already in flight as
// the user crossed into point altitude.
//
// The function re-checks altitude/`mode` after its own (potentially
// long) `loadRes` await for the same reason: if the user zooms back out
// or another path enters point mode during the wait, we must not force
// entry afterwards.
//
// INVARIANT: every `loadRes` call site in this cell *outside this
// helper* that could be in flight when the user crosses below
// `ENTER_POINT_ALT` MUST capture the call's return value and chase
// with `await tryEnterPointModeIfNeeded()` *iff that return is `true`*
// (i.e., fresh data was applied). Idiom:
//
// const applied = await loadRes(...);
// if (applied) await tryEnterPointModeIfNeeded();
//
// The `!loading` short-circuit below relies on this — when an unrelated
// `loadRes` is in flight we bail and leave recovery to that call site's
// own post-await chase.
//
// Why gate the chase on `applied` (issue #193):
// - On `false`-stale: a newer `loadRes` caller superseded us; that
// caller chases when it settles, so our chase would be redundant.
// - On `false`-failed: `loadRes` already painted a "Failed to load…"
// phase message that the user should be able to read; chasing here
// would immediately overpaint it with "Fetching sample index…".
//
// A new `loadRes` caller added without the chase would silently break
// supersession recovery and only surface as a rare liveness regression
// (see issue #194). Verify with:
// grep -nE "await[[:space:]]+loadRes\(" explorer.qmd
// which lists exactly the awaited call sites (one inside this helper,
// and one per external caller). For each external match, confirm it
// captures the return into `applied` and is immediately followed by
// `if (applied) await tryEnterPointModeIfNeeded();`.
async function tryEnterPointModeIfNeeded(opts) {
if (getMode() === 'point') return;
if (viewer.camera.positionCartographic.height >= ENTER_POINT_ALT) return;
let res8Ready = currentRes === 8;
// See INVARIANT above: if an unrelated load is in flight, that
// call site is responsible for chasing with this helper when it
// settles. Don't second-guess it from here.
if (!res8Ready && !loading) {
res8Ready = await loadRes(8, h3_res8_url, {
loadingMsg: 'Fetching sample index…',
suppressDoneMsg: true,
errorMsg: 'Failed to fetch the sample index — try zooming out and back in.',
});
}
const hNow = viewer.camera.positionCartographic.height;
if (res8Ready && getMode() !== 'point' && hNow < ENTER_POINT_ALT) {
// Propagate `pushHistory` so boot/hash hydration callers can
// avoid growing the browser history stack (issue #207 item 3).
enterPointMode(opts && opts.pushHistory);
}
}
// === Cross-filter facet count refresh (issue #156, Phase 2) ===
//
// Counts answer: for each value in facet D, how many samples would match
// this value plus the active filters in all OTHER facets. This keeps
// selected facets useful as drill-out controls instead of just echoing the
// selected values.
let facetCountsReqId = 0;
let facetCountsDebounce = null;
function describeCrossFilters() {
const sourceChecks = document.querySelectorAll('#sourceFilter input[type="checkbox"]');
const sourceTotal = sourceChecks.length;
const sources = getActiveSources();
const mat = getCheckedValues('materialFilterBody');
const ctx = getCheckedValues('contextFilterBody');
const ot = getCheckedValues('objectTypeFilterBody');
const dims = [
{ key: 'source', col: 'source', values: sources.length < sourceTotal ? sources : [] },
{ key: 'material', col: 'material', values: mat },
{ key: 'context', col: 'context', values: ctx },
{ key: 'object_type', col: 'object_type', values: ot },
];
const activeDims = dims.filter(d => d.values.length > 0);
const totalActiveValues = activeDims.reduce((n, d) => n + d.values.length, 0);
return {
dims,
activeDims,
totalActiveValues,
sourceImpossible: sourceTotal > 0 && sources.length === 0,
};
}
// `colPrefix` lets callers qualify column names when the WHERE is going
// to be used inside a JOIN (B1 bbox-aware path). Defaults to '' so the
// pre-B1 single-table call sites keep working unchanged.
function buildCrossFilterWhere(excludeFacet, colPrefix = '') {
const { activeDims, sourceImpossible } = describeCrossFilters();
if (sourceImpossible && excludeFacet !== 'source') return '1=0';
const conds = activeDims
.filter(d => d.key !== excludeFacet)
.map(d => {
const list = d.values.map(v => `'${escSql(v)}'`).join(',');
return `${colPrefix}${d.col} IN (${list})`;
});
return conds.length > 0 ? conds.join(' AND ') : '1=1';
}
async function updateCrossFilteredCounts(myReq) {
if (myReq !== facetCountsReqId) return;
const { dims, activeDims, totalActiveValues, sourceImpossible } = describeCrossFilters();
// --- B1 (issue #234 step 3): bbox-aware counts ---
//
// Snapshot the viewport state at function entry. If the camera moves
// after this, a fresh `refreshFacetCounts()` call (from the moveEnd
// listener) bumps `facetCountsReqId` and supersedes this in-flight
// request — every `await` resume checks `myReq !== facetCountsReqId`.
//
// `isGlobalView()` true → no spatial constraint → cube fast-path and
// baseline early-return remain valid as before.
// `isGlobalView()` false → snapshot a bbox SQL fragment scoped to the
// `lite_url` lat/lon columns (which we JOIN to in the slow path,
// since `facets_url` carries no coordinates today). Cube fast-path
// is unconditionally gated off (it is pre-aggregated globally and
// can't answer viewport-scoped questions).
const isGlobal = isGlobalView();
const bboxSQL = isGlobal ? null : viewerBboxSQL('l.latitude', 'l.longitude', 0);
// Baseline early-return only applies when there is no filter AND no
// spatial constraint. In a non-global view with no facet filter, B1
// still wants per-value counts scoped to what's visible — fall
// through to the slow path with `where = '1=1'`.
if (!sourceImpossible && activeDims.length === 0 && bboxSQL === null) {
for (const d of dims) applyFacetCounts(d.key, null);
return;
}
markFacetCountsRecomputing();
// Cube fast-path: pre-aggregated globally, so it's valid only when
// the camera is at (or close to) the global view.
const singleActiveDim = !sourceImpossible
&& activeDims.length === 1 && activeDims[0].values.length === 1
? activeDims[0] : null;
if (singleActiveDim && totalActiveValues === 1 && bboxSQL === null) {
try {
const filterCols = ['filter_source', 'filter_material', 'filter_context', 'filter_object_type'];
const filterColForKey = {
source: 'filter_source',
material: 'filter_material',
context: 'filter_context',
object_type: 'filter_object_type',
};
const targetCol = filterColForKey[singleActiveDim.key];
const value = escSql(singleActiveDim.values[0]);
const whereParts = filterCols.map(c =>
c === targetCol ? `${c} = '${value}'` : `${c} IS NULL`
);
const rows = await db.query(`
SELECT facet_type, facet_value, count
FROM read_parquet('${cross_filter_url}')
WHERE ${whereParts.join(' AND ')}
`);
if (myReq !== facetCountsReqId) return;
if (rows && rows.length > 0) {
const grouped = { source: new Map(), material: new Map(), context: new Map(), object_type: new Map() };
for (const r of rows) {
if (grouped[r.facet_type]) grouped[r.facet_type].set(r.facet_value, Number(r.count));
}
for (const d of dims) {
applyFacetCounts(d.key, d.key === singleActiveDim.key ? null : grouped[d.key]);
}
return;
}
} catch (err) {
console.warn('Cross-filter cache lookup failed; falling back to on-the-fly:', err);
}
}
await Promise.all(dims.map(async (d) => {
try {
let rows;
if (bboxSQL) {
// B1 bbox-scoped slow path: JOIN facets_url to lite_url
// on pid so we can filter by lite.latitude / lite.longitude.
// facets_url has no coordinates of its own. Per-PR follow-up:
// if this JOIN turns out too slow in practice, bake a
// pre-joined parquet (decision Q1 in plan).
const where = buildCrossFilterWhere(d.key, 'f.');
rows = await db.query(`
SELECT f.${d.col} AS value, COUNT(*) AS count
FROM read_parquet('${facets_url}') f
JOIN read_parquet('${lite_url}') l ON l.pid = f.pid
WHERE ${where} AND f.${d.col} IS NOT NULL${bboxSQL}
GROUP BY f.${d.col}
`);
} else {
const where = buildCrossFilterWhere(d.key);
rows = await db.query(`
SELECT ${d.col} AS value, COUNT(*) AS count
FROM read_parquet('${facets_url}')
WHERE ${where} AND ${d.col} IS NOT NULL
GROUP BY ${d.col}
`);
}
if (myReq !== facetCountsReqId) return;
const map = new Map();
for (const r of rows) map.set(r.value, Number(r.count));
applyFacetCounts(d.key, map);
} catch (err) {
if (myReq !== facetCountsReqId) return;
console.warn(`Cross-filter count query failed for ${d.key}:`, err);
// Q3 in plan: on bbox-query throw, fall back to global
// baseline rather than clobber with an error indicator.
applyFacetCounts(d.key, null);
}
}));
}
function refreshFacetCounts() {
clearTimeout(facetCountsDebounce);
const myReq = ++facetCountsReqId;
facetCountsDebounce = setTimeout(() => {
updateCrossFilteredCounts(myReq);
}, 250);
}
// === Heatmap overlay (issue #233 phase 1) ===
let heatmapInstance = null;
let heatmapImageryLayer = null;
let heatmapContainer = null;
let heatmapReqId = 0;
let heatmapDebounce = null;
let heatmapLastKey = null;
const HEATMAP_CANVAS_SIZE = 512;
const HEATMAP_LIMIT = 100000;
function heatmapEnabled() {
return document.getElementById('heatmapToggle')?.checked === true;
}
function setHeatmapStatus(text) {
const el = document.getElementById('heatmapStatus');
if (!el) return;
el.textContent = text || '';
el.style.display = text ? 'block' : 'none';
}
function ensureHeatmapContainer() {
if (heatmapContainer) return heatmapContainer;
heatmapContainer = document.createElement('div');
heatmapContainer.id = 'heatmapRenderSurface';
heatmapContainer.style.cssText = [
'position:absolute',
'left:-10000px',
'top:-10000px',
`width:${HEATMAP_CANVAS_SIZE}px`,
`height:${HEATMAP_CANVAS_SIZE}px`,
'pointer-events:none',
].join(';');
document.body.appendChild(heatmapContainer);
return heatmapContainer;
}
function getHeatmapInstance() {
if (heatmapInstance) return heatmapInstance;
if (!window.h337) throw new Error('heatmap.js did not load');
// maxOpacity caps the rendered alpha so dense areas don't fully
// wash out the satellite imagery underneath. Without this, world
// view (35k+ pixel cells with overlapping blur radii) saturates
// to solid red. RY feedback 2026-05-27 on PR #240 follow-up.
heatmapInstance = window.h337.create({
container: ensureHeatmapContainer(),
radius: 25,
maxOpacity: 0.6,
});
return heatmapInstance;
}
// Adaptive per-point radius. heatmap.js applies a Gaussian blur of
// size `radius` around each data point; overlapping blurs add
// linearly, so at high cell density (world view: 35k cells on 512²
// canvas, each cell's default 25-pixel blur covering ~1% of canvas)
// the sum exceeds 1.0 across most of the canvas and everything
// saturates to full red regardless of underlying density.
//
// Empirical scaling: at world view (35k cells) want ~6 px; at small
// viewports (~300 cells) want ~30 px to fill space smoothly.
// sqrt(canvas_pixels / cell_count) gives ~3 at world, ~30 at small —
// double it and clamp to [6, 30].
function heatmapRadiusFor(cellCount) {
const canvasPx = HEATMAP_CANVAS_SIZE * HEATMAP_CANVAS_SIZE;
const raw = Math.sqrt(canvasPx / Math.max(1, cellCount)) * 2;
return Math.max(6, Math.min(30, Math.round(raw)));
}
function heatmapFilterHash() {
return JSON.stringify({
sources: getActiveSources().slice().sort(),
material: getCheckedValues('materialFilterBody').slice().sort(),
context: getCheckedValues('contextFilterBody').slice().sort(),
object_type: getCheckedValues('objectTypeFilterBody').slice().sort(),
});
}
function heatmapBboxPredicate(bounds, latCol, lngCol) {
const lngClause = (bounds.west > bounds.east)
? `(${lngCol} BETWEEN ${bounds.west} AND 180 OR ${lngCol} BETWEEN -180 AND ${bounds.east})`
: `${lngCol} BETWEEN ${bounds.west} AND ${bounds.east}`;
return `${latCol} BETWEEN ${bounds.south} AND ${bounds.north} AND ${lngClause}`;
}
function heatmapStringHash(value) {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
}
return String(hash);
}
function heatmapKey(bounds) {
const bbox = [bounds.south, bounds.north, bounds.west, bounds.east]
.map(v => Number(v).toFixed(4))
.join(',');
return `${bbox}:${heatmapFilterHash()}`;
}
function clearHeatmap() {
++heatmapReqId;
clearTimeout(heatmapDebounce);
heatmapLastKey = null;
setHeatmapStatus('');
if (heatmapImageryLayer) {
viewer.imageryLayers.remove(heatmapImageryLayer, true);
heatmapImageryLayer = null;
}
viewer._heatmapOverlay = {
enabled: false,
layer: null,
lastRefreshAt: viewer._heatmapOverlay?.lastRefreshAt || 0,
lastPointCount: 0,
lastKey: null,
};
}
async function renderHeatmap(myReq, key, bounds) {
if (!heatmapEnabled()) return;
setHeatmapStatus('Rendering heatmap...');
try {
// SQL pre-aggregation at pixel resolution (issue #233 phase 1.5).
//
// Previous approach: SELECT latitude, longitude LIMIT 100000 then
// bin per pixel in JS. Two problems:
// (1) LIMIT 100000 picks an arbitrary first 100k rows in parquet
// storage order — NOT geographic random. At world view, the
// heatmap silently showed whichever source happened to be
// physically first in the file (likely SESAR).
// (2) For sample sets above the cap, the density was unfaithful.
//
// This approach: push the binning into DuckDB. The SQL groups by
// pixel-cell coordinates derived from the bbox + canvas size, so
// each row returned is one (x, y, count) tuple. Result cardinality
// is bounded by canvas pixels (≤ 512² = 262k), independent of how
// many samples the bbox contains. No LIMIT needed — every sample
// counted into its true pixel bucket.
//
// Antimeridian handling: when bbox wraps (west > east), the SQL
// shifts longitudes < west by +360 so the pixel arithmetic works
// in a continuous coordinate space, matching what the old JS loop
// did at line 2976. Same `eastForRectangle` adjustment downstream.
const width = HEATMAP_CANVAS_SIZE;
const height = HEATMAP_CANVAS_SIZE;
const west = bounds.west;
const eastNorm = bounds.west > bounds.east ? bounds.east + 360 : bounds.east;
const lngSpan = Math.max(1e-9, eastNorm - west);
const latSpan = Math.max(1e-9, bounds.north - bounds.south);
const wraps = bounds.west > bounds.east;
// SQL-side pixel coordinate computation. CAST(... AS INTEGER) is
// explicit so DuckDB groups by integer keys, not floats.
const lngExprBase = `(longitude ${wraps ? `+ CASE WHEN longitude < ${west} THEN 360 ELSE 0 END` : ``})`;
const xExpr = `CAST(LEAST(${width - 1}, GREATEST(0, FLOOR((${lngExprBase} - ${west}) / ${lngSpan} * ${width}))) AS INTEGER)`;
const yExpr = `CAST(LEAST(${height - 1}, GREATEST(0, FLOOR((${bounds.north} - latitude) / ${latSpan} * ${height}))) AS INTEGER)`;
const aggregated = await db.query(`
SELECT
${xExpr} AS x,
${yExpr} AS y,
COUNT(*) AS n
FROM read_parquet('${lite_url}')
WHERE ${heatmapBboxPredicate(bounds, 'latitude', 'longitude')}
${sourceFilterSQL('source')}
${facetFilterSQL()}
GROUP BY x, y
`);
if (myReq !== heatmapReqId || !heatmapEnabled()) return;
// SQL did the binning. Convert each row to a heatmap.js point.
// Log-scale bin weights to defeat supersite max-bias. iSamples
// data has extreme power-law spatial distribution: at Cyprus
// medium zoom, one position carries 52,252 co-located samples
// (likely a museum aggregation) while the median position has
// 2 — a 26,000× ratio. Linear heatmap.js max-normalization
// makes the supersite bin full red and everything else
// essentially invisible (2/52252 = 0.004% intensity). log(1+n)
// compresses the supersite (log(52253) ≈ 10.86) and lifts the
// median (log(3) ≈ 1.10), bringing the ratio to ~10× and
// revealing the actual density distribution the user expects
// to see. RY feedback 2026-05-27 on PR #240.
const pointsRaw = [];
let logMax = 0;
let totalSamples = 0;
for (const row of aggregated) {
const n = Number(row.n);
totalSamples += n;
const logVal = Math.log1p(n);
if (logVal > logMax) logMax = logVal;
pointsRaw.push({ x: Number(row.x), y: Number(row.y), value: logVal });
}
// Adaptive radius: tight at high cell counts (world view) to
// avoid blur-overlap saturation; wide at low cell counts to
// fill space smoothly.
const radius = heatmapRadiusFor(pointsRaw.length);
const points = pointsRaw.map(p => ({ ...p, radius }));
const hm = getHeatmapInstance();
hm.setData({ min: 0, max: logMax, data: points });
const canvas = heatmapContainer.querySelector('canvas');
if (!canvas) throw new Error('heatmap.js did not produce a canvas');
const url = canvas.toDataURL('image/png');
if (myReq !== heatmapReqId || !heatmapEnabled()) return;
const eastForRectangle = bounds.west > bounds.east ? bounds.east + 360 : bounds.east;
const provider = new Cesium.SingleTileImageryProvider({
url,
rectangle: Cesium.Rectangle.fromDegrees(bounds.west, bounds.south, eastForRectangle, bounds.north),
});
const nextLayer = viewer.imageryLayers.addImageryProvider(provider);
nextLayer.alpha = 0.65;
if (heatmapImageryLayer) {
viewer.imageryLayers.remove(heatmapImageryLayer, true);
}
heatmapImageryLayer = nextLayer;
heatmapLastKey = key; // success-only — see refreshHeatmap()
const refreshedAt = Date.now();
// With SQL pre-aggregation, every sample in the bbox is counted
// into its pixel cell — no more arbitrary LIMIT cap. `capped` is
// kept on the state shape (for spec back-compat) but always
// false. `lastPointCount` is now the true sample total, not the
// capped raw-row count.
viewer._heatmapOverlay = {
enabled: true,
layer: heatmapImageryLayer,
lastRefreshAt: refreshedAt,
lastPointCount: totalSamples,
lastBinnedPointCount: points.length,
lastImageHash: heatmapStringHash(url),
lastKey: key,
capped: false,
};
setHeatmapStatus(`Heatmap rendered from ${totalSamples.toLocaleString()} samples.`);
} catch (err) {
if (myReq !== heatmapReqId) return;
console.warn('Heatmap refresh failed:', err);
setHeatmapStatus('Heatmap unavailable for this view.');
// Clear dedupe key so a retry on the same (viewport, filter)
// actually re-attempts the render. Codex round-1 review of #240.
heatmapLastKey = null;
// Codex round-2 review of #240: also remove the prior imagery
// layer on failure. Without this, the user sees the OLD heatmap
// density alongside the "Heatmap unavailable" status — a UI lie
// that survives until the next successful render. Matches the
// no-bounds path which does the same.
if (heatmapImageryLayer) {
viewer.imageryLayers.remove(heatmapImageryLayer, true);
heatmapImageryLayer = null;
}
viewer._heatmapOverlay = {
enabled: heatmapEnabled(),
layer: null,
lastRefreshAt: viewer._heatmapOverlay?.lastRefreshAt || 0,
lastPointCount: 0,
error: String(err && err.message ? err.message : err),
lastKey: null,
};
}
}
function refreshHeatmap() {
if (!heatmapEnabled()) return;
// Match VIEWPORT_PAD_FACTOR used by the table, point-mode loader,
// and cluster-mode "Samples in View" stat. Phase-1 plan OQ4 chose
// exact-viewport (padding=0) on the theory of "what's in the
// rectangle you see"; but in practice RY (2026-05-27 staging
// review) noticed the table reports more samples than the heatmap
// for the same view, which is confusing. Matching the established
// padded-bbox contract makes the numbers agree across all surfaces
// that report "in view." Codex review of PR #240, follow-up.
const bounds = paddedViewportBounds(VIEWPORT_PAD_FACTOR);
if (!bounds) {
++heatmapReqId;
clearTimeout(heatmapDebounce);
heatmapLastKey = null;
if (heatmapImageryLayer) {
viewer.imageryLayers.remove(heatmapImageryLayer, true);
heatmapImageryLayer = null;
}
setHeatmapStatus('Heatmap unavailable for this view.');
viewer._heatmapOverlay = {
enabled: true,
layer: null,
lastRefreshAt: viewer._heatmapOverlay?.lastRefreshAt || 0,
lastPointCount: 0,
lastKey: null,
};
return;
}
const key = heatmapKey(bounds);
if (key === heatmapLastKey) return;
// NOTE: heatmapLastKey is set ONLY after a successful layer swap in
// renderHeatmap(), and cleared on error/cancellation. Setting it here
// would let a moveStart-cancellation between the debounce schedule
// and the actual render leave the dedupe key set without a render
// having happened — the next moveEnd then early-returns and the
// overlay is wedged. Codex round-1 review of PR #240.
clearTimeout(heatmapDebounce);
const myReq = ++heatmapReqId;
heatmapDebounce = setTimeout(() => {
renderHeatmap(myReq, key, bounds);
}, 250);
}
document.getElementById('heatmapToggle')?.addEventListener('change', () => {
if (heatmapEnabled()) {
refreshHeatmap();
} else {
clearHeatmap();
}
});
viewer._heatmapOverlay = { enabled: false, layer: null, lastRefreshAt: 0, lastPointCount: 0, lastKey: null };
// --- Busy-flag depth counter (#173 review round 2) ---
//
// body.classList 'explorer-busy' tracks "any change-triggered async
// work in flight." Without depth counting, overlapping handlers race:
// a fast handler's `finally` removes the class while a slower
// handler's loadRes / facet recompute is still running, defeating the
// whole point of the flag. Depth-counted: class is added on the
// 0 → 1 transition and removed on the 1 → 0 transition.
let _busyDepth = 0;
function busyAcquire() {
if (_busyDepth === 0) document.body.classList.add('explorer-busy');
_busyDepth++;
}
function busyRelease() {
_busyDepth = Math.max(0, _busyDepth - 1);
if (_busyDepth === 0) document.body.classList.remove('explorer-busy');
}
// --- Source filter change handler ---
//
// The body.classList 'explorer-busy' flag wraps every async work path
// out of this handler so external observers (Playwright tests,
// perf-smoke harnesses) can wait for "all triggered work has settled"
// without race conditions against the debounced facet recompute. The
// 300ms post-refreshFacetCounts wait is intentional: refreshFacetCounts
// schedules a 250ms debounce that THEN sets the .recomputing class on
// facet count spans; we hold the busy flag until that has fired so the
// .recomputing-clear poll downstream is meaningful. See #173 review.
const resUrls = { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url };
document.getElementById('sourceFilter').addEventListener('change', async () => {
busyAcquire();
// Source filter affects which clusters / samples are visible. Invalidate
// any in-flight selection lookup AND re-validate the current selection
// (cluster or sample) under the new filter — if it's filtered out, drop
// it from runtime state and the URL so the side panel matches the globe.
const isStale = freshSelectionToken(viewer);
try {
updateSourceLegendState();
writeQueryState();
refreshHeatmap();
if (getMode() === 'cluster') {
loading = false;
const applied = await loadRes(currentRes, resUrls[currentRes]);
// Liveness recovery (issue #190 round-2 review): if the user
// is sitting at point-mode altitude — e.g. they toggled the
// source filter mid-way through the cold-cache boot wait,
// which superseded the camera handler's pending res8 load —
// drive the cluster→point transition forward here.
//
// Only chase on `applied === true` (issue #193): on `false`
// we either lost to a newer call (in which case that call's
// chase will recover) or we caught an error (in which case
// the user has a "Failed to load…" message they should be
// able to read, not have it papered over by "Fetching
// sample index…").
if (applied) await tryEnterPointModeIfNeeded();
} else {
await loadViewportSamples();
}
refreshFacetCounts();
// Re-validate selection (only if no newer filter change has fired).
if (!isStale()) {
const sel = viewer._globeState;
if (sel.selectedH3) {
const meta = await fetchClusterByH3(sel.selectedH3);
if (isStale()) return;
if (!meta) {
sel.selectedH3 = null;
updateClusterCard(null);
const sampEl = document.getElementById('samplesSection');
if (sampEl) sampEl.innerHTML = '';
history.replaceState(null, '', buildHash(viewer));
} else {
// Cluster's dominant_source still checked, but the
// nearby-samples list inside hydrateClusterUI is
// source-filtered too — re-run it under the new filter
// so the panel doesn't show stale rows from unchecked
// sources (or miss newly-checked ones).
await hydrateClusterUI(meta, isStale);
}
} else if (sel.selectedPid) {
const safe = sel.selectedPid.replace(/'/g, "''");
const stillVisible = await db.query(`
SELECT 1 FROM read_parquet('${lite_url}')
WHERE pid = '${safe}'
${sourceFilterSQL('source')}
LIMIT 1
`);
if (isStale()) return;
if (!stillVisible || stillVisible.length === 0) {
sel.selectedPid = null;
updateClusterCard(null);
const sampEl = document.getElementById('samplesSection');
if (sampEl) sampEl.innerHTML = '';
history.replaceState(null, '', buildHash(viewer));
}
}
}
await new Promise(r => setTimeout(r, 300));
} finally {
busyRelease();
}
});
// --- Material / Context / Specimen Type filter change handler ---
//
// Cluster-mode honesty: the H3 summary parquets only carry
// `dominant_source`, so material / context / object_type filters cannot
// affect cluster counts. When any of these is active in cluster mode,
// surface the explanatory `#facetNote` so users understand the filter
// takes effect at neighborhood zoom. See issue #156, Phase 1, and
// `syncFacetNote()` for the shared visibility invariant (#234 step 1).
async function handleFacetFilterChange() {
busyAcquire();
try {
syncFacetNote();
writeQueryState();
refreshHeatmap();
if (getMode() === 'point') {
await loadViewportSamples();
}
refreshFacetCounts();
await new Promise(r => setTimeout(r, 300));
} finally {
busyRelease();
}
}
document.getElementById('materialFilterBody').addEventListener('change', handleFacetFilterChange);
document.getElementById('contextFilterBody').addEventListener('change', handleFacetFilterChange);
document.getElementById('objectTypeFilterBody').addEventListener('change', handleFacetFilterChange);
// --- Camera change handler ---
let timer = null;
viewer.camera.changed.addEventListener(() => {
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
const h = viewer.camera.positionCartographic.height;
// Write the URL hash BEFORE any awaits (issue #201, Bug A).
// The camera position/heading is known synchronously; without this
// early write, a user who pans into a region that triggers a
// resolution change (cluster→res8) or a cold-cache point-mode
// transition won't see the URL update until the await chain
// below settles — and a URL copy in the meantime captures the
// PREVIOUS state. We still write again at the end of the
// handler to capture any mode change that the awaits produced.
if (!viewer._suppressHashWrite) {
history.replaceState(null, '', buildHash(viewer));
}
// Determine target mode with hysteresis
const targetMode = h < ENTER_POINT_ALT ? 'point'
: h > EXIT_POINT_ALT ? 'cluster'
: getMode();
if (targetMode === 'point' && getMode() !== 'point') {
// Cold-cache deep-link: the res8 + samples_map_lite fetches
// can take 60-90s (DuckDB-WASM 1.24.0 falls back to a full
// HTTP read; see issue #190). Delegate to the shared helper
// so the source-filter handler can call the same path on
// supersession recovery.
await tryEnterPointModeIfNeeded();
} else if (targetMode === 'cluster' && getMode() !== 'cluster') {
exitPointMode();
// Reload appropriate resolution
const target = h > 3000000 ? 4 : h > 300000 ? 6 : 8;
if (target !== currentRes && !loading) {
const applied = await loadRes(target, { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }[target]);
// The user may have crossed below ENTER_POINT_ALT while
// this cluster load was in flight; reconcile after it
// settles so no extra camera nudge is required.
//
// Skip chase on non-applied returns (issue #193): a
// stale return is recovered by the supersedor's own
// chase, and a failed return should leave the user's
// "Failed to load…" message visible instead of
// overpainting it with "Fetching sample index…".
if (applied) await tryEnterPointModeIfNeeded();
}
} else if (targetMode === 'point') {
// Already in point mode — viewport sample refresh is driven
// by the `moveEnd` listener below (issue #221), in lockstep
// with the samples table. `camera.changed` is debounced
// (`percentageChanged = 0.1`) so sub-10% pans don't fire
// here; the `moveEnd` path catches every settled move.
} else {
// Cluster mode — check if resolution should change
const target = h > 3000000 ? 4 : h > 300000 ? 6 : 8;
if (target !== currentRes && !loading) {
const applied = await loadRes(target, { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }[target]);
// The user may have crossed below ENTER_POINT_ALT while
// this cluster load was in flight; reconcile after it
// settles so no extra camera nudge is required.
// Same chase gate as the cluster-reload branch above (issue #193).
if (applied) await tryEnterPointModeIfNeeded();
}
}
// Update viewport cluster count (cluster mode only; point mode
// already shows viewport count). Padded bbox so the cluster
// "Samples in View" stat matches the samples table row total
// (issue #221 round 2).
if (getMode() === 'cluster' && viewer._clusterData) {
const inView = countInViewport(paddedViewportBounds(VIEWPORT_PAD_FACTOR));
const total = viewer._clusterTotal;
if (total) {
updateStats(`H3 Res${currentRes}`, `${inView.clusters.toLocaleString()} / ${total.clusters.toLocaleString()}`, inView.samples.toLocaleString(), null, 'Clusters in View / Loaded', 'Samples in View');
}
}
// Update URL hash (replaceState for continuous movement)
if (!viewer._suppressHashWrite) {
history.replaceState(null, '', buildHash(viewer));
}
}, 600);
});
viewer.camera.percentageChanged = 0.1;
// Backstop URL-write + point-mode sample-refresh trigger.
//
// URL write (issue #204): `camera.changed` is suppressed for
// sub-`percentageChanged` moves, so a small drag-pan that doesn't
// cross the 10% threshold never schedules the debounced callback above —
// and the URL stays stale until a larger move fires the event. `moveEnd`
// fires once per discrete settled camera move regardless of magnitude
// (mouseup on a drag, wheel-stop on a zoom, flight-complete on flyTo),
// so this catches every camera position the user can pan/zoom to.
// The `_suppressHashWrite` gate keeps the hashchange-flight path
// unaffected.
//
// Point-mode samples (issue #221): the samples table re-queries on
// every `moveEnd`. Refresh point-mode samples on the same trigger so
// the "Samples in View" stat / phase-msg stay in lockstep with the
// table's row count even on small sub-10% pans that `camera.changed`
// doesn't fire for.
// B1 (issue #234 step 3): on the very first sign of a camera move,
// (a) mark the legend italic-stale so the user sees "checking…" the
// moment they grab the globe (instead of waiting moveEnd + 250 ms);
// (b) bump `facetCountsReqId` and clear the pending debounce so any
// in-flight or debounced `updateCrossFilteredCounts` for the
// PRE-MOVE viewport is invalidated.
//
// Without (b), a query that was already in flight at moveStart could
// resume after its SELECT lands, pass the stale guard, and call
// `applyFacetCounts` — which would WRITE OLD COUNTS for a viewport the
// user has already abandoned AND clear `.recomputing` before moveEnd
// schedules the new query, leaving the legend looking authoritative
// (no italic) but stale until moveEnd's 250 ms debounce fires.
// Codex round-1 review of PR #237 caught this.
//
// moveEnd's `refreshFacetCounts()` below will bump `facetCountsReqId`
// AGAIN — that's intentional and harmless: a second supersession of
// already-superseded work, debounce coalesced.
//
// We skip the mark/bump when there are no facet-count spans rendered
// yet (initial boot, before facet UI hydrates) to avoid DOM thrash
// and a no-op debounce reset during the very first camera-position
// events that fire before the OJS facet cells have settled.
viewer.camera.moveStart.addEventListener(() => {
if (!document.querySelector('.facet-count')) return;
markFacetCountsRecomputing();
clearTimeout(facetCountsDebounce);
++facetCountsReqId;
if (heatmapEnabled()) {
clearTimeout(heatmapDebounce);
++heatmapReqId;
// Clear dedupe key — otherwise a moveStart-cancellation between
// refreshHeatmap()'s setTimeout and renderHeatmap()'s success
// could leave the key set without a render having happened,
// causing the next moveEnd to early-return and wedge the
// overlay. Codex round-1 review of #240.
heatmapLastKey = null;
setHeatmapStatus('Heatmap waiting for camera...');
}
});
viewer.camera.moveEnd.addEventListener(() => {
if (!viewer._suppressHashWrite) {
history.replaceState(null, '', buildHash(viewer));
}
// B1: viewport-aware facet counts. Bouncing through refreshFacetCounts
// reuses the existing 250ms debounce + facetCountsReqId stale-guard,
// so bursts of moveEnd (drag-pan, wheel-zoom) coalesce into one query
// and any in-flight superseded query discards its result on resume.
refreshFacetCounts();
refreshHeatmap();
if (getMode() !== 'point') return;
const h = viewer.camera.positionCartographic.height;
if (h > EXIT_POINT_ALT) {
// Sub-10% zoom-out from point mode (e.g. 175 km → 181 km) won't
// fire `camera.changed`, so without driving the exit here we'd
// be stuck in point mode above `EXIT_POINT_ALT` until a larger
// camera move. Mirror the `camera.changed` cluster-transition
// branch's `exitPointMode()` (Codex round-2 review of #221).
// The cluster-resolution reload that `camera.changed` also
// does is not needed for these small zoom-outs: in the
// 180–300 km band the target resolution stays res8, which
// point mode already had loaded. Larger zoom-outs that cross
// a resolution threshold will themselves fire `camera.changed`
// and run the normal reload path. The reverse direction
// (sub-10% zoom-in past `ENTER_POINT_ALT` from cluster mode)
// is left to `camera.changed` — pre-existing behavior, out of
// scope here.
exitPointMode();
return;
}
// In point mode and at point-mode altitude: refresh samples in
// lockstep with the samples table (issue #221).
loadViewportSamples();
});
// --- Helper: hydrate cluster row from H3 cell index ---
// Canonical H3 cells encode resolution in the 2nd hex char (the leading-zero-
// stripped form is `8<res><...>` for cell-mode indices). We support res 4/6/8
// — the parquets we have. Reject malformed input rather than silently strip:
// a stray prefix (`xxx843f...`) would otherwise change which key we look up.
// h3_cell is UBIGINT in the parquet; DuckDB-WASM rejects `0x...` literals, so
// hex → decimal happens in JS via BigInt, then CAST to UBIGINT in SQL.
const RES_TO_H3_URL = { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url };
async function fetchClusterByH3(cellHex) {
if (typeof cellHex !== 'string') return null;
const lower = cellHex.toLowerCase();
if (!/^[0-9a-f]{15}$/.test(lower)) return null;
// First nibble of the leading-zero-stripped 15-char form is the H3 mode
// (cells are mode 1 → '8' here). Reject non-cell modes (edges, vertices,
// etc.) so we don't issue spurious lookups for them.
if (lower[0] !== '8') return null;
const res = parseInt(lower[1], 16);
const url = RES_TO_H3_URL[res];
if (!url) return null;
let decimal;
try { decimal = BigInt('0x' + lower).toString(); }
catch { return null; }
try {
// Honor the active ?sources= filter — same predicate phase1/loadRes
// apply when rendering clusters (`:976`, `:1319`). A cluster whose
// dominant_source is currently unchecked wouldn't be visible on the
// globe, so hydrating its side panel would mismatch the user's view
// (and would inconsistently combine unfiltered cluster card with
// source-filtered nearby-samples in hydrateClusterUI).
const result = await db.query(`
SELECT sample_count, center_lat, center_lng, dominant_source, source_count
FROM read_parquet('${url}')
WHERE h3_cell = CAST('${decimal}' AS UBIGINT)
${sourceFilterSQL('dominant_source')}
LIMIT 1
`);
if (!result || result.length === 0) return null;
const r = result[0];
return {
// The validated input `lower` is the canonical hex; round-tripping
// r.h3_cell would lose precision (DuckDB-WASM returns UBIGINT as
// JS Number for values > 2^53).
h3_cell: lower,
count: r.sample_count,
source: r.dominant_source,
lat: r.center_lat,
lng: r.center_lng,
resolution: res,
};
} catch(err) {
console.error("fetchClusterByH3 query failed:", err);
return null;
}
}
// --- Helper: populate side panel + nearby-samples list from a cluster meta object ---
// Mirrors the cluster-click handler at the LEFT_CLICK input action above.
// The optional `isStale` predicate is consulted *after* the inner await so
// that a hashchange superseded by a newer one doesn't paint stale samples
// into the side panel. The cluster-click caller passes a no-op predicate
// because click selection is its own latest event.
async function hydrateClusterUI(meta, isStale) {
if (!meta) return;
updateClusterCard(meta);
const sampEl = document.getElementById('samplesSection');
if (sampEl) sampEl.innerHTML = '<div style="text-align: center; color: #999; padding: 12px;">Loading nearby samples...</div>';
const delta = meta.resolution === 4 ? 2.0 : meta.resolution === 6 ? 0.5 : 0.1;
try {
const samples = await db.query(`
SELECT pid, label, source, latitude, longitude, place_name
FROM read_parquet('${lite_url}')
WHERE latitude BETWEEN ${meta.lat - delta} AND ${meta.lat + delta}
AND longitude BETWEEN ${meta.lng - delta} AND ${meta.lng + delta}
${sourceFilterSQL('source')}
${facetFilterSQL()}
LIMIT 30
`);
if (isStale && isStale()) return;
updateSamples(samples);
} catch(err) {
if (isStale && isStale()) return;
console.error("Cluster hydration nearby query failed:", err);
if (sampEl) sampEl.innerHTML = '<div style="color: #c62828; padding: 12px;">Query failed — try again.</div>';
}
}
// --- Handle browser back/forward ---
window.addEventListener('hashchange', async () => {
// Bump the selection generation BEFORE any early-return so even
// hashchanges that lack lat/lng invalidate stale async work.
const isStale = freshSelectionToken(viewer);
const state = readHash();
if (state.lat == null || state.lng == null) return;
viewer._suppressHashWrite = true;
clearTimeout(viewer._suppressTimer);
viewer.camera.cancelFlight();
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(state.lng, state.lat, state.alt || 20000000),
orientation: {
heading: Cesium.Math.toRadians(state.heading),
pitch: Cesium.Math.toRadians(state.pitch)
},
duration: 1.5,
});
// After flight settles, force mode and clear suppress flag.
// Use the same altitude-authoritative predicate as the boot path
// (issue #207 item 4 → Codex follow-up): without this, back/forward
// through a `#alt=8000` URL with no `mode=point` would exit point
// mode here even though boot would have entered it.
viewer._suppressTimer = setTimeout(() => {
viewer._suppressHashWrite = false;
const s = readHash();
const wantsPoint = s.mode === 'point' || (s.alt != null && s.alt < ENTER_POINT_ALT);
if (wantsPoint && getMode() !== 'point') enterPointMode(false);
else if (!wantsPoint && getMode() === 'point') exitPointMode(false);
}, 2000);
// Handle pid / h3 selection (sample mode wins if both present — see
// EXPLORER_CLUSTER_URL_PROPOSAL §4). Both branches do an `await` against
// a remote parquet, so a fast back/forward could race: an older fetch
// resolves AFTER a newer hash has applied, and would otherwise repaint
// the side panel with stale data. `isStale` is the freshness token
// captured at the top of this handler; we check it after each await.
if (state.pid) {
viewer._globeState.selectedPid = state.pid;
viewer._globeState.selectedH3 = null;
try {
const sample = await db.query(`
SELECT pid, label, source, latitude, longitude, place_name, result_time
FROM read_parquet('${lite_url}')
WHERE pid = '${state.pid.replace(/'/g, "''")}'
LIMIT 1
`);
if (isStale()) return;
if (sample && sample.length > 0) {
const s = sample[0];
updateSampleCard({
pid: s.pid, label: s.label, source: s.source,
lat: s.latitude, lng: s.longitude,
place_name: s.place_name, result_time: s.result_time
});
}
} catch(err) {
console.error("Hash pid query failed:", err);
}
} else if (state.h3) {
viewer._globeState.selectedPid = null;
viewer._globeState.selectedH3 = state.h3.toLowerCase();
const meta = await fetchClusterByH3(state.h3);
if (isStale()) return;
if (meta) {
viewer._globeState.selectedH3 = meta.h3_cell; // canonical lowercase
await hydrateClusterUI(meta, isStale);
} else {
// Unknown / malformed h3, OR filtered out by ?sources= —
// clear the side panel rather than leaving prior content
// stranded, AND drop the h3 from runtime state so
// buildHash() doesn't keep emitting it (issue #207 item 6:
// restores symmetry with the boot deep-link path which
// already does this).
viewer._globeState.selectedH3 = null;
updateClusterCard(null);
const sampEl = document.getElementById('samplesSection');
if (sampEl) sampEl.innerHTML = '';
}
} else {
viewer._globeState.selectedPid = null;
viewer._globeState.selectedH3 = null;
updateClusterCard(null);
const sampEl = document.getElementById('samplesSection');
if (sampEl) sampEl.innerHTML = '';
}
});
// --- Share button ---
const shareBtn = document.getElementById('shareBtn');
if (shareBtn) {
shareBtn.addEventListener('click', async () => {
history.replaceState(null, '', buildHash(viewer));
try {
await navigator.clipboard.writeText(location.href);
const toast = document.getElementById('shareToast');
if (toast) {
toast.style.opacity = '1';
setTimeout(() => { toast.style.opacity = '0'; }, 2000);
}
} catch(err) {
prompt('Copy this link:', location.href);
}
});
}
// --- Search handler ---
const searchAreaBtn = document.getElementById('searchAreaBtn');
const searchWorldBtn = document.getElementById('searchWorldBtn');
const searchInput = document.getElementById('sampleSearch');
const searchResults = document.getElementById('searchResults');
let _searchSeq = 0;
// Initial scope hydrated from URL; default 'world' on missing/unknown.
let _searchScope = (
new URLSearchParams(location.search).get('search_scope') === 'area'
) ? 'area' : 'world';
function persistSearchScope(scope) {
// writeQueryState() doesn't know about scope; keep the URL param
// honest by manipulating directly. 'world' is default, omitted from
// URL.
const params = new URLSearchParams(location.search);
if (scope === 'area') params.set('search_scope', 'area');
else params.delete('search_scope');
const qs = params.toString();
const url = `${location.pathname}${qs ? `?${qs}` : ''}${location.hash}`;
if (url !== `${location.pathname}${location.search}${location.hash}`) {
history.replaceState(null, '', url);
}
}
async function doSearch(scope) {
if (scope === 'area' || scope === 'world') _searchScope = scope;
const effectiveScope = _searchScope;
// Bump the freshness token FIRST — even for invalid submits
// (empty / 1-char). Codex review of #236 round 3 caught the gap:
// if a valid search is in flight and the user clears the box and
// resubmits, the early-return below used to write "Type at least
// 2 characters" WITHOUT invalidating the in-flight search. When
// that older search later rejected, its catch would see
// `searchId === _searchSeq` and overwrite the "Type at least..."
// message with `Search error: ...`. Bumping first ensures any
// submission — valid or not — supersedes prior in-flight work.
const searchId = ++_searchSeq;
const term = searchInput.value.trim();
if (!term || term.length < 2) {
searchResults.textContent = 'Type at least 2 characters';
writeQueryState();
persistSearchScope(effectiveScope);
return;
}
writeQueryState();
persistSearchScope(effectiveScope);
searchResults.textContent = effectiveScope === 'area'
? 'Searching selected areas...'
: 'Searching entire world...';
// Per-search perf instrumentation (#167). Captures cold/warm latency,
// result count, and bytes transferred from data.isamples.org during
// the search window. transferSize is 0 for cross-origin responses
// missing Timing-Allow-Origin; we fall back to encodedBodySize.
const markStart = `search-${searchId}-start`;
const markEnd = `search-${searchId}-end`;
performance.mark(markStart);
const tStart = performance.now();
const terms = searchTerms(term);
// Snapshot the filter-state telemetry booleans here, BEFORE the
// try block, so they remain in scope through `finally`. They
// reflect the DOM state at search-fire time — independent of
// any toggles the user makes during the await chain (#167 + #236
// round 2 review).
const hadSourceFilter = getActiveSources().length !== SOURCE_VALUES.length;
const hadFacetFilter = hasFacetFilters();
let resultsCount = 0;
let totalCount = null; // Real count when cap is hit (#232); null otherwise.
let countMs = 0; // Duration of the follow-up COUNT scan, ms.
let wasSuperseded = false; // True when a newer search bumped _searchSeq mid-flight.
let errorMessage = null;
try {
// INTERIM RECALL FIX (#168 Direction A) — knowingly accepts the
// latency regression in exchange for fixing false-zero results.
//
// Background: the previous query searched samples_map_lite over
// label + place_name only; samples_map_lite does not carry
// description, so queries like `pottery Cyprus` returned zero
// even when ~7,124 samples actually match (Cyprus appears only
// in description). This is a *broken* user experience even
// though it's fast.
//
// This is NOT the FTS solution. The proper substrate work in
// #169-#172 builds a sample-centric document projection with
// hash-partitioned BM25 indexes that fixes both recall AND
// latency. This code path goes away when #171 lands.
//
// CTE-then-keyed-join shape (NOT a naive LEFT JOIN). Native
// DuckDB benchmark: naive 4.2 s vs CTE 0.5 s for `pottery`.
// The browser DuckDB-WASM penalty makes the difference even
// more pronounced; the naive form times out on `pottery` cold.
// Use `f.`-qualified columns so the same searchWhere/score
// strings work for both the world-mode CTE (single table aliased
// f) and the area-mode INNER JOIN (f + l, both via USING (pid)).
const searchWhere = textSearchWhere(terms, [
'f.label',
'f.description',
'CAST(f.place_name AS VARCHAR)',
]);
const score = textSearchScore(terms, [
{ col: 'f.label', weight: 3 },
{ col: 'f.description', weight: 1 },
{ col: 'CAST(f.place_name AS VARCHAR)', weight: 2 },
]);
// Snapshot the source / facet predicates ONCE per search so the
// follow-up COUNT(*) at the end of this try block uses the same
// filter state as the SELECT. Without this snapshot, a user who
// toggles a source checkbox or material facet while the SELECT
// is in flight would see a label like "50 of M" where M reflects
// the NEW filter state, not the rendered 50 rows (Codex review
// of PR #236 round 1). The string is captured here, before any
// `db.query` await, and re-used by both the SELECT and the
// COUNT below.
const sourceSQL = sourceFilterSQL('f.source');
const facetSQL = facetFilterSQL();
// Telemetry-equivalent of the SQL snapshots above (`hadSourceFilter`
// / `hadFacetFilter`) is declared OUTSIDE this try block so it
// remains in scope through `finally` — see comment near the
// function-level `let` declarations.
// Two SQL shapes — one per scope. The fix per #179 round-2
// review: in area mode, the viewport predicate MUST run BEFORE
// the top-50 selection, otherwise we're searching only the
// global top-50 within the area ("current viewport among the
// global top 50") rather than "top 50 within the current
// viewport." For broad terms like `pottery`, the global top-50
// is concentrated in a few hot regions, so a Sudan-area query
// would return zero even though Sudan has plenty of pottery.
//
// World mode keeps the original CTE-then-LEFT-JOIN shape so
// samples that have facets but no `samples_map_lite` row
// (i.e., no coordinates) still appear in the results, with
// null lat/lng. The click-to-fly handler already guards on
// isNaN(lat).
//
// Area mode uses INNER JOIN inside the candidate selection
// because area-scoped search by definition requires
// coordinates. Drop coord-less samples before ranking; apply
// the viewport predicate; THEN top-50.
//
// Track which shape actually ran so the follow-up COUNT(*)
// query for #232 uses the matching WHERE/JOIN. The area-scope
// path falls back to `world` when `viewerBboxSQL` returns null,
// so `effectiveScope === 'area'` is NOT a reliable predicate.
let results;
let effectiveQueryShape = 'world';
let effectiveBboxSQL = null;
if (effectiveScope === 'area') {
const bboxSQL = viewerBboxSQL('l.latitude', 'l.longitude');
if (!bboxSQL) {
// Camera couldn't produce a view rectangle (shouldn't
// happen in practice; defensive). Fall through to the
// world query so the user gets results, with a console
// hint for diagnostics.
console.warn('Area scope requested but no view rectangle; falling back to world.');
results = await runWorldQuery();
} else {
results = await db.query(`
SELECT f.pid, f.label, f.source, l.latitude, l.longitude,
f.place_name, (${score}) AS relevance_score
FROM read_parquet('${facets_url}') f
INNER JOIN read_parquet('${lite_url}') l USING (pid)
WHERE ${searchWhere}
${bboxSQL}
${sourceSQL}
${facetSQL}
ORDER BY relevance_score DESC, f.label
LIMIT 50
`);
effectiveQueryShape = 'area';
effectiveBboxSQL = bboxSQL;
}
} else {
results = await runWorldQuery();
}
async function runWorldQuery() {
return db.query(`
WITH matches AS (
SELECT f.pid, f.label, f.source, f.place_name,
(${score}) AS relevance_score
FROM read_parquet('${facets_url}') f
WHERE ${searchWhere}
${sourceSQL}
${facetSQL}
ORDER BY relevance_score DESC
LIMIT 50
)
SELECT m.pid, m.label, m.source, l.latitude, l.longitude,
m.place_name, m.relevance_score
FROM matches m
LEFT JOIN read_parquet('${lite_url}') l USING (pid)
ORDER BY m.relevance_score DESC, m.label
`);
}
// Stale-search guard (Codex review of PR #236 round 1). A
// second search the user fires while this SELECT is in flight
// bumps `_searchSeq`. Without this early guard, the superseded
// search would still mutate `searchResults.textContent`,
// repaint `samplesSection`, trigger an unwanted `camera.flyTo`,
// and launch a wasted COUNT scan — even though a later guard
// would correctly suppress the COUNT result itself. Bail BEFORE
// any of that work. The later COUNT-specific guard remains as
// a second line of defense for the case where supersession
// happens between this check and the COUNT.
if (searchId !== _searchSeq) {
wasSuperseded = true;
return;
}
resultsCount = results.length;
if (results.length === 0) {
searchResults.textContent = `No results for "${term}"`;
return;
}
// Initial render. The "+" indicates "more exist" when the cap was
// hit; if the cap was hit, the follow-up COUNT below replaces
// this with the real total ("50 of 7,124 results"). This keeps
// the user informed during cold-cache COUNT scans (where the
// second query can add a few hundred ms) rather than leaving the
// bare result-row count visible while the precise total
// resolves. See issue #232.
const capReached = results.length === 50;
searchResults.textContent = capReached
? `${results.length}+ results for "${term}"`
: `${results.length} results for "${term}"`;
// Show results in the samples panel
const sampEl = document.getElementById('samplesSection');
if (sampEl) {
let h = `<h4>Search: "${term}" (${results.length})</h4>`;
for (const s of results) {
const color = SOURCE_COLORS[s.source] || '#666';
const name = SOURCE_NAMES[s.source] || s.source;
const sUrl = sourceUrl(s.pid);
h += `<div class="sample-row" style="cursor: pointer;" data-lat="${s.latitude}" data-lng="${s.longitude}" data-pid="${s.pid}">
<div style="display: flex; align-items: center; gap: 6px;">
${sUrl ? `<a class="sample-label" href="${sUrl}" target="_blank" rel="noopener noreferrer" style="color: #1565c0; text-decoration: none;">${s.label || s.pid}</a>` : `<span class="sample-label">${s.label || s.pid}</span>`}
<span class="source-badge" style="background: ${color}; font-size: 10px;">${name}</span>
</div>
</div>`;
}
sampEl.innerHTML = h;
// Click search result → treat as a full selection event
// (issue #207 item 8 → Codex follow-up): freshness token
// bump, sample-card hydration, selection state, flight,
// and lazy detail load — matching the on-globe sample
// click ceremony at line ~944. Without the full ceremony
// a slow prior selection load could repaint the panel
// after the click, leaving UI inconsistent with URL.
const resultsByPid = new Map(results.map(s => [s.pid, s]));
sampEl.querySelectorAll('.sample-row[data-lat]').forEach(row => {
row.addEventListener('click', async (e) => {
if (e.target.tagName === 'A') return; // let links work
const pid = row.dataset.pid;
const sample = pid ? resultsByPid.get(pid) : null;
if (!sample || sample.latitude == null || sample.longitude == null) return;
// Bump the freshness token BEFORE any async work so
// a prior cluster/sample detail load can't repaint
// the panel after we navigate.
const isStale = freshSelectionToken(viewer);
viewer._globeState.selectedPid = pid;
viewer._globeState.selectedH3 = null;
updateSampleCard({
pid: sample.pid,
label: sample.label,
source: sample.source,
lat: sample.latitude,
lng: sample.longitude,
place_name: sample.place_name,
result_time: sample.result_time
});
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(sample.longitude, sample.latitude, 50000),
duration: 1.5
});
// Lazy-load full description (mirrors the globe
// click handler). Don't clear samplesSection here —
// it currently holds the search results the user
// is browsing.
try {
const detail = await db.query(`
SELECT description
FROM read_parquet('${wide_url}')
WHERE pid = '${pid.replace(/'/g, "''")}'
LIMIT 1
`);
if (isStale()) return;
if (detail && detail.length > 0) updateSampleDetail(detail[0]);
else updateSampleDetail({ description: '' });
} catch(err) {
if (isStale()) return;
console.error("Search-row detail query failed:", err);
updateSampleDetail(null);
}
});
});
}
// Fly to the first result. Skip for area-scoped searches —
// the user is already at the area they care about; flying
// would zoom in and disorient.
//
// Auto-flight is a NUDGE, not an explicit selection — clear
// any prior selectedPid/selectedH3 so the resulting URL
// doesn't carry stale selection state across the flight
// (issue #207 item 8). User clicks on a specific row to
// establish a new selection.
if (effectiveScope === 'world' && results[0].latitude && results[0].longitude) {
viewer._globeState.selectedPid = null;
viewer._globeState.selectedH3 = null;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(results[0].longitude, results[0].latitude, 200000),
duration: 1.5
});
}
// Real-count follow-up (#232). When the SELECT hit `LIMIT 50` we
// can't know whether the true result set is 51 or 50,000 — the
// legacy `50+` label hid this entirely. Mirror the pattern used
// by `loadViewportSamples()` (where this same kind of follow-up
// turned "Samples in View capped at 5,000" into a real count):
// re-run the WHERE clause as a `COUNT(*)` only when the cap was
// hit. Below the cap, `results.length` IS the truth and a second
// scan would be wasted.
//
// Cancellation: a second search the user fires while this COUNT
// is in flight bumps `_searchSeq`. The guard below ensures a
// stale COUNT can't overwrite the newer search's result text.
// We also skip the structured-log `total_count` update on stale
// returns so the #167 log line still reflects only the live
// search's measurements.
//
// Failure handling: a COUNT error leaves the initial `50+` text
// in place — better to under-disclose than to clear the line
// entirely. The cost is logged via `count_ms` so #167 analysis
// can track when the COUNT path runs.
if (capReached) {
// Use the snapshotted `sourceSQL` / `facetSQL` so the COUNT
// matches the SELECT's filter state byte-for-byte even if
// the user has toggled source/facet filters during the
// SELECT await.
const countSQL = effectiveQueryShape === 'area'
? `
SELECT COUNT(*) AS n
FROM read_parquet('${facets_url}') f
INNER JOIN read_parquet('${lite_url}') l USING (pid)
WHERE ${searchWhere}
${effectiveBboxSQL}
${sourceSQL}
${facetSQL}
`
: `
SELECT COUNT(*) AS n
FROM read_parquet('${facets_url}') f
WHERE ${searchWhere}
${sourceSQL}
${facetSQL}
`;
const countStart = performance.now();
try {
const countRow = await db.query(countSQL);
countMs = performance.now() - countStart;
if (searchId !== _searchSeq) {
// A newer search owns `searchResults.textContent`
// now; leaving `totalCount` null also keeps this
// stale COUNT out of the structured log.
wasSuperseded = true;
return;
}
const total = Number(countRow[0]?.n ?? results.length);
totalCount = total;
// When `total === results.length`, the cap happened to
// catch exactly the full set — drop the "of N" to
// avoid a redundant "50 of 50 results" reading.
searchResults.textContent = total > results.length
? `${results.length} of ${total.toLocaleString()} results for "${term}"`
: `${results.length} results for "${term}"`;
} catch(err) {
countMs = performance.now() - countStart;
if (searchId !== _searchSeq) {
wasSuperseded = true;
return;
}
console.warn("Search COUNT(*) failed; keeping '50+' label:", err);
}
}
} catch(err) {
// Stale-search guard for the error path (Codex review of #236
// round 2). Without this, a search whose SELECT later rejects
// would overwrite a newer search's UI with "Search error: …".
// The freshness-token check matches the success-path guard
// above so both paths are coherent: a superseded search is
// recorded in the structured log (with `superseded: true` and
// the original error captured in `error`) but does NOT mutate
// user-visible state.
if (searchId !== _searchSeq) {
wasSuperseded = true;
errorMessage = err?.message || String(err);
console.warn(`Stale search (id=${searchId}) rejected after supersession; suppressing UI write.`, err);
} else {
console.error("Search failed:", err);
searchResults.textContent = `Search error: ${err.message}`;
errorMessage = err.message || String(err);
}
} finally {
performance.mark(markEnd);
try { performance.measure(`search-${searchId}`, markStart, markEnd); } catch (e) {}
const elapsedMs = performance.now() - tStart;
// Per-URL byte data from data.isamples.org during the search
// window. transferSize is 0 cross-origin without Timing-Allow-Origin;
// encodedBodySize is reported as a fallback. Per-URL detail (rather
// than just summed bytes) lets analysis post-hoc-filter concurrent
// fetches that are not actually attributable to the search.
const seenUrls = [];
let transferBytes = 0;
let bodyBytes = 0;
try {
const entries = performance.getEntriesByType('resource');
for (const e of entries) {
if (!e.name.startsWith(R2_BASE)) continue;
if (e.startTime < tStart || e.startTime > tStart + elapsedMs) continue;
seenUrls.push({
name: e.name,
transfer_size: e.transferSize || 0,
body_size: e.encodedBodySize || 0,
});
transferBytes += (e.transferSize || 0);
bodyBytes += (e.encodedBodySize || 0);
}
} catch (e) {}
// Structured log for Playwright capture (#167). `total_count`
// and `count_ms` (added #232): null/0 when the cap wasn't hit
// or when the COUNT follow-up failed/was superseded; otherwise
// the real result-set size and the time it took to compute.
// `elapsed_ms` continues to mark end-of-handler so it still
// covers SELECT+render+COUNT for end-to-end user-perceived
// latency.
//
// `superseded` (added round 2 of #236): a search whose work was
// abandoned mid-flight because the user fired a newer search.
// The log line still emits so downstream perf analysis can see
// how often this happens, but consumers should generally treat
// superseded rows as best-effort (results / counts may be
// partial and the UI never reflected them).
try {
console.log(JSON.stringify({
event: 'isamples.search',
id: searchId,
term: term,
terms_count: terms.length,
scope: effectiveScope,
results_count: resultsCount,
total_count: totalCount,
elapsed_ms: Math.round(elapsedMs),
count_ms: Math.round(countMs),
bytes_transfer: transferBytes,
bytes_body: bodyBytes,
seen_urls: seenUrls,
has_source_filter: hadSourceFilter,
has_facet_filter: hadFacetFilter,
superseded: wasSuperseded,
error: errorMessage,
}));
} catch (e) {}
// Append a row to the ?perf=1 panel if it's open. The panel
// renders once at boot from existing performance.measure entries
// (perfPanel cell, ~:2010); this hooks each subsequent search
// so the panel stays current per the #173 review.
try {
const panel = document.getElementById('perfPanel');
if (panel) {
const tbl = panel.querySelector('table');
if (tbl) {
const fmt = (ms) => ms >= 1000
? (ms / 1000).toFixed(2) + ' s'
: Math.round(ms) + ' ms';
const tr = document.createElement('tr');
const labelCell = document.createElement('td');
labelCell.style.cssText = 'padding:1px 8px 1px 0;color:#bbb;';
labelCell.textContent = `search #${searchId} ${effectiveScope}: "${term}" (${resultsCount})`;
const valCell = document.createElement('td');
valCell.style.cssText = 'padding:1px 0;text-align:right;color:#a5d6a7;font-variant-numeric:tabular-nums;';
valCell.textContent = fmt(elapsedMs);
tr.appendChild(labelCell);
tr.appendChild(valCell);
tbl.appendChild(tr);
}
}
} catch (e) {}
}
}
if (searchAreaBtn) searchAreaBtn.addEventListener('click', () => doSearch('area'));
if (searchWorldBtn) searchWorldBtn.addEventListener('click', () => doSearch('world'));
// Slim-overlay submit button — same behavior as Enter on `#sampleSearch`.
// Uses `_searchScope` so URL-hydrated `?search_scope=area` still routes
// to the area-scoped query path even though the scope toggle UI is
// hidden in the slim treatment.
const searchSubmitBtn = document.getElementById('searchSubmitBtn');
if (searchSubmitBtn) searchSubmitBtn.addEventListener('click', () => doSearch(_searchScope));
// Enter key uses the last-clicked scope (or the URL-hydrated scope if
// no button has been clicked yet). Defaults to 'world' for keyboard-only
// users on first invocation.
if (searchInput) searchInput.addEventListener('keydown', (e) => {
// Skip Enter during IME composition — IMEs send Enter to commit a
// candidate, and submitting then would fire on the pre-commit value.
if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) {
doSearch(_searchScope);
}
});
// Sidebar open-text search (M-1B). Mirrors #sampleSearch so there's one
// logical query term with two input chrome. Enter on sidebar always
// submits world scope — typed-text-from-sidebar implies "find anywhere".
// The mirror guards against feedback loops by comparing values before
// setting; setting .value does not fire 'input' so a simple guard suffices.
const searchInputSidebar = document.getElementById('sampleSearchSidebar');
if (searchInputSidebar && searchInput) {
searchInputSidebar.addEventListener('input', () => {
if (searchInput.value !== searchInputSidebar.value) {
searchInput.value = searchInputSidebar.value;
}
});
searchInput.addEventListener('input', () => {
if (searchInputSidebar.value !== searchInput.value) {
searchInputSidebar.value = searchInput.value;
}
});
}
if (searchInputSidebar) searchInputSidebar.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) {
doSearch('world');
}
});
if (searchInput && searchInput.value.trim().length >= 2) {
doSearch(_searchScope);
}
refreshFacetCounts();
// --- Deep-link: restore selection from initial hash ---
// Sample mode wins if both &pid= and &h3= are present (see EXPLORER_CLUSTER_URL_PROPOSAL §4).
// The boot path runs once, but the hashchange listener is already registered
// by this point — back/forward or a manual hash edit during the boot await
// could supersede this lookup. Capture the same freshness token the
// hashchange handler uses; bumping it here also invalidates any in-flight
// lookups.
// Wrap in try/finally so `_suppressHashWrite = false` always runs even if
// a stale early-return aborts the deep-link work — otherwise a no-lat/lng
// hashchange during boot could leave hash writes suppressed forever.
const isStale = freshSelectionToken(viewer);
const ih = viewer._initialHash;
try {
if (ih.pid) {
viewer._globeState.selectedPid = ih.pid;
viewer._globeState.selectedH3 = null;
try {
const sample = await db.query(`
SELECT pid, label, source, latitude, longitude, place_name, result_time
FROM read_parquet('${lite_url}')
WHERE pid = '${ih.pid.replace(/'/g, "''")}'
LIMIT 1
`);
if (isStale()) return "active";
if (sample && sample.length > 0) {
const s = sample[0];
updateSampleCard({
pid: s.pid, label: s.label, source: s.source,
lat: s.latitude, lng: s.longitude,
place_name: s.place_name, result_time: s.result_time
});
const detail = await db.query(`
SELECT description FROM read_parquet('${wide_url}')
WHERE pid = '${ih.pid.replace(/'/g, "''")}'
LIMIT 1
`);
if (isStale()) return "active";
if (detail && detail.length > 0) updateSampleDetail(detail[0]);
else updateSampleDetail({ description: '' });
}
} catch(err) {
console.error("Deep-link pid query failed:", err);
}
} else if (ih.h3) {
viewer._globeState.selectedH3 = ih.h3.toLowerCase();
const meta = await fetchClusterByH3(ih.h3);
if (isStale()) return "active";
if (meta) {
viewer._globeState.selectedH3 = meta.h3_cell; // canonical lowercase
await hydrateClusterUI(meta, isStale);
} else {
// Unknown / malformed h3, OR filtered out by ?sources=. Drop it
// from runtime state so buildHash() doesn't keep emitting it.
viewer._globeState.selectedH3 = null;
}
}
} finally {
// Enable hash writing now that everything is initialized — runs even on
// stale-abort early returns above.
viewer._suppressHashWrite = false;
}
// Deep-link mode hydration (issue #201 Bug B + issue #207 item 4).
// The boot path positions the camera via `camera.setView`, which in
// some cases (notably when the URL omits `heading` — i.e. heading
// defaults to 0 — at point altitudes) does not raise `camera.changed`,
// so the camera-changed handler never runs and the URL's `mode=point`
// is silently ignored. Drive the mode transition explicitly here so
// the boot is independent of whether `setView` happened to fire the
// event.
//
// Trigger on EITHER `ih.mode === 'point'` OR low altitude (< ENTER_POINT_ALT).
// The altitude branch closes the loophole introduced by #203's early
// URL write: a user copying mid-transition can produce a URL like
// `alt=8000` *without* `mode=point`, which previously round-tripped
// as cluster mode at low altitude. Now boot enters point mode for
// any URL whose altitude says it should. `tryEnterPointModeIfNeeded()`
// short-circuits if alt >= ENTER_POINT_ALT or we're already in point
// mode, so this is a no-op for cluster deep-links at cluster altitude.
if (ih.mode === 'point' || (ih.alt != null && ih.alt < ENTER_POINT_ALT)) {
// pushHistory: false — boot should reconcile state without adding
// a history entry (issue #207 item 3).
await tryEnterPointModeIfNeeded({ pushHistory: false });
}
// #233 phase 1: hydrate heatmap overlay from `heatmap=1` URL param.
// Reported by RY 2026-05-27 on PR #240 staging — toggle state was
// missing from "Copy Link to Current View." `refreshHeatmap()` lives
// in a different OJS cell and isn't reachable from here; dispatch a
// 'change' event so the existing change-listener picks it up.
if (ih.heatmap) {
const toggle = document.getElementById('heatmapToggle');
if (toggle && !toggle.checked) {
toggle.checked = true;
toggle.dispatchEvent(new Event('change', { bubbles: true }));
}
}
return "active";
}// === Performance timing panel (opt-in: append ?perf=1 to URL) ===
// v0: reads performance.mark/measure entries and renders a small fixed panel.
// Reports navigation→duckdb_init, navigation→viewer_init, phase 1 res4 load,
// and navigation→first paint. Also dumps to console.table for CI / Playwright.
perfPanel = {
if (!phase1) return; // wait for phase 1 to have run
const params = new URLSearchParams(location.search);
const isOn = params.get('perf') === '1';
if (!isOn) return;
// Give first-paint a tick to fire, then collect
await new Promise(r => setTimeout(r, 100));
const origin = performance.timeOrigin;
const mark = (name) => {
const e = performance.getEntriesByName(name, 'mark').pop();
return e ? e.startTime : null;
};
const measure = (name) => {
const e = performance.getEntriesByName(name, 'measure').pop();
return e ? e.duration : null;
};
// Paint timings from the browser (free, no instrumentation needed)
const paintEntries = performance.getEntriesByType('paint');
const fcp = paintEntries.find(e => e.name === 'first-contentful-paint')?.startTime;
const fp = paintEntries.find(e => e.name === 'first-paint')?.startTime;
const rows = [
['first-paint (browser)', fp],
['first-contentful-paint', fcp],
['duckdb init', measure('duckdb_init')],
['viewer init', measure('viewer_init')],
['nav → viewer ready', mark('viewer-init-end')],
['phase 1 res4 (duration)', measure('p1')],
['nav → phase 1 complete', mark('p1-end')],
['nav → first globe frame', mark('first-globe-frame')],
].filter(([, v]) => v != null);
// Append search timings if any have run by the time the panel renders
// (#167). Each search emits a structured console.log; the panel surface
// is purely informational here.
const searchMeasures = performance.getEntriesByType('measure')
.filter(e => e.name.startsWith('search-'));
for (const m of searchMeasures) {
rows.push([`search ${m.name.replace(/^search-/, '#')}`, m.duration]);
}
// Console table for CI / offline capture
console.table(Object.fromEntries(rows.map(([k, v]) => [k, `${v.toFixed(0)} ms`])));
// Render a small floating panel
const fmt = (ms) => ms == null ? '—' : ms >= 1000 ? `${(ms/1000).toFixed(2)} s` : `${ms.toFixed(0)} ms`;
const panel = document.createElement('div');
panel.id = 'perfPanel';
panel.style.cssText = `
position: fixed; bottom: 12px; right: 12px; z-index: 9999;
background: rgba(0,0,0,0.82); color: #e8f5e9; padding: 10px 12px;
border-radius: 6px; font: 11px/1.4 ui-monospace, SFMono-Regular, monospace;
max-width: 320px; box-shadow: 0 2px 12px rgba(0,0,0,0.3);
`;
panel.innerHTML = `
<div style="font-weight:600;color:#fff;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center;">
<span>⏱ Perf timings</span>
<button id="perfClose" style="background:none;border:none;color:#aaa;cursor:pointer;font-size:14px;padding:0 4px;">×</button>
</div>
<table style="border-collapse:collapse;width:100%;">
${rows.map(([label, v]) => `
<tr><td style="padding:1px 8px 1px 0;color:#bbb;">${label}</td>
<td style="padding:1px 0;text-align:right;color:#a5d6a7;font-variant-numeric:tabular-nums;">${fmt(v)}</td></tr>
`).join('')}
</table>
<div style="margin-top:6px;color:#777;font-size:10px;">timeOrigin: ${new Date(origin).toISOString().split('T')[1].slice(0,12)}</div>
`;
document.body.appendChild(panel);
panel.querySelector('#perfClose').onclick = () => panel.remove();
return "shown";
}1 How This Demo Works
Pre-aggregated H3 hexagonal indices achieve near-instant globe rendering, with seamless drill-down to individual samples:
| Phase | Data | Size | Points |
|---|---|---|---|
| Instant | H3 res4 | 580 KB | 38K clusters (continental) |
| Zoom in | H3 res6 | 1.6 MB | 112K clusters (city) |
| Zoom more | H3 res8 | 2.5 MB | 176K clusters (neighborhood) |
| Zoom deep | Map lite | 60 MB (range req.) | Up to 5K individual samples |
| Click sample | Full dataset | ~280 MB (range req.) | Full metadata for 1 sample |
4 parquet files, zero backend. All queries run in your browser via DuckDB-WASM with HTTP range requests — only the bytes you need are transferred.
2 See Also
- Deep-Dive Analysis — DuckDB-WASM SQL tutorial
- Tutorials — index of all interactive tutorials