Skip to content

Commit f3e3fd3

Browse files
rdhyeeclaude
andcommitted
Add cross-filtering to Explorer facet counts
When any filter is active, facet counts now reflect the intersection of all OTHER active filters. For example, selecting SESAR as source updates material/context/specimen counts to show only what exists in SESAR data. Uses parallel GROUP BY queries via DuckDB-WASM. Counts update via DOM manipulation to avoid resetting checkbox selections. Zero-count facet values are dimmed for visual clarity. When no filters are active, pre-computed summaries are used (instant). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0ffbd4c commit f3e3fd3

1 file changed

Lines changed: 155 additions & 7 deletions

File tree

tutorials/isamples_explorer.qmd

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Search and explore **6.7 million physical samples** from scientific collections
1212

1313
::: {.callout-note}
1414
### Serverless Architecture
15-
This app uses a **two-tier loading strategy**: a 2KB pre-computed summary loads instantly for facet counts (source, material, context, specimen type), while the full ~280 MB Parquet file is only queried when drilling into records. All powered by DuckDB-WASM in your browser -- no server required!
15+
This app uses a **two-tier loading strategy**: a 2KB pre-computed summary loads instantly for facet counts, while the full ~280 MB Parquet file is queried on demand. **Cross-filtering** keeps counts accurate — selecting a source updates material/context/specimen counts to reflect only that source's samples. All powered by DuckDB-WASM in your browser no server required!
1616
:::
1717

