Skip to content

Commit 8ccc758

Browse files
rdhyeeclaude
andauthored
Add material and sampled-feature filters to Interactive Explorer (#68)
Built supplemental sample_facets.parquet (25MB, 8.3M rows) from existing narrow+wide parquet — no pipeline rebuild needed. Uploaded to R2. New filters: - Material (19 categories): rock, sediment, organicmaterial, etc. - Sampled Feature (17 categories): earthinterior, Animalia, etc. - Collapsible sections populated from facet_summaries.parquet - Each checkbox shows count (e.g., "rock (1,208,585)") - Filters apply in point mode via JOIN with facets parquet - In cluster mode, note shows "filters apply at sample zoom level" - Also applies to cluster-click nearby samples query Data file: isamples_202601_sample_facets.parquet on R2 - pid | material | context - Built by resolving narrow-format edges to IdentifiedConcept labels Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8b95b16 commit 8ccc758

File tree

1 file changed

+170
-17
lines changed

1 file changed

+170
-17
lines changed

tutorials/progressive_globe.qmd

Lines changed: 170 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,25 @@ Circle size = log(sample count). Color = dominant data source.
153153
<label class="legend-item"><input type="checkbox" value="SMITHSONIAN" checked><span class="legend-dot" style="background:#FF9900"></span> Smithsonian</label>
154154
</div>
155155
</div>
156+
<div class="filter-section" id="materialFilter">
157+
<div class="filter-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
158+
Material <span>▾</span>
159+
</div>
160+
<div class="filter-body" style="display: none;" id="materialFilterBody">
161+
<em style="font-size: 11px; color: #999;">Loading...</em>
162+
</div>
163+
</div>
164+
<div class="filter-section" id="contextFilter">
165+
<div class="filter-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
166+
Sampled Feature <span>▾</span>
167+
</div>
168+
<div class="filter-body" style="display: none;" id="contextFilterBody">
169+
<em style="font-size: 11px; color: #999;">Loading...</em>
170+
</div>
171+
</div>
172+
<div id="facetNote" style="display: none; font-size: 11px; color: #888; margin-top: 4px; font-style: italic;">
173+
Material/feature filters apply at sample zoom level
174+
</div>
156175
<div style="margin-top: 8px; display: flex; gap: 8px; align-items: center;">
157176
<button id="shareBtn" class="share-btn" title="Copy link to current view">Share View</button>
158177
<span id="shareToast" class="share-toast">Link copied!</span>
@@ -184,6 +203,8 @@ h3_res6_url = `${R2_BASE}/isamples_202601_h3_summary_res6.parquet`
184203
h3_res8_url = `${R2_BASE}/isamples_202601_h3_summary_res8.parquet`
185204
lite_url = `${R2_BASE}/isamples_202601_samples_map_lite.parquet`
186205
wide_url = `${R2_BASE}/isamples_202601_wide.parquet`
206+
facets_url = `${R2_BASE}/isamples_202601_sample_facets.parquet`
207+
facet_summaries_url = `${R2_BASE}/isamples_202601_facet_summaries.parquet`
187208
188209
SOURCE_COLORS = ({
189210
SESAR: '#3366CC', OPENCONTEXT: '#DC3912',
@@ -217,6 +238,42 @@ function sourceFilterSQL(col) {
217238
return ` AND ${col} IN (${list})`;
218239
}
219240
241+
// === Material/Context Filters ===
242+
function getCheckedValues(containerId) {
243+
const checks = document.querySelectorAll(`#${containerId} input[type="checkbox"]`);
244+
return Array.from(checks).filter(c => c.checked).map(c => c.value);
245+
}
246+
247+
function hasFacetFilters() {
248+
const mat = getCheckedValues('materialFilterBody');
249+
const ctx = getCheckedValues('contextFilterBody');
250+
const matTotal = document.querySelectorAll('#materialFilterBody input[type="checkbox"]').length;
251+
const ctxTotal = document.querySelectorAll('#contextFilterBody input[type="checkbox"]').length;
252+
// Active if some (but not all) are checked, or if none are checked
253+
return (mat.length > 0 && mat.length < matTotal) || (ctx.length > 0 && ctx.length < ctxTotal);
254+
}
255+
256+
function facetFilterSQL() {
257+
let sql = '';
258+
const mat = getCheckedValues('materialFilterBody');
259+
const matTotal = document.querySelectorAll('#materialFilterBody input[type="checkbox"]').length;
260+
if (mat.length > 0 && mat.length < matTotal) {
261+
const list = mat.map(s => `'${s}'`).join(',');
262+
sql += ` AND f.material IN (${list})`;
263+
} else if (mat.length === 0 && matTotal > 0) {
264+
sql += ' AND 1=0';
265+
}
266+
const ctx = getCheckedValues('contextFilterBody');
267+
const ctxTotal = document.querySelectorAll('#contextFilterBody input[type="checkbox"]').length;
268+
if (ctx.length > 0 && ctx.length < ctxTotal) {
269+
const list = ctx.map(s => `'${s}'`).join(',');
270+
sql += ` AND f.context IN (${list})`;
271+
} else if (ctx.length === 0 && ctxTotal > 0) {
272+
sql += ' AND 1=0';
273+
}
274+
return sql;
275+
}
276+
220277
// === URL State: encode/decode globe state in hash fragment ===
221278
function parseNum(val, def, min, max) {
222279
if (val == null) return def;
@@ -503,14 +560,31 @@ viewer = {
503560
504561
const delta = meta.resolution === 4 ? 2.0 : meta.resolution === 6 ? 0.5 : 0.1;
505562
try {
506-
const samples = await db.query(`
507-
SELECT pid, label, source, latitude, longitude, place_name
508-
FROM read_parquet('${lite_url}')
509-
WHERE latitude BETWEEN ${meta.lat - delta} AND ${meta.lat + delta}
510-
AND longitude BETWEEN ${meta.lng - delta} AND ${meta.lng + delta}
511-
${sourceFilterSQL('source')}
512-
LIMIT 30
513-
`);
563+
const facetActive = hasFacetFilters();
564+
const facetSQL = facetActive ? facetFilterSQL() : '';
565+
let nearbyQuery;
566+
if (facetActive) {
567+
nearbyQuery = `
568+
SELECT l.pid, l.label, l.source, l.latitude, l.longitude, l.place_name
569+
FROM read_parquet('${lite_url}') l
570+
JOIN read_parquet('${facets_url}') f ON l.pid = f.pid
571+
WHERE l.latitude BETWEEN ${meta.lat - delta} AND ${meta.lat + delta}
572+
AND l.longitude BETWEEN ${meta.lng - delta} AND ${meta.lng + delta}
573+
${sourceFilterSQL('l.source')}
574+
${facetSQL}
575+
LIMIT 30
576+
`;
577+
} else {
578+
nearbyQuery = `
579+
SELECT pid, label, source, latitude, longitude, place_name
580+
FROM read_parquet('${lite_url}')
581+
WHERE latitude BETWEEN ${meta.lat - delta} AND ${meta.lat + delta}
582+
AND longitude BETWEEN ${meta.lng - delta} AND ${meta.lng + delta}
583+
${sourceFilterSQL('source')}
584+
LIMIT 30
585+
`;
586+
}
587+
const samples = await db.query(nearbyQuery);
514588
updateSamples(samples);
515589
} catch(err) {
516590
console.error("Sample query failed:", err);
@@ -574,9 +648,57 @@ phase1 = {
574648
//| echo: false
575649
//| output: false
576650
651+
// === Load facet summaries and populate filter checkboxes ===
652+
facetFilters = {
653+
if (!phase1) return;
654+
try {
655+
const summaries = await db.query(`
656+
SELECT facet_type, facet_value, count
657+
FROM read_parquet('${facet_summaries_url}')
658+
ORDER BY facet_type, count DESC
659+
`);
660+
661+
const grouped = { material: [], context: [] };
662+
for (const row of summaries) {
663+
if (grouped[row.facet_type]) {
664+
// Extract short label from URI
665+
const shortLabel = row.facet_value.split('/').pop() || row.facet_value;
666+
grouped[row.facet_type].push({ value: shortLabel, fullUri: row.facet_value, count: row.count });
667+
}
668+
}
669+
670+
// Populate material checkboxes
671+
const matBody = document.getElementById('materialFilterBody');
672+
if (matBody && grouped.material.length > 0) {
673+
matBody.innerHTML = grouped.material.map(m =>
674+
`<label><input type="checkbox" value="${m.value}" checked> ${m.value} <span style="color:#999">(${Number(m.count).toLocaleString()})</span></label>`
675+
).join('');
676+
}
677+
678+
// Populate context checkboxes
679+
const ctxBody = document.getElementById('contextFilterBody');
680+
if (ctxBody && grouped.context.length > 0) {
681+
ctxBody.innerHTML = grouped.context.map(c =>
682+
`<label><input type="checkbox" value="${c.value}" checked> ${c.value} <span style="color:#999">(${Number(c.count).toLocaleString()})</span></label>`
683+
).join('');
684+
}
685+
686+
console.log(`Facet filters loaded: ${grouped.material.length} materials, ${grouped.context.length} contexts`);
687+
} catch(err) {
688+
console.warn("Facet summaries failed to load:", err);
689+
}
690+
return "loaded";
691+
}
692+
```
693+
694+
```{ojs}
695+
//| echo: false
696+
//| output: false
697+
577698
// === Zoom watcher: H3 cluster mode + individual sample point mode ===
578699
zoomWatcher = {
579700
if (!phase1) return;
701+
if (!facetFilters) return; // wait for facet checkboxes
580702
581703
// --- State ---
582704
let mode = 'cluster'; // 'cluster' or 'point'
@@ -714,15 +836,33 @@ zoomWatcher = {
714836
715837
try {
716838
performance.mark('sp-s');
717-
const data = await db.query(`
718-
SELECT pid, label, source, latitude, longitude,
719-
place_name, result_time
720-
FROM read_parquet('${lite_url}')
721-
WHERE latitude BETWEEN ${padded.south} AND ${padded.north}
722-
AND longitude BETWEEN ${padded.west} AND ${padded.east}
723-
${sourceFilterSQL('source')}
724-
LIMIT ${POINT_BUDGET}
725-
`);
839+
const facetActive = hasFacetFilters();
840+
const facetSQL = facetActive ? facetFilterSQL() : '';
841+
let query;
842+
if (facetActive) {
843+
query = `
844+
SELECT l.pid, l.label, l.source, l.latitude, l.longitude,
845+
l.place_name, l.result_time, f.material, f.context
846+
FROM read_parquet('${lite_url}') l
847+
JOIN read_parquet('${facets_url}') f ON l.pid = f.pid
848+
WHERE l.latitude BETWEEN ${padded.south} AND ${padded.north}
849+
AND l.longitude BETWEEN ${padded.west} AND ${padded.east}
850+
${sourceFilterSQL('l.source')}
851+
${facetSQL}
852+
LIMIT ${POINT_BUDGET}
853+
`;
854+
} else {
855+
query = `
856+
SELECT pid, label, source, latitude, longitude,
857+
place_name, result_time
858+
FROM read_parquet('${lite_url}')
859+
WHERE latitude BETWEEN ${padded.south} AND ${padded.north}
860+
AND longitude BETWEEN ${padded.west} AND ${padded.east}
861+
${sourceFilterSQL('source')}
862+
LIMIT ${POINT_BUDGET}
863+
`;
864+
}
865+
const data = await db.query(query);
726866
performance.mark('sp-e');
727867
performance.measure('sp', 'sp-s', 'sp-e');
728868
const elapsed = performance.getEntriesByName('sp').pop().duration;
@@ -827,6 +967,19 @@ zoomWatcher = {
827967
}
828968
});
829969
970+
// --- Material/Context filter change handler ---
971+
const facetNote = document.getElementById('facetNote');
972+
function handleFacetFilterChange() {
973+
const active = hasFacetFilters();
974+
if (facetNote) facetNote.style.display = (active && mode === 'cluster') ? 'block' : 'none';
975+
if (mode === 'point') {
976+
cachedBounds = null;
977+
loadViewportSamples();
978+
}
979+
}
980+
document.getElementById('materialFilterBody').addEventListener('change', handleFacetFilterChange);
981+
document.getElementById('contextFilterBody').addEventListener('change', handleFacetFilterChange);
982+
830983
// --- Camera change handler ---
831984
let timer = null;
832985
viewer.camera.changed.addEventListener(() => {

0 commit comments

Comments
 (0)