iSamples Interactive Explorer
Search, filter, and explore 6.7 million material samples
- 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.
Material ▾
Loading…
Sampled Feature ▾
Loading…
Specimen Type ▾
Loading…
Material / feature / specimen filters apply at sample zoom level
Link copied!
Loading H3 global overview…
0.1 Results
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 cache for fast cross-filter count lookups in the
// single-facet-value-active case. Multi-filter combinations fall back to
// on-the-fly GROUP BY queries against facets_url. See issue #154.
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();
// Empty == all (no filter), matching the semantics of the other facet
// groups (material / context / object_type) and Raymond's stated
// intuition (PR #155 thread). All-4 checked is also "all" by virtue
// of the IN list covering everything; we short-circuit to skip an
// unnecessary IN clause.
if (active.length === 0 || active.length === 4) return '';
const list = active.map(s => `'${s}'`).join(',');
return ` AND ${col} IN (${list})`;
}
// === Material/Context Filters ===
function getCheckedValues(containerId) {
const checks = document.querySelectorAll(`#${containerId} input[type="checkbox"]`);
return Array.from(checks).filter(c => c.checked).map(c => c.value);
}
// Semantics (matches the prior explorer): empty = no filter, show all.
// Selecting one or more items = include only those. Reduces visual noise
// at startup (you don't see hundreds of pre-checked rows).
function hasFacetFilters() {
return getCheckedValues('materialFilterBody').length > 0
|| getCheckedValues('contextFilterBody').length > 0
|| getCheckedValues('objectTypeFilterBody').length > 0;
}
function facetFilterSQL() {
let sql = '';
const mat = getCheckedValues('materialFilterBody');
if (mat.length > 0) {
const list = mat.map(s => `'${s.replace(/'/g, "''")}'`).join(',');
sql += ` AND f.material IN (${list})`;
}
const ctx = getCheckedValues('contextFilterBody');
if (ctx.length > 0) {
const list = ctx.map(s => `'${s.replace(/'/g, "''")}'`).join(',');
sql += ` AND f.context IN (${list})`;
}
const ot = getCheckedValues('objectTypeFilterBody');
if (ot.length > 0) {
const list = ot.map(s => `'${s.replace(/'/g, "''")}'`).join(',');
sql += ` AND f.object_type IN (${list})`;
}
return sql;
}
// === 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,
};
}
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');
if (gs.selectedPid) params.set('pid', gs.selectedPid);
return '#' + params.toString();
}
// === 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>` : ''}`;
}
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;
}
// === Results table (below globe) ===
// Renders the first N samples matching current filters, regardless of camera.
// Stable, sourceless ordering (no ORDER BY RANDOM) keeps it cheap on lite_url.
function renderResultsTable(rows) {
const el = document.getElementById('resultsTable');
if (!el) return;
if (!rows || rows.length === 0) {
el.innerHTML = '<div class="results-empty">No samples match the current filters.</div>';
return;
}
const head = `<thead><tr><th>Source</th><th>Label</th><th>Place</th><th>Lat</th><th>Lon</th></tr></thead>`;
const body = rows.map(r => {
const color = SOURCE_COLORS[r.source] || '#666';
const name = SOURCE_NAMES[r.source] || r.source;
const placeParts = r.place_name;
const place = Array.isArray(placeParts) && placeParts.length > 0
? placeParts.filter(Boolean).join(' › ')
: '';
const lat = (r.latitude != null) ? r.latitude.toFixed(4) : '';
const lng = (r.longitude != null) ? r.longitude.toFixed(4) : '';
const sUrl = sourceUrl(r.pid);
const labelHtml = sUrl
? `<a class="row-link" href="${sUrl}" target="_blank" rel="noopener noreferrer">${r.label || r.pid}</a>`
: (r.label || r.pid);
return `<tr data-pid="${r.pid}" data-lat="${r.latitude}" data-lng="${r.longitude}">
<td><span class="row-badge" style="background:${color}">${name}</span></td>
<td>${labelHtml}</td>
<td>${place}</td>
<td>${lat}</td>
<td>${lng}</td>
</tr>`;
}).join('');
el.innerHTML = `<table class="results-table">${head}<tbody>${body}</tbody></table>`;
}
function updateResultsTableMeta(text, isLoading) {
const el = document.getElementById('resultsTableMeta');
if (!el) return;
el.textContent = text;
el.style.color = isLoading ? '#1565c0' : '#555';
}
// === Cross-filter facet count updates (issue #154) ===
// Updates the count span next to each checkbox for one facet group. If
// `countsMap` is null, falls back to baseline counts on `viewer._baselineCounts`
// (set in facetFilters). Dims rows where count === 0 so the user sees that
// their selection eliminated those values rather than silently hiding them.
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'));
}// === Cesium Viewer (created once, never re-created) ===
viewer = {
performance.mark('viewer-init-start');
const v = new Cesium.Viewer("cesiumContainer", {
timeline: false,
animation: false,
baseLayerPicker: false,
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 };
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 });
}
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
new Cesium.ScreenSpaceEventHandler(v.scene.canvas).setInputAction(async (e) => {
const picked = v.scene.pick(e.position);
if (!Cesium.defined(picked) || !picked.primitive || !picked.id) return;
const meta = picked.id;
if (typeof meta === 'object' && meta.type === 'sample') {
// --- Individual sample click ---
updateSampleCard(meta);
v._globeState.selectedPid = meta.pid;
history.pushState(null, '', buildHash(v));
// Clear nearby list
const sampEl = document.getElementById('samplesSection');
if (sampEl) sampEl.innerHTML = '';
// Stage 2: lazy-load full description from wide parquet
try {
const detail = await db.query(`
SELECT description
FROM read_parquet('${wide_url}')
WHERE pid = '${meta.pid.replace(/'/g, "''")}'
LIMIT 1
`);
if (detail && detail.length > 0) {
updateSampleDetail(detail[0]);
} else {
updateSampleDetail({ description: '' });
}
} catch(err) {
console.error("Detail query failed:", err);
updateSampleDetail(null);
}
} else if (typeof meta === 'object' && meta.count) {
// --- Cluster click ---
updateClusterCard(meta);
v._globeState.selectedPid = 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 {
const facetActive = hasFacetFilters();
const facetSQL = facetActive ? facetFilterSQL() : '';
let nearbyQuery;
if (facetActive) {
nearbyQuery = `
SELECT l.pid, l.label, l.source, l.latitude, l.longitude, l.place_name
FROM read_parquet('${lite_url}') l
JOIN read_parquet('${facets_url}') f ON l.pid = f.pid
WHERE l.latitude BETWEEN ${meta.lat - delta} AND ${meta.lat + delta}
AND l.longitude BETWEEN ${meta.lng - delta} AND ${meta.lng + delta}
${sourceFilterSQL('l.source')}
${facetSQL}
LIMIT 30
`;
} else {
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')}
LIMIT 30
`;
}
const samples = await db.query(nearbyQuery);
updateSamples(samples);
} catch(err) {
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');
// Read ?sources=A,B from query string and pre-check the matching legend
// checkboxes BEFORE running the cluster query (so phase 1 honors the
// bookmarkable filter). Camera state still lives in the hash.
{
const initialSources = (new URLSearchParams(location.search).get('sources') || '')
.split(',').map(s => s.trim()).filter(Boolean);
if (initialSources.length > 0) {
document.querySelectorAll('#sourceFilter input[type="checkbox"]').forEach(cb => {
cb.checked = initialSources.includes(cb.value);
});
document.querySelectorAll('#sourceFilter .legend-item').forEach(li => {
const cb = li.querySelector('input');
li.classList.toggle('disabled', !cb.checked);
});
}
}
const data = await db.query(`
SELECT h3_cell, 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);
viewer.h3Points.add({
id: { 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),
scaleByDistance: scalar,
});
}
// 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 semantics: start UNCHECKED (no filter; show everything). User
// checks items to *include only those*. Empty = no filter. This matches the
// prior explorer's UX and keeps the side panel compact at startup.
//
// 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.
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
});
}
}
// Stash baseline counts on `viewer` so refreshFacetCounts() can
// restore them when filters return to the no-active-filters state.
// Schema: viewer._baselineCounts[facetKey] = Map(uri → 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])),
};
// Paint baseline counts now so the source legend and (after this
// function's renderFilter calls below) the facet rows show numbers
// immediately, before any user interaction debounces refresh.
// Wrapped in setTimeout(0) so the renderFilter calls below land first.
setTimeout(() => {
applyFacetCounts('source', null);
applyFacetCounts('material', null);
applyFacetCounts('context', null);
applyFacetCounts('object_type', null);
}, 0);
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` count spans so
// refreshFacetCounts() can update text in place without rebuilding
// the HTML (which would lose mid-interaction selections). See #154.
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);
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";
}// === Zoom watcher: H3 cluster mode + individual sample point mode ===
zoomWatcher = {
if (!phase1) return;
if (!facetFilters) return; // wait for facet checkboxes
// --- State ---
let mode = 'cluster'; // 'cluster' or 'point'
let currentRes = 4;
let loading = false;
let requestId = 0; // stale-request guard
// clusterDataCache stored on viewer._clusterData (set by phase1 and loadRes)
// 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
// Sample budget is driven by the Max Samples slider; reads at query time.
const getMaxSamples = () => {
const el = document.getElementById('maxSamples');
const v = el ? parseInt(el.value, 10) : NaN;
return Number.isFinite(v) && v > 0 ? v : 5000;
};
// Viewport cache: avoid re-querying same area
let cachedBounds = null; // { south, north, west, east }
let cachedData = null; // array of rows
// --- H3 cluster loading (existing logic) ---
let loadResGen = 0; // generation counter to discard stale results
const loadRes = async (res, url) => {
const gen = ++loadResGen; // claim a generation
loading = true;
updatePhaseMsg(`Loading H3 res${res}...`, 'loading');
try {
performance.mark(`r${res}-s`);
const data = await db.query(`
SELECT h3_cell, sample_count, center_lat, center_lng,
dominant_source, source_count
FROM read_parquet('${url}')
WHERE 1=1${sourceFilterSQL('dominant_source')}
`);
if (gen !== loadResGen) return; // 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);
viewer.h3Points.add({
id: { 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),
scaleByDistance: scalar,
});
}
// 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
const bounds = getViewportBounds();
const inView = countInViewport(bounds);
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');
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`);
} catch(err) {
console.error(`Failed to load res${res}:`, err);
updatePhaseMsg(`Failed to load H3 res${res} — try zooming again.`, 'loading');
} finally {
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 };
}
// --- Check if viewport is within cached bounds ---
function isWithinCache(bounds) {
if (!cachedBounds || !bounds) return false;
return bounds.south >= cachedBounds.south &&
bounds.north <= cachedBounds.north &&
bounds.west >= cachedBounds.west &&
bounds.east <= cachedBounds.east;
}
// --- Load individual samples for current viewport ---
async function loadViewportSamples() {
const myReqId = ++requestId;
const bounds = getViewportBounds();
if (!bounds) return;
// If viewport is within cached area, just re-render from cache
if (isWithinCache(bounds) && cachedData) {
renderSamplePoints(cachedData, bounds);
return;
}
// Fetch with 30% padding for smooth panning
const latPad = (bounds.north - bounds.south) * 0.3;
const lngPad = (bounds.east - bounds.west) * 0.3;
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');
const facetActive = hasFacetFilters();
const facetSQL = facetActive ? facetFilterSQL() : '';
let query;
if (facetActive) {
query = `
SELECT l.pid, l.label, l.source, l.latitude, l.longitude,
l.place_name, l.result_time, f.material, f.context
FROM read_parquet('${lite_url}') l
JOIN read_parquet('${facets_url}') f ON l.pid = f.pid
WHERE l.latitude BETWEEN ${padded.south} AND ${padded.north}
AND l.longitude BETWEEN ${padded.west} AND ${padded.east}
${sourceFilterSQL('l.source')}
${facetSQL}
LIMIT ${getMaxSamples()}
`;
} else {
query = `
SELECT pid, label, source, latitude, longitude,
place_name, result_time
FROM read_parquet('${lite_url}')
WHERE latitude BETWEEN ${padded.south} AND ${padded.north}
AND longitude BETWEEN ${padded.west} AND ${padded.east}
${sourceFilterSQL('source')}
LIMIT ${getMaxSamples()}
`;
}
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;
}
// Cache the padded bounds + data
cachedBounds = padded;
cachedData = Array.from(data);
renderSamplePoints(cachedData, bounds);
updateStats('Samples', cachedData.length, cachedData.length, `${(elapsed/1000).toFixed(1)}s`, 'Samples in View', 'Samples in View');
updatePhaseMsg(`${cachedData.length.toLocaleString()} individual samples. Click one for details.`, 'done');
console.log(`Point mode: ${cachedData.length} samples in ${elapsed.toFixed(0)}ms`);
} 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),
scaleByDistance: scalar,
});
}
}
// --- Mode transitions ---
function enterPointMode(pushHistory) {
mode = 'point';
viewer._globeState.mode = 'point';
viewer.h3Points.show = false;
viewer.samplePoints.show = true;
if (pushHistory !== false) history.pushState(null, '', buildHash(viewer));
loadViewportSamples();
console.log('Entered point mode');
}
function exitPointMode(pushHistory) {
mode = 'cluster';
viewer._globeState.mode = 'cluster';
viewer.samplePoints.show = false;
viewer.samplePoints.removeAll();
viewer.h3Points.show = true;
if (pushHistory !== false) history.pushState(null, '', buildHash(viewer));
cachedBounds = null;
cachedData = null;
// Restore cluster stats with viewport count
const bounds = getViewportBounds();
const inView = countInViewport(bounds);
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');
}
// --- Results table refresh (below globe) ---
// Independent of globe mode — always shows up to maxSamples rows matching
// filters. Cap rendered rows at TABLE_RENDER_CAP to keep DOM tractable
// (the slider can go up to 100K which is fine for the globe but jank for
// a flat HTML table).
const TABLE_RENDER_CAP = 200;
let tableReqId = 0;
async function refreshResultsTable() {
const myReq = ++tableReqId;
const limit = getMaxSamples();
const renderLimit = Math.min(limit, TABLE_RENDER_CAP);
updateResultsTableMeta('Loading samples matching filters…', true);
try {
const facetActive = hasFacetFilters();
const facetSQL = facetActive ? facetFilterSQL() : '';
let query;
if (facetActive) {
query = `
SELECT l.pid, l.label, l.source, l.latitude, l.longitude, l.place_name
FROM read_parquet('${lite_url}') l
JOIN read_parquet('${facets_url}') f ON l.pid = f.pid
WHERE 1=1
${sourceFilterSQL('l.source')}
${facetSQL}
LIMIT ${limit}
`;
} else {
query = `
SELECT pid, label, source, latitude, longitude, place_name
FROM read_parquet('${lite_url}')
WHERE 1=1
${sourceFilterSQL('source')}
LIMIT ${limit}
`;
}
const data = await db.query(query);
if (myReq !== tableReqId) return; // stale
const visible = data.slice(0, renderLimit);
renderResultsTable(visible);
let note;
if (data.length === limit) {
note = `Showing first ${visible.length.toLocaleString()} of ${limit.toLocaleString()}+ samples matching filters.`;
} else if (visible.length < data.length) {
note = `Showing first ${visible.length.toLocaleString()} of ${data.length.toLocaleString()} samples matching filters.`;
} else {
note = `Showing ${data.length.toLocaleString()} samples matching filters.`;
}
updateResultsTableMeta(note, false);
} catch (err) {
if (myReq !== tableReqId) return;
console.error('Results table query failed:', err);
updateResultsTableMeta('Failed to load results table.', false);
}
}
// === Cross-filter facet count refresh (issue #154) ===
//
// Strategy:
// - No filters active → restore baseline counts (no query).
// - Exactly one facet value → cache lookup against cross_filter_url.
// - Anything else → on-the-fly group-by on facets_url, four
// concurrent queries (one per target facet),
// each excluding the column being recomputed.
//
// Debounced ~250 ms with a generation guard so rapid clicking only
// resolves the latest selection. Source counts are sample-level
// (not H3 dominant_source counts).
let facetCountsReqId = 0;
let facetCountsDebounce = null;
function describeActiveFilters() {
const sources = getActiveSources();
const allSourcesChecked = sources.length === 4;
const mat = getCheckedValues('materialFilterBody');
const ctx = getCheckedValues('contextFilterBody');
const ot = getCheckedValues('objectTypeFilterBody');
const dims = [
{ key: 'source', col: 'source', values: allSourcesChecked ? [] : 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 };
}
function buildExcludeWhere(activeDims, excludeKey) {
const conds = activeDims
.filter(d => d.key !== excludeKey)
.map(d => {
const list = d.values.map(v => `'${String(v).replace(/'/g, "''")}'`).join(',');
return `${d.col} IN (${list})`;
});
return conds.length > 0 ? conds.join(' AND ') : '1=1';
}
async function refreshFacetCountsNow(myReq) {
// Stale check up front: if a newer schedule has happened during the
// debounce window, drop this run before doing any work.
if (myReq !== facetCountsReqId) return;
const { dims, activeDims, totalActiveValues } = describeActiveFilters();
// Case 1: no filters → restore baseline.
if (activeDims.length === 0) {
for (const d of dims) applyFacetCounts(d.key, null);
return;
}
markFacetCountsRecomputing();
// Case 2: single-filter cache. The pre-aggregated parquet has a
// (filter_source, filter_material, filter_context, filter_object_type)
// schema where exactly one is non-null per row. Use it only when
// exactly one facet value is active across all dims (the cache shape).
const singleActiveDim = activeDims.length === 1 && activeDims[0].values.length === 1
? activeDims[0] : null;
if (singleActiveDim) {
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 = String(singleActiveDim.values[0]).replace(/'/g, "''");
const whereParts = filterCols.map(c =>
c === targetCol ? `${c} = '${value}'` : `${c} IS NULL`
);
const sql = `
SELECT facet_type, facet_value, count
FROM read_parquet('${cross_filter_url}')
WHERE ${whereParts.join(' AND ')}
`;
const rows = await db.query(sql);
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) {
if (d.key === singleActiveDim.key) {
// Active dim with no OTHER filters → recomputing
// its own values under "no other dim filters" =
// baseline. Cache also doesn't carry rows for the
// filter-dim's own facet_type (verified empirically).
applyFacetCounts(d.key, null);
} else {
applyFacetCounts(d.key, grouped[d.key]);
}
}
return;
}
// empty → fall through to on-the-fly
} catch (err) {
console.warn('Cross-filter cache lookup failed; falling back to on-the-fly:', err);
}
}
// Case 3: on-the-fly — four GROUP BY queries against facets_url, each
// excluding the dim being recomputed. Per semantics (A) (issue #154
// / PR #155 thread): for *every* dim D, including dims with active
// selections, count(value=V) = `WHERE V AND <other-dim filters>`.
// This makes each value's count answer "how many samples have THIS
// value under my other filters", regardless of which values within
// this dim are currently selected.
const queries = dims.map(async (d) => {
const where = buildExcludeWhere(activeDims, d.key);
const sql = `
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}
`;
try {
const rows = await db.query(sql);
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 on-the-fly failed for ${d.key}:`, err);
applyFacetCounts(d.key, null); // give up on this dim, leave baseline
}
});
await Promise.all(queries);
}
function refreshFacetCounts() {
// Bump the generation synchronously so any in-flight queries
// (whose `myReq` is now strictly less) are invalidated immediately
// — without this, a stale query could finish during the debounce
// window of the new request and pass the gen check, repainting
// stale counts (Codex review on PR #155).
clearTimeout(facetCountsDebounce);
const myReq = ++facetCountsReqId;
facetCountsDebounce = setTimeout(() => {
refreshFacetCountsNow(myReq);
}, 250);
}
// --- Source filter change handler ---
const resUrls = { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url };
document.getElementById('sourceFilter').addEventListener('change', async () => {
// Toggle visual state on labels. Per the new source semantics
// (0 == all == 4), don't dim any row when the filter isn't actually
// narrowing the result set; only dim unchecked rows when 1–3 are
// checked. Without this, unchecking all 4 made every label appear
// disabled even though semantically all sources are active.
const active = getActiveSources();
const filterIsActive = active.length > 0 && active.length < 4;
document.querySelectorAll('#sourceFilter .legend-item').forEach(li => {
const cb = li.querySelector('input');
li.classList.toggle('disabled', filterIsActive && !cb.checked);
});
// Persist source filter in URL query string for bookmarkable links
// (e.g. ?sources=OPENCONTEXT). Camera state lives in the hash.
const params = new URLSearchParams(location.search);
if (filterIsActive) {
params.set('sources', active.join(','));
} else {
params.delete('sources');
}
const qs = params.toString();
const newSearch = qs ? `?${qs}` : '';
if (location.search !== newSearch) {
history.replaceState(null, '', `${location.pathname}${newSearch}${location.hash}`);
}
if (mode === 'cluster') {
loading = false; // allow loadRes to run (gen counter discards stale results)
await loadRes(currentRes, resUrls[currentRes]);
} else {
cachedBounds = null; // force re-query
await loadViewportSamples();
}
refreshResultsTable();
refreshFacetCounts();
});
// --- Material/Context/Specimen filter change handler ---
const facetNote = document.getElementById('facetNote');
function handleFacetFilterChange() {
const active = hasFacetFilters();
if (facetNote) facetNote.style.display = (active && mode === 'cluster') ? 'block' : 'none';
if (mode === 'point') {
cachedBounds = null;
loadViewportSamples();
}
refreshResultsTable();
refreshFacetCounts();
}
document.getElementById('materialFilterBody').addEventListener('change', handleFacetFilterChange);
document.getElementById('contextFilterBody').addEventListener('change', handleFacetFilterChange);
document.getElementById('objectTypeFilterBody').addEventListener('change', handleFacetFilterChange);
// --- Max Samples slider ---
const maxSamplesEl = document.getElementById('maxSamples');
const maxSamplesValueEl = document.getElementById('maxSamplesValue');
if (maxSamplesEl && maxSamplesValueEl) {
// Live label while dragging, debounced query on release.
let sliderDebounce = null;
maxSamplesEl.addEventListener('input', () => {
const v = parseInt(maxSamplesEl.value, 10);
maxSamplesValueEl.textContent = Number(v).toLocaleString();
clearTimeout(sliderDebounce);
sliderDebounce = setTimeout(() => {
if (mode === 'point') {
cachedBounds = null;
loadViewportSamples();
}
refreshResultsTable();
}, 300);
});
}
// --- Clear Filters button ---
const clearBtn = document.getElementById('clearFiltersBtn');
if (clearBtn) {
clearBtn.addEventListener('click', () => {
// Strip query string and hash, reload to a clean state. Simplest
// and matches the prior explorer's "<a href='?'>" behavior.
location.href = location.pathname;
});
}
// Initial table load + initial facet counts (paints baseline counts +
// applies any dimming if a `?sources=` URL param has narrowed the source set).
refreshResultsTable();
refreshFacetCounts();
// --- Camera change handler ---
let timer = null;
viewer.camera.changed.addEventListener(() => {
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
const h = viewer.camera.positionCartographic.height;
// Determine target mode with hysteresis
const targetMode = h < ENTER_POINT_ALT ? 'point'
: h > EXIT_POINT_ALT ? 'cluster'
: mode;
if (targetMode === 'point' && mode !== 'point') {
// Make sure we're at res8 clusters before transitioning
if (currentRes !== 8 && !loading) {
await loadRes(8, h3_res8_url);
}
enterPointMode();
} else if (targetMode === 'cluster' && mode !== 'cluster') {
exitPointMode();
// Reload appropriate resolution
const target = h > 3000000 ? 4 : h > 300000 ? 6 : 8;
if (target !== currentRes && !loading) {
await loadRes(target, { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }[target]);
}
} else if (targetMode === 'point') {
// Already in point mode — update viewport samples
loadViewportSamples();
} else {
// Cluster mode — check if resolution should change
const target = h > 3000000 ? 4 : h > 300000 ? 6 : 8;
if (target !== currentRes && !loading) {
await loadRes(target, { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }[target]);
}
}
// Update viewport cluster count (cluster mode only; point mode already shows viewport count)
if (mode === 'cluster' && viewer._clusterData) {
const bounds = getViewportBounds();
const inView = countInViewport(bounds);
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;
// --- Handle browser back/forward ---
window.addEventListener('hashchange', async () => {
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
viewer._suppressTimer = setTimeout(() => {
viewer._suppressHashWrite = false;
const s = readHash();
if (s.mode === 'point' && mode !== 'point') enterPointMode(false);
else if (s.mode !== 'point' && mode === 'point') exitPointMode(false);
}, 2000);
// Handle pid selection
if (state.pid) {
viewer._globeState.selectedPid = state.pid;
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 (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 {
viewer._globeState.selectedPid = null;
updateClusterCard(null);
}
});
// --- 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 searchBtn = document.getElementById('searchBtn');
const searchInput = document.getElementById('sampleSearch');
const searchResults = document.getElementById('searchResults');
async function doSearch() {
const term = searchInput.value.trim();
if (!term || term.length < 2) {
searchResults.textContent = 'Type at least 2 characters';
return;
}
searchResults.textContent = 'Searching...';
try {
const escaped = term.replace(/'/g, "''");
// Compose with facet filters so search honors the same Material /
// Sampled Feature / Specimen Type selections that the table and
// point-mode globe use. Without this, search would surface (and
// fly to) samples outside the active filters.
const facetActive = hasFacetFilters();
const facetSQL = facetActive ? facetFilterSQL() : '';
const query = facetActive ? `
SELECT l.pid, l.label, l.source, l.latitude, l.longitude, l.place_name
FROM read_parquet('${lite_url}') l
JOIN read_parquet('${facets_url}') f ON l.pid = f.pid
WHERE l.label ILIKE '%${escaped}%'
${sourceFilterSQL('l.source')}
${facetSQL}
LIMIT 50
` : `
SELECT pid, label, source, latitude, longitude, place_name
FROM read_parquet('${lite_url}')
WHERE label ILIKE '%${escaped}%'
${sourceFilterSQL('source')}
LIMIT 50
`;
const results = await db.query(query);
if (results.length === 0) {
searchResults.textContent = `No results for "${term}"`;
return;
}
searchResults.textContent = `${results.length}${results.length === 50 ? '+' : ''} 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 → fly to it
sampEl.querySelectorAll('.sample-row[data-lat]').forEach(row => {
row.addEventListener('click', (e) => {
if (e.target.tagName === 'A') return; // let links work
const lat = parseFloat(row.dataset.lat);
const lng = parseFloat(row.dataset.lng);
const pid = row.dataset.pid;
if (!isNaN(lat) && !isNaN(lng)) {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lng, lat, 50000),
duration: 1.5
});
}
});
});
}
// Fly to the first result
if (results[0].latitude && results[0].longitude) {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(results[0].longitude, results[0].latitude, 200000),
duration: 1.5
});
}
} catch(err) {
console.error("Search failed:", err);
searchResults.textContent = `Search error: ${err.message}`;
}
}
if (searchBtn) searchBtn.addEventListener('click', doSearch);
if (searchInput) searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doSearch();
});
// --- Deep-link: restore selection from initial hash ---
const ih = viewer._initialHash;
if (ih.pid) {
viewer._globeState.selectedPid = ih.pid;
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 (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 (detail && detail.length > 0) updateSampleDetail(detail[0]);
else updateSampleDetail({ description: '' });
}
} catch(err) {
console.error("Deep-link pid query failed:", err);
}
}
// Enable hash writing now that everything is initialized
viewer._suppressHashWrite = false;
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);
// 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 It Works
The globe loads progressively: a 580 KB pre-aggregated H3 res4 file paints the world in clusters within ~1 s, finer-grained res6 / res8 files swap in as you zoom, and only when you zoom past 120 km altitude does the explorer switch to individual sample points (capped at the Max Samples slider, viewport-bounded). The results table below the globe queries the 60 MB lite parquet for samples matching your filters — independent of the camera. Filters compose at sample zoom (and in the results table and search): source + material + sampled feature + specimen type all apply via a join on the facets parquet. At cluster zoom, the H3 tier files only carry one row per (h3_cell, dominant_source), so source filtering matches on each cell’s dominant source — two consequences worth knowing: (1) a cell whose dominant source is OpenContext disappears entirely when you uncheck OpenContext, even if some of its samples are from sources that remain checked; (2) a cell whose dominant source is OpenContext stays visible with its full sample_count when OpenContext is checked, even though some samples in it may be from sources you unchecked. Material / sampled feature / specimen filters don’t apply at cluster zoom at all (the tier files aren’t pre-filtered by those facets) — they kick in once you zoom into sample mode, in the results table, and in search.
| 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 Max Samples slider value (default 25K, max 100K) individual samples in viewport |
| Click sample | Full dataset | ~280 MB (range req.) | Full metadata for 1 sample |
All queries run in your browser via DuckDB-WASM with HTTP range requests — only the bytes you need are transferred. No backend.
2 See Also
- Progressive Globe — Same globe rendering without the faceted filter UI
- Deep-Dive Analysis — DuckDB-WASM SQL tutorial
- Why H3? — Why hexagonal hierarchical spatial indexing is the right substrate