Skip to content

Commit a2c677d

Browse files
rdhyeeclaude
andauthored
Progressive globe: source links, filtering, and stats clarity (#53)
* Clarify stats labels: distinguish clusters vs samples, loaded vs in-view Labels now say "Clusters Loaded" / "Samples Loaded" on initial load, and "Clusters in View / Loaded" / "Samples in View" when zoomed in. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add "View at source" links resolving pids via n2t.net - Sample card shows "View at [Source Name] →" link using n2t.net/{pid} - All 4 sources resolve: OpenContext (ark:/28722), SESAR (IGSN:), GEOME (ark:/21547), Smithsonian (ark:/65665) - Nearby samples list: sample labels are now clickable links to source - Removed generic "Open in Analysis Tool" link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add source filter checkboxes to progressive globe Legend dots are now clickable checkboxes that filter data by source (SESAR, OpenContext, GEOME, Smithsonian). Unchecking a source: - Removes its clusters from H3 aggregate views - Excludes its samples from point mode viewport queries - Excludes from nearby samples on cluster click - Visually dims the unchecked legend item Filter is applied via SQL WHERE clause injected into all parquet queries. Changing filters triggers immediate reload of current view. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix review findings: all-unchecked edge case and loadRes race condition - sourceFilterSQL returns AND 1=0 when no sources checked (shows nothing) - Add generation counter to loadRes to discard stale async results - Add missing await on loadViewportSamples in filter handler Addresses Codex and Gemini review feedback on PR #53. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 438a5fc commit a2c677d

1 file changed

Lines changed: 61 additions & 13 deletions

File tree

tutorials/progressive_globe.qmd

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,11 @@ Circle size = log(sample count). Color = dominant data source.
6969
}
7070
.stat-box .val { font-weight: bold; font-size: 16px; font-family: monospace; display: block; }
7171
.stat-box .lbl { color: #aaa; font-size: 11px; }
72-
.legend { display: flex; gap: 10px; flex-wrap: wrap; font-size: 12px; }
73-
.legend-item { display: flex; align-items: center; gap: 3px; }
72+
.legend { display: flex; gap: 8px; flex-wrap: wrap; font-size: 12px; }
73+
.legend-item { display: flex; align-items: center; gap: 3px; cursor: pointer; user-select: none; }
74+
.legend-item.disabled { opacity: 0.3; }
7475
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
76+
.legend-item input[type="checkbox"] { margin: 0; width: 12px; height: 12px; cursor: pointer; }
7577
.source-badge { color: white; padding: 2px 8px; border-radius: 10px; font-size: 0.8em; white-space: nowrap; }
7678
.cluster-card { border-left: 4px solid #ccc; padding: 10px 12px; background: white; border-radius: 0 6px 6px 0; }
7779
.sample-row { padding: 6px 0; border-bottom: 1px solid #eee; line-height: 1.4; }
@@ -104,11 +106,11 @@ Circle size = log(sample count). Color = dominant data source.
104106
<div class="stat-box"><span id="sTime" class="val">-</span><span class="lbl">Load Time</span></div>
105107
</div>
106108
<div style="margin-top: 8px;">
107-
<div class="legend">
108-
<span class="legend-item"><span class="legend-dot" style="background:#3366CC"></span> SESAR</span>
109-
<span class="legend-item"><span class="legend-dot" style="background:#DC3912"></span> OpenContext</span>
110-
<span class="legend-item"><span class="legend-dot" style="background:#109618"></span> GEOME</span>
111-
<span class="legend-item"><span class="legend-dot" style="background:#FF9900"></span> Smithsonian</span>
109+
<div class="legend" id="sourceFilter">
110+
<label class="legend-item"><input type="checkbox" value="SESAR" checked><span class="legend-dot" style="background:#3366CC"></span> SESAR</label>
111+
<label class="legend-item"><input type="checkbox" value="OPENCONTEXT" checked><span class="legend-dot" style="background:#DC3912"></span> OpenContext</label>
112+
<label class="legend-item"><input type="checkbox" value="GEOME" checked><span class="legend-dot" style="background:#109618"></span> GEOME</label>
113+
<label class="legend-item"><input type="checkbox" value="SMITHSONIAN" checked><span class="legend-dot" style="background:#FF9900"></span> Smithsonian</label>
112114
</div>
113115
</div>
114116
<div style="margin-top: 8px; display: flex; gap: 8px; align-items: center;">
@@ -152,6 +154,29 @@ SOURCE_NAMES = ({
152154
GEOME: 'GEOME', SMITHSONIAN: 'Smithsonian'
153155
})
154156
157+
// === Source URL: resolve pid to original repository ===
158+
function sourceUrl(pid) {
159+
if (!pid) return null;
160+
// All sources resolve via n2t.net:
161+
// ARK pids (OpenContext, GEOME, Smithsonian) → n2t.net/ark:/...
162+
// IGSN pids (SESAR) → n2t.net/IGSN:...
163+
return `https://n2t.net/${pid}`;
164+
}
165+
166+
// === Source Filter: get active sources and build SQL clause ===
167+
function getActiveSources() {
168+
const checks = document.querySelectorAll('#sourceFilter input[type="checkbox"]');
169+
return Array.from(checks).filter(c => c.checked).map(c => c.value);
170+
}
171+
172+
function sourceFilterSQL(col) {
173+
const active = getActiveSources();
174+
if (active.length === 0) return ' AND 1=0'; // nothing checked = show nothing
175+
if (active.length === 4) return ''; // all checked = no filter
176+
const list = active.map(s => `'${s}'`).join(',');
177+
return ` AND ${col} IN (${list})`;
178+
}
179+
155180
// === URL State: encode/decode globe state in hash fragment ===
156181
function parseNum(val, def, min, max) {
157182
if (val == null) return def;
@@ -246,6 +271,7 @@ function updateSampleCard(sample) {
246271
const placeStr = Array.isArray(placeParts) && placeParts.length > 0
247272
? placeParts.filter(Boolean).join(' › ')
248273
: '';
274+
const srcUrl = sourceUrl(sample.pid);
249275
el.innerHTML = `<h4>Sample</h4>
250276
<div class="cluster-card" style="border-left-color: ${color}">
251277
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
@@ -259,6 +285,7 @@ function updateSampleCard(sample) {
259285
</div>
260286
${placeStr ? `<div style="font-size: 12px; color: #555; margin-bottom: 4px;">${placeStr}</div>` : ''}
261287
${sample.result_time ? `<div style="font-size: 11px; color: #888;">Date: ${sample.result_time}</div>` : ''}
288+
${srcUrl ? `<div style="margin-top: 4px;"><a class="detail-link" href="${srcUrl}" target="_blank" rel="noopener noreferrer">View at ${name} →</a></div>` : ''}
262289
<div id="sampleDetail" class="detail-loading">Loading full details...</div>
263290
</div>`;
264291
}
@@ -273,10 +300,7 @@ function updateSampleDetail(detail) {
273300
const desc = detail.description
274301
? (detail.description.length > 300 ? detail.description.slice(0, 300) + '...' : detail.description)
275302
: '';
276-
el.innerHTML = `${desc ? `<div style="font-size: 12px; color: #444; margin-top: 6px; line-height: 1.4;">${desc}</div>` : ''}
277-
<div style="margin-top: 8px;">
278-
<a class="detail-link" href="zenodo_isamples_analysis.html" target="_blank" rel="noopener noreferrer">Open in Analysis Tool →</a>
279-
</div>`;
303+
el.innerHTML = `${desc ? `<div style="font-size: 12px; color: #444; margin-top: 6px; line-height: 1.4;">${desc}</div>` : ''}`;
280304
}
281305
282306
function updateSamples(samples) {
@@ -294,9 +318,10 @@ function updateSamples(samples) {
294318
const desc = Array.isArray(placeParts) && placeParts.length > 0
295319
? placeParts.filter(Boolean).join(' › ')
296320
: '';
321+
const sUrl = sourceUrl(s.pid);
297322
h += `<div class="sample-row">
298323
<div style="display: flex; align-items: center; gap: 6px;">
299-
<span class="sample-label">${s.label || s.pid}</span>
324+
${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>`}
300325
<span class="source-badge" style="background: ${color}; font-size: 10px;">${name}</span>
301326
</div>
302327
${desc ? `<div class="sample-desc">${desc}</div>` : ''}
@@ -443,6 +468,7 @@ viewer = {
443468
FROM read_parquet('${lite_url}')
444469
WHERE latitude BETWEEN ${meta.lat - delta} AND ${meta.lat + delta}
445470
AND longitude BETWEEN ${meta.lng - delta} AND ${meta.lng + delta}
471+
${sourceFilterSQL('source')}
446472
LIMIT 30
447473
`);
448474
updateSamples(samples);
@@ -469,6 +495,7 @@ phase1 = {
469495
SELECT h3_cell, sample_count, center_lat, center_lng,
470496
dominant_source, source_count
471497
FROM read_parquet('${h3_res4_url}')
498+
WHERE 1=1${sourceFilterSQL('dominant_source')}
472499
`);
473500
474501
const scalar = new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.5);
@@ -528,8 +555,9 @@ zoomWatcher = {
528555
let cachedData = null; // array of rows
529556
530557
// --- H3 cluster loading (existing logic) ---
558+
let loadResGen = 0; // generation counter to discard stale results
531559
const loadRes = async (res, url) => {
532-
if (loading) return;
560+
const gen = ++loadResGen; // claim a generation
533561
loading = true;
534562
updatePhaseMsg(`Loading H3 res${res}...`, 'loading');
535563
@@ -539,8 +567,10 @@ zoomWatcher = {
539567
SELECT h3_cell, sample_count, center_lat, center_lng,
540568
dominant_source, source_count
541569
FROM read_parquet('${url}')
570+
WHERE 1=1${sourceFilterSQL('dominant_source')}
542571
`);
543572
573+
if (gen !== loadResGen) return; // stale — a newer call superseded this one
544574
viewer.h3Points.removeAll();
545575
const scalar = new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.3);
546576
let total = 0;
@@ -650,6 +680,7 @@ zoomWatcher = {
650680
FROM read_parquet('${lite_url}')
651681
WHERE latitude BETWEEN ${padded.south} AND ${padded.north}
652682
AND longitude BETWEEN ${padded.west} AND ${padded.east}
683+
${sourceFilterSQL('source')}
653684
LIMIT ${POINT_BUDGET}
654685
`);
655686
performance.mark('sp-e');
@@ -739,6 +770,23 @@ zoomWatcher = {
739770
console.log('Exited point mode');
740771
}
741772
773+
// --- Source filter change handler ---
774+
const resUrls = { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url };
775+
document.getElementById('sourceFilter').addEventListener('change', async () => {
776+
// Toggle visual state on labels
777+
document.querySelectorAll('#sourceFilter .legend-item').forEach(li => {
778+
const cb = li.querySelector('input');
779+
li.classList.toggle('disabled', !cb.checked);
780+
});
781+
if (mode === 'cluster') {
782+
loading = false; // allow loadRes to run (gen counter discards stale results)
783+
await loadRes(currentRes, resUrls[currentRes]);
784+
} else {
785+
cachedBounds = null; // force re-query
786+
await loadViewportSamples();
787+
}
788+
});
789+
742790
// --- Camera change handler ---
743791
let timer = null;
744792
viewer.camera.changed.addEventListener(() => {

0 commit comments

Comments
 (0)