1818
## Setup
@@ -92,7 +92,6 @@ facetSummariesWarning
9292
//| code-fold: true
9393
// Source checkboxes with counts - uses pre-computed summaries for instant load
9494
viewof sourceCheckboxes = {
95-
// Use pre-computed facet summaries (instant) instead of scanning full parquet
9695
const counts = facetsByType.source;
9796
const options = counts.map(r => r.value);
9897
@@ -104,7 +103,7 @@ viewof sourceCheckboxes = {
104103
const count = r ? Number(r.count).toLocaleString() : "0";
105104
return html`<span style="display: inline-flex; align-items: center; gap: 6px;">
106105
<span style="width: 12px; height: 12px; border-radius: 50%; background: ${color};"></span>
107-
${x} <span style="color: #888;">(${count})</span>
106+
${x} <span class="facet-count" data-facet="source" data-value="${x}" style="color: #888;">(${count})</span>
108107
</span>`;
109108
}
110109
});
@@ -125,7 +124,7 @@ viewof materialCheckboxes = {
125124
const r = counts.find(s => s.value === x);
126125
const count = r ? Number(r.count).toLocaleString() : "0";
127126
return html`<span style="display: inline-flex; align-items: center; gap: 4px;">
128-
${x} <span style="color: #888; font-size: 11px;">(${count})</span>
127+
${x} <span class="facet-count" data-facet="material" data-value="${x}" style="color: #888; font-size: 11px;">(${count})</span>
129128
</span>`;
130129
}
131130
});
@@ -146,7 +145,7 @@ viewof contextCheckboxes = {
146145
const r = counts.find(s => s.value === x);
147146
const count = r ? Number(r.count).toLocaleString() : "0";
148147
return html`<span style="display: inline-flex; align-items: center; gap: 4px;">
149-
${x} <span style="color: #888; font-size: 11px;">(${count})</span>
148+
${x} <span class="facet-count" data-facet="context" data-value="${x}" style="color: #888; font-size: 11px;">(${count})</span>
150149
</span>`;
151150
}
152151
});
@@ -167,7 +166,7 @@ viewof objectTypeCheckboxes = {
167166
const r = counts.find(s => s.value === x);
168167
const count = r ? Number(r.count).toLocaleString() : "0";
169168
return html`<span style="display: inline-flex; align-items: center; gap: 4px;">
170-
${x} <span style="color: #888; font-size: 11px;">(${count})</span>
169+
${x} <span class="facet-count" data-facet="object_type" data-value="${x}" style="color: #888; font-size: 11px;">(${count})</span>
171170
</span>`;
172171
}
173172
});
@@ -366,7 +365,7 @@ facetSummariesWarning = {
366365
</div>`;
367366
}
368367
369-
// Extract facet counts by type from pre-computed summaries
368+
// Extract facet counts by type from pre-computed summaries (baseline)
370369
facetsByType = {
371370
const grouped = { source: [], material: [], context: [], object_type: [] };
372371
for (const row of facetSummaries) {
@@ -383,6 +382,155 @@ facetsByType = {
383382
}
384383
```
385384

385+
```{ojs}
386+
//| code-fold: true
387+
// Cross-filter: build WHERE clause excluding one facet dimension
388+
// This lets each facet show counts reflecting all OTHER active filters
389+
function buildWhereClause(excludeFacet) {
390+
const conditions = [
391+
"otype = 'MaterialSampleRecord'",
392+
"latitude IS NOT NULL"
393+
];
394+
395+
if (searchInput?.trim()) {
396+
const term = searchInput.trim().replace(/'/g, "''");
397+
conditions.push(`(
398+
label ILIKE '%${term}%'
399+
OR description ILIKE '%${term}%'
400+
OR CAST(place_name AS VARCHAR) ILIKE '%${term}%'
401+
)`);
402+
}
403+
404+
if (excludeFacet !== 'source') {
405+
const sources = Array.from(sourceCheckboxes || []);
406+
if (sources.length > 0) {
407+
const sourceList = sources.map(s => `'${s}'`).join(", ");
408+
conditions.push(`n IN (${sourceList})`);
409+
}
410+
}
411+
412+
if (excludeFacet !== 'material') {
413+
const materials = Array.from(materialCheckboxes || []);
414+
if (materials.length > 0) {
415+
const matList = materials.map(m => `'${m.replace(/'/g, "''")}'`).join(", ");
416+
conditions.push(`has_material_category IN (${matList})`);
417+
}
418+
}
419+
420+
if (excludeFacet !== 'context') {
421+
const contexts = Array.from(contextCheckboxes || []);
422+
if (contexts.length > 0) {
423+
const ctxList = contexts.map(c => `'${c.replace(/'/g, "''")}'`).join(", ");
424+
conditions.push(`has_context_category IN (${ctxList})`);
425+
}
426+
}
427+
428+
if (excludeFacet !== 'object_type') {
429+
const objectTypes = Array.from(objectTypeCheckboxes || []);
430+
if (objectTypes.length > 0) {
431+
const otList = objectTypes.map(o => `'${o.replace(/'/g, "''")}'`).join(", ");
432+
conditions.push(`has_specimen_category IN (${otList})`);
433+
}
434+
}
435+
436+
return conditions.join(" AND ");
437+
}
438+
```
439+
440+
```{ojs}
441+
//| code-fold: true
442+
// Detect whether any filter is active (triggers cross-filter queries)
443+
hasActiveFilters = {
444+
const hasSearch = searchInput?.trim()?.length > 0;
445+
const hasSources = (sourceCheckboxes || []).length > 0;
446+
const hasMaterials = (materialCheckboxes || []).length > 0;
447+
const hasContexts = (contextCheckboxes || []).length > 0;
448+
const hasObjectTypes = (objectTypeCheckboxes || []).length > 0;
449+
return hasSearch || hasSources || hasMaterials || hasContexts || hasObjectTypes;
450+
}
451+
```
452+
453+
```{ojs}
454+
//| code-fold: true
455+
// Cross-filtered facet counts: recompute when filters are active
456+
// Each facet uses a WHERE clause with all filters EXCEPT its own dimension,
457+
// so you see how many items exist for each value given other active filters
458+
crossFilteredFacets = {
459+
if (!hasActiveFilters) return null; // Use pre-computed summaries when no filters
460+
461+
const facetConfig = [
462+
{ key: 'source', column: 'n', exclude: 'source' },
463+
{ key: 'material', column: 'has_material_category', exclude: 'material' },
464+
{ key: 'context', column: 'has_context_category', exclude: 'context' },
465+
{ key: 'object_type', column: 'has_specimen_category', exclude: 'object_type' },
466+
];
467+
468+
const results = {};
469+
470+
// Run all 4 facet queries in parallel
471+
const queries = facetConfig.map(async ({ key, column, exclude }) => {
472+
const where = buildWhereClause(exclude);
473+
const sql = `
474+
SELECT ${column} AS value, COUNT(*) AS count
475+
FROM samples
476+
WHERE ${where} AND ${column} IS NOT NULL
477+
GROUP BY ${column}
478+
ORDER BY count DESC
479+
`;
480+
try {
481+
const rows = await runQuery(sql);
482+
results[key] = rows.map(r => ({ value: r.value, count: r.count }));
483+
} catch (e) {
484+
console.warn(`Cross-filter query failed for ${key}:`, e);
485+
results[key] = null; // Fall back to pre-computed
486+
}
487+
});
488+
489+
await Promise.all(queries);
490+
return results;
491+
}
492+
```
493+
494+
```{ojs}
495+
//| code-fold: true
496+
// Merge cross-filtered counts with baseline facets
497+
// Baseline provides the full list of values; cross-filter overrides counts
498+
function getDisplayCounts(facetKey) {
499+
const baseline = facetsByType[facetKey] || [];
500+
if (!crossFilteredFacets || !crossFilteredFacets[facetKey]) return baseline;
501+
502+
const filtered = crossFilteredFacets[facetKey];
503+
const countMap = new Map(filtered.map(r => [r.value, r.count]));
504+
505+
return baseline.map(item => ({
506+
...item,
507+
count: countMap.has(item.value) ? countMap.get(item.value) : 0,
508+
}));
509+
}
510+
```
511+
512+
```{ojs}
513+
//| code-fold: true
514+
// Update facet count labels in-place when cross-filtered counts arrive
515+
// This avoids re-rendering checkboxes (which would reset user selections)
516+
{
517+
if (!crossFilteredFacets) return; // No active filters — keep pre-computed counts
518+
519+
for (const [facetKey, rows] of Object.entries(crossFilteredFacets)) {
520+
if (!rows) continue;
521+
const countMap = new Map(rows.map(r => [r.value, r.count]));
522+
523+
document.querySelectorAll(`.facet-count[data-facet="${facetKey}"]`).forEach(el => {
524+
const value = el.getAttribute('data-value');
525+
const count = countMap.get(value) ?? 0;
526+
el.textContent = `(${Number(count).toLocaleString()})`;
527+
// Dim zero-count items
528+
el.style.opacity = count === 0 ? '0.4' : '1';
529+
});
530+
}
531+
}
532+
```
533+
386534
```{ojs}
387535
//| code-fold: true
388536
// Build WHERE clause from current filters (Tier 2: queries full parquet only when filtering)

0 commit comments

Comments
 (0)