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.
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
Table view loads 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
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');
if (!input) return;
const params = new URLSearchParams(location.search);
const q = params.get('search');
if (q != null) input.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);
});
if (typeof document !== 'undefined' && document.body && document.body.classList.contains('table-view-active')) {
params.set('view', 'table');
} else {
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;
}
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 ')})`;
}
// === 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,
};
}
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;
}
// === Binary Globe/Table view ===
TABLE_PAGE_SIZE = 100
TABLE_DEFAULT_MAX = 25000
TABLE_MIN_MAX = 1000
TABLE_MAX_MAX = 100000
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function clampTableMaxSamples(value) {
const n = parseInt(value, 10);
if (!Number.isFinite(n)) return TABLE_DEFAULT_MAX;
return Math.min(TABLE_MAX_MAX, Math.max(TABLE_MIN_MAX, n));
}
function getTableMaxSamples() {
const el = document.getElementById('maxSamples');
const value = clampTableMaxSamples(el ? el.value : TABLE_DEFAULT_MAX);
if (el && String(value) !== String(el.value)) el.value = value;
return value;
}
function isTableViewActive() {
return document.body.classList.contains('table-view-active');
}// === 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 {
// 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);
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');
applyQueryToSearch();
applyQueryToSourceFilter();
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 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();
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: paginated sample rows matching current filters ===
tableView = {
if (!facetFilters) return;
let rows = [];
let page = 0;
let requestId = 0;
let loadedMax = 0;
let hitHardCap = false;
let tableDirty = true;
const globeLayout = document.querySelector('.globe-layout');
const tableContainer = document.getElementById('tableContainer');
const tableControls = document.getElementById('tableControls');
const globeBtn = document.getElementById('globeViewBtn');
const tableBtn = document.getElementById('tableViewBtn');
const maxInput = document.getElementById('maxSamples');
const prevBtn = document.getElementById('tablePrev');
const nextBtn = document.getElementById('tableNext');
const metaEl = document.getElementById('tableMeta');
const pageInfoEl = document.getElementById('tablePageInfo');
const tableEl = document.getElementById('samplesTable');
function setMeta(text, loading) {
if (!metaEl) return;
metaEl.textContent = text;
metaEl.style.color = loading ? '#1565c0' : '#555';
}
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 totalPages = Math.max(1, Math.ceil(rows.length / TABLE_PAGE_SIZE));
page = Math.min(page, totalPages - 1);
const start = page * TABLE_PAGE_SIZE;
const visible = rows.slice(start, start + TABLE_PAGE_SIZE);
if (!tableEl) return;
if (visible.length === 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 {
const body = visible.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 || '';
const url = sourceUrl(r.pid);
const labelHtml = url
? `<a class="table-link" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`
: escapeHtml(label);
return `<tr>
<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>`;
}
if (pageInfoEl) {
const first = rows.length === 0 ? 0 : start + 1;
const last = Math.min(rows.length, start + visible.length);
pageInfoEl.textContent = rows.length === 0
? 'Page 0 of 0'
: `Page ${page + 1} of ${totalPages} (${first.toLocaleString()}-${last.toLocaleString()} of ${rows.length.toLocaleString()})`;
}
if (prevBtn) prevBtn.disabled = page <= 0;
if (nextBtn) nextBtn.disabled = page >= totalPages - 1;
}
async function refreshTable() {
const myReq = ++requestId;
loadedMax = getTableMaxSamples();
page = 0;
setMeta(`Loading up to ${loadedMax.toLocaleString()} samples matching filters...`, true);
try {
const data = await db.query(`
SELECT pid, label, source, latitude, longitude, place_name, result_time
FROM read_parquet('${lite_url}')
WHERE 1=1
${sourceFilterSQL('source')}
${facetFilterSQL()}
LIMIT ${loadedMax}
`);
if (myReq !== requestId) return;
const arr = Array.from(data);
hitHardCap = arr.length === loadedMax;
rows = arr;
tableDirty = false;
renderTable();
const capText = hitHardCap
? (loadedMax < TABLE_MAX_MAX
? ` Max samples cap reached; raise it to inspect more rows.`
: ` Maximum table cap reached.`)
: '';
setMeta(`Loaded ${rows.length.toLocaleString()} sample rows.${capText}`, false);
} catch (err) {
if (myReq !== requestId) return;
console.error('Table query failed:', err);
rows = [];
renderTable();
setMeta('Table query failed; adjust filters and try again.', false);
}
}
function setView(mode, updateUrl) {
const tableMode = mode === 'table';
document.body.classList.toggle('table-view-active', tableMode);
if (globeLayout) globeLayout.style.display = tableMode ? 'none' : '';
if (tableContainer) tableContainer.style.display = tableMode ? 'block' : 'none';
if (tableControls) tableControls.style.display = tableMode ? 'flex' : 'none';
if (globeBtn) {
globeBtn.classList.toggle('active', !tableMode);
globeBtn.setAttribute('aria-pressed', String(!tableMode));
}
if (tableBtn) {
tableBtn.classList.toggle('active', tableMode);
tableBtn.setAttribute('aria-pressed', String(tableMode));
}
if (updateUrl) writeQueryState();
if (tableMode && (tableDirty || rows.length === 0)) refreshTable();
if (!tableMode && typeof viewer !== 'undefined') {
setTimeout(() => viewer.resize(), 0);
}
}
if (globeBtn) globeBtn.addEventListener('click', () => setView('globe', true));
if (tableBtn) tableBtn.addEventListener('click', () => setView('table', true));
if (prevBtn) prevBtn.addEventListener('click', () => { page = Math.max(0, page - 1); renderTable(); });
if (nextBtn) nextBtn.addEventListener('click', () => { page += 1; renderTable(); });
if (maxInput) {
maxInput.addEventListener('change', () => {
maxInput.value = getTableMaxSamples();
if (isTableViewActive()) refreshTable();
});
}
function handleTableFilterChange() {
tableDirty = true;
if (isTableViewActive()) refreshTable();
}
document.getElementById('sourceFilter')?.addEventListener('change', handleTableFilterChange);
document.getElementById('materialFilterBody')?.addEventListener('change', handleTableFilterChange);
document.getElementById('contextFilterBody')?.addEventListener('change', handleTableFilterChange);
document.getElementById('objectTypeFilterBody')?.addEventListener('change', handleTableFilterChange);
window.refreshSamplesTable = refreshTable;
const params = new URLSearchParams(location.search);
setView(params.get('view') === 'table' ? 'table' : 'globe', false);
return "active";
}// === 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
const POINT_BUDGET = DEFAULT_POINT_BUDGET;
// 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');
// facetFilterSQL() returns a portable `pid IN (...)` predicate,
// so the same query works whether or not facet filters are active.
const 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')}
${facetFilterSQL()}
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;
}
// 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');
}
// === 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,
};
}
function buildCrossFilterWhere(excludeFacet) {
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 `${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();
if (!sourceImpossible && activeDims.length === 0) {
for (const d of dims) applyFacetCounts(d.key, null);
return;
}
markFacetCountsRecomputing();
const singleActiveDim = !sourceImpossible
&& activeDims.length === 1 && activeDims[0].values.length === 1
? activeDims[0] : null;
if (singleActiveDim && totalActiveValues === 1) {
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) => {
const where = buildCrossFilterWhere(d.key);
try {
const 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);
applyFacetCounts(d.key, null);
}
}));
}
function refreshFacetCounts() {
clearTimeout(facetCountsDebounce);
const myReq = ++facetCountsReqId;
facetCountsDebounce = setTimeout(() => {
updateCrossFilteredCounts(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
updateSourceLegendState();
writeQueryState();
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();
}
refreshFacetCounts();
});
// --- 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.
const facetNote = document.getElementById('facetNote');
function handleFacetFilterChange() {
const active = hasFacetFilters();
if (facetNote) facetNote.style.display = (active && mode === 'cluster') ? 'block' : 'none';
writeQueryState();
if (mode === 'point') {
cachedBounds = null;
loadViewportSamples();
}
refreshFacetCounts();
}
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;
// 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';
writeQueryState();
return;
}
writeQueryState();
searchResults.textContent = 'Searching...';
try {
const terms = searchTerms(term);
const searchWhere = textSearchWhere(terms, ['label', 'CAST(place_name AS VARCHAR)']);
const score = textSearchScore(terms, [
{ col: 'label', weight: 3 },
{ col: 'CAST(place_name AS VARCHAR)', weight: 2 },
]);
const results = await db.query(`
SELECT pid, label, source, latitude, longitude, place_name,
(${score}) AS relevance_score
FROM read_parquet('${lite_url}')
WHERE ${searchWhere}
${sourceFilterSQL('source')}
${facetFilterSQL()}
ORDER BY relevance_score DESC, label
LIMIT 50
`);
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();
});
if (searchInput && searchInput.value.trim().length >= 2) {
doSearch();
}
refreshFacetCounts();
// --- 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 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