@@ -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`
184203h3_res8_url = `${R2_BASE}/isamples_202601_h3_summary_res8.parquet`
185204lite_url = `${R2_BASE}/isamples_202601_samples_map_lite.parquet`
186205wide_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
188209SOURCE_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 ===
221278function 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 ===
578699zoomWatcher = {
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