@@ -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 ===
156181function 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
282306function 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