Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 71 additions & 22 deletions tutorials/progressive_globe.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ Circle size = log(sample count). Color = dominant data source.
}
.stat-box .val { font-weight: bold; font-size: 16px; font-family: monospace; display: block; }
.stat-box .lbl { color: #aaa; font-size: 11px; }
.legend { display: flex; gap: 10px; flex-wrap: wrap; font-size: 12px; }
.legend-item { display: flex; align-items: center; gap: 3px; }
.legend { display: flex; gap: 8px; flex-wrap: wrap; font-size: 12px; }
.legend-item { display: flex; align-items: center; gap: 3px; cursor: pointer; user-select: none; }
.legend-item.disabled { opacity: 0.3; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.legend-item input[type="checkbox"] { margin: 0; width: 12px; height: 12px; cursor: pointer; }
.source-badge { color: white; padding: 2px 8px; border-radius: 10px; font-size: 0.8em; white-space: nowrap; }
.cluster-card { border-left: 4px solid #ccc; padding: 10px 12px; background: white; border-radius: 0 6px 6px 0; }
.sample-row { padding: 6px 0; border-bottom: 1px solid #eee; line-height: 1.4; }
Expand Down Expand Up @@ -99,16 +101,16 @@ Circle size = log(sample count). Color = dominant data source.
<div class="panel-section">
<div class="stats-compact">
<div class="stat-box"><span id="sPhase" class="val">Loading...</span><span class="lbl">Resolution</span></div>
<div class="stat-box"><span id="sPoints" class="val">0</span><span id="sPointsLbl" class="lbl">Clusters</span></div>
<div class="stat-box"><span id="sSamples" class="val">0</span><span class="lbl">Samples</span></div>
<div class="stat-box"><span id="sPoints" class="val">0</span><span id="sPointsLbl" class="lbl">Clusters Loaded</span></div>
<div class="stat-box"><span id="sSamples" class="val">0</span><span id="sSamplesLbl" class="lbl">Samples Loaded</span></div>
<div class="stat-box"><span id="sTime" class="val">-</span><span class="lbl">Load Time</span></div>
</div>
<div style="margin-top: 8px;">
<div class="legend">
<span class="legend-item"><span class="legend-dot" style="background:#3366CC"></span> SESAR</span>
<span class="legend-item"><span class="legend-dot" style="background:#DC3912"></span> OpenContext</span>
<span class="legend-item"><span class="legend-dot" style="background:#109618"></span> GEOME</span>
<span class="legend-item"><span class="legend-dot" style="background:#FF9900"></span> Smithsonian</span>
<div class="legend" id="sourceFilter">
<label class="legend-item"><input type="checkbox" value="SESAR" checked><span class="legend-dot" style="background:#3366CC"></span> SESAR</label>
<label class="legend-item"><input type="checkbox" value="OPENCONTEXT" checked><span class="legend-dot" style="background:#DC3912"></span> OpenContext</label>
<label class="legend-item"><input type="checkbox" value="GEOME" checked><span class="legend-dot" style="background:#109618"></span> GEOME</label>
<label class="legend-item"><input type="checkbox" value="SMITHSONIAN" checked><span class="legend-dot" style="background:#FF9900"></span> Smithsonian</label>
</div>
</div>
<div style="margin-top: 8px; display: flex; gap: 8px; align-items: center;">
Expand Down Expand Up @@ -152,6 +154,29 @@ SOURCE_NAMES = ({
GEOME: 'GEOME', SMITHSONIAN: 'Smithsonian'
})

// === Source URL: resolve pid to original repository ===
function sourceUrl(pid) {
if (!pid) return null;
// All sources resolve via n2t.net:
// ARK pids (OpenContext, GEOME, Smithsonian) → n2t.net/ark:/...
// IGSN pids (SESAR) → n2t.net/IGSN:...
return `https://n2t.net/${pid}`;
}

// === Source Filter: get active sources and build SQL clause ===
function getActiveSources() {
const checks = document.querySelectorAll('#sourceFilter input[type="checkbox"]');
return Array.from(checks).filter(c => c.checked).map(c => c.value);
}

function sourceFilterSQL(col) {
const active = getActiveSources();
if (active.length === 0) return ' AND 1=0'; // nothing checked = show nothing
if (active.length === 4) return ''; // all checked = no filter
const list = active.map(s => `'${s}'`).join(',');
return ` AND ${col} IN (${list})`;
}

// === URL State: encode/decode globe state in hash fragment ===
function parseNum(val, def, min, max) {
if (val == null) return def;
Expand Down Expand Up @@ -195,13 +220,14 @@ function buildHash(v) {
}

// === Helpers: update DOM imperatively (no OJS reactivity) ===
function updateStats(phase, points, samples, time, pointsLabel) {
function updateStats(phase, points, samples, time, pointsLabel, samplesLabel) {
const s = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; };
s('sPhase', phase);
s('sPoints', typeof points === 'string' ? points : points.toLocaleString());
s('sSamples', typeof samples === 'string' ? samples : samples.toLocaleString());
if (time != null) s('sTime', time);
if (pointsLabel) s('sPointsLbl', pointsLabel);
if (samplesLabel) s('sSamplesLbl', samplesLabel);
}

function updatePhaseMsg(text, type) {
Expand Down Expand Up @@ -245,6 +271,7 @@ function updateSampleCard(sample) {
const placeStr = Array.isArray(placeParts) && placeParts.length > 0
? placeParts.filter(Boolean).join(' › ')
: '';
const srcUrl = sourceUrl(sample.pid);
el.innerHTML = `<h4>Sample</h4>
<div class="cluster-card" style="border-left-color: ${color}">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
Expand All @@ -258,6 +285,7 @@ function updateSampleCard(sample) {
</div>
${placeStr ? `<div style="font-size: 12px; color: #555; margin-bottom: 4px;">${placeStr}</div>` : ''}
${sample.result_time ? `<div style="font-size: 11px; color: #888;">Date: ${sample.result_time}</div>` : ''}
${srcUrl ? `<div style="margin-top: 4px;"><a class="detail-link" href="${srcUrl}" target="_blank" rel="noopener noreferrer">View at ${name} →</a></div>` : ''}
<div id="sampleDetail" class="detail-loading">Loading full details...</div>
</div>`;
}
Expand All @@ -272,10 +300,7 @@ function updateSampleDetail(detail) {
const desc = detail.description
? (detail.description.length > 300 ? detail.description.slice(0, 300) + '...' : detail.description)
: '';
el.innerHTML = `${desc ? `<div style="font-size: 12px; color: #444; margin-top: 6px; line-height: 1.4;">${desc}</div>` : ''}
<div style="margin-top: 8px;">
<a class="detail-link" href="zenodo_isamples_analysis.html" target="_blank" rel="noopener noreferrer">Open in Analysis Tool →</a>
</div>`;
el.innerHTML = `${desc ? `<div style="font-size: 12px; color: #444; margin-top: 6px; line-height: 1.4;">${desc}</div>` : ''}`;
}

function updateSamples(samples) {
Expand All @@ -293,9 +318,10 @@ function updateSamples(samples) {
const desc = Array.isArray(placeParts) && placeParts.length > 0
? placeParts.filter(Boolean).join(' › ')
: '';
const sUrl = sourceUrl(s.pid);
h += `<div class="sample-row">
<div style="display: flex; align-items: center; gap: 6px;">
<span class="sample-label">${s.label || s.pid}</span>
${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>`}
<span class="source-badge" style="background: ${color}; font-size: 10px;">${name}</span>
</div>
${desc ? `<div class="sample-desc">${desc}</div>` : ''}
Expand Down Expand Up @@ -442,6 +468,7 @@ viewer = {
FROM read_parquet('${lite_url}')
WHERE latitude BETWEEN ${meta.lat - delta} AND ${meta.lat + delta}
AND longitude BETWEEN ${meta.lng - delta} AND ${meta.lng + delta}
${sourceFilterSQL('source')}
LIMIT 30
`);
updateSamples(samples);
Expand All @@ -468,6 +495,7 @@ phase1 = {
SELECT h3_cell, sample_count, center_lat, center_lng,
dominant_source, source_count
FROM read_parquet('${h3_res4_url}')
WHERE 1=1${sourceFilterSQL('dominant_source')}
`);

const scalar = new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.5);
Expand All @@ -494,7 +522,7 @@ phase1 = {
performance.measure('p1', 'p1-start', 'p1-end');
const elapsed = performance.getEntriesByName('p1').pop().duration;

updateStats('H3 Res4', data.length, totalSamples, `${(elapsed/1000).toFixed(1)}s`, 'Global Clusters');
updateStats('H3 Res4', data.length, totalSamples, `${(elapsed/1000).toFixed(1)}s`, 'Clusters Loaded', 'Samples Loaded');
updatePhaseMsg(`${data.length.toLocaleString()} clusters, ${totalSamples.toLocaleString()} samples. Zoom in for finer detail.`, 'done');
console.log(`Phase 1: ${data.length} clusters in ${elapsed.toFixed(0)}ms`);

Expand Down Expand Up @@ -527,8 +555,9 @@ zoomWatcher = {
let cachedData = null; // array of rows

// --- H3 cluster loading (existing logic) ---
let loadResGen = 0; // generation counter to discard stale results
const loadRes = async (res, url) => {
if (loading) return;
const gen = ++loadResGen; // claim a generation
loading = true;
updatePhaseMsg(`Loading H3 res${res}...`, 'loading');

Expand All @@ -538,8 +567,10 @@ zoomWatcher = {
SELECT h3_cell, sample_count, center_lat, center_lng,
dominant_source, source_count
FROM read_parquet('${url}')
WHERE 1=1${sourceFilterSQL('dominant_source')}
`);

if (gen !== loadResGen) return; // stale — a newer call superseded this one
viewer.h3Points.removeAll();
const scalar = new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.3);
let total = 0;
Expand Down Expand Up @@ -567,7 +598,7 @@ zoomWatcher = {
// Show viewport count immediately
const bounds = getViewportBounds();
const inView = countInViewport(bounds);
updateStats(`H3 Res${res}`, `${inView.clusters.toLocaleString()} / ${data.length.toLocaleString()}`, inView.samples.toLocaleString(), `${(elapsed/1000).toFixed(1)}s`, 'In View / Total');
updateStats(`H3 Res${res}`, `${inView.clusters.toLocaleString()} / ${data.length.toLocaleString()}`, inView.samples.toLocaleString(), `${(elapsed/1000).toFixed(1)}s`, 'Clusters in View / Loaded', 'Samples in View');
updatePhaseMsg(`${data.length.toLocaleString()} clusters, ${total.toLocaleString()} samples. ${res < 8 ? 'Zoom in for finer detail.' : 'Zoom closer for individual samples.'}`, 'done');

currentRes = res;
Expand Down Expand Up @@ -649,6 +680,7 @@ zoomWatcher = {
FROM read_parquet('${lite_url}')
WHERE latitude BETWEEN ${padded.south} AND ${padded.north}
AND longitude BETWEEN ${padded.west} AND ${padded.east}
${sourceFilterSQL('source')}
LIMIT ${POINT_BUDGET}
`);
performance.mark('sp-e');
Expand All @@ -667,7 +699,7 @@ zoomWatcher = {

renderSamplePoints(cachedData, bounds);

updateStats('Samples', cachedData.length, cachedData.length, `${(elapsed/1000).toFixed(1)}s`, 'In View');
updateStats('Samples', cachedData.length, cachedData.length, `${(elapsed/1000).toFixed(1)}s`, 'Samples in View', 'Samples in View');
updatePhaseMsg(`${cachedData.length.toLocaleString()} individual samples. Click one for details.`, 'done');
console.log(`Point mode: ${cachedData.length} samples in ${elapsed.toFixed(0)}ms`);

Expand Down Expand Up @@ -730,14 +762,31 @@ zoomWatcher = {
const inView = countInViewport(bounds);
const total = viewer._clusterTotal;
if (total) {
updateStats(`H3 Res${currentRes}`, `${inView.clusters.toLocaleString()} / ${total.clusters.toLocaleString()}`, inView.samples.toLocaleString(), '—', 'In View / Total');
updateStats(`H3 Res${currentRes}`, `${inView.clusters.toLocaleString()} / ${total.clusters.toLocaleString()}`, inView.samples.toLocaleString(), '—', 'Clusters in View / Loaded', 'Samples in View');
} else {
updateStats(`H3 Res${currentRes}`, viewer.h3Points.length, '—', '—', 'Global Clusters');
updateStats(`H3 Res${currentRes}`, viewer.h3Points.length, '—', '—', 'Clusters Loaded', 'Samples Loaded');
}
updatePhaseMsg(`${inView.clusters.toLocaleString()} clusters in view. Zoom closer for individual samples.`, 'done');
console.log('Exited point mode');
}

// --- Source filter change handler ---
const resUrls = { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url };
document.getElementById('sourceFilter').addEventListener('change', async () => {
// Toggle visual state on labels
document.querySelectorAll('#sourceFilter .legend-item').forEach(li => {
const cb = li.querySelector('input');
li.classList.toggle('disabled', !cb.checked);
});
if (mode === 'cluster') {
loading = false; // allow loadRes to run (gen counter discards stale results)
await loadRes(currentRes, resUrls[currentRes]);
} else {
cachedBounds = null; // force re-query
await loadViewportSamples();
}
});

// --- Camera change handler ---
let timer = null;
viewer.camera.changed.addEventListener(() => {
Expand Down Expand Up @@ -780,7 +829,7 @@ zoomWatcher = {
const inView = countInViewport(bounds);
const total = viewer._clusterTotal;
if (total) {
updateStats(`H3 Res${currentRes}`, `${inView.clusters.toLocaleString()} / ${total.clusters.toLocaleString()}`, inView.samples.toLocaleString(), null, 'In View / Total');
updateStats(`H3 Res${currentRes}`, `${inView.clusters.toLocaleString()} / ${total.clusters.toLocaleString()}`, inView.samples.toLocaleString(), null, 'Clusters in View / Loaded', 'Samples in View');
}
}

Expand Down
Loading