Interactive Explorer – iSamples
// === Zoom watcher: H3 cluster mode + individual sample point mode ===
zoomWatcher = {
if (!phase1) return;
if (!facetFilters) return; // wait for facet checkboxes
// --- State ---
let mode = 'cluster'; // 'cluster' or 'point'
let currentRes = 4;
let loading = false;
let requestId = 0; // stale-request guard
// clusterDataCache stored on viewer._clusterData (set by phase1 and loadRes)
// Hysteresis thresholds to avoid flicker
const ENTER_POINT_ALT = 120000; // 120 km → enter point mode
const EXIT_POINT_ALT = 180000; // 180 km → exit point mode
const POINT_BUDGET = 5000;
// Viewport cache: avoid re-querying same area
let cachedBounds = null; // { south, north, west, east }
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) => {
const gen = ++loadResGen; // claim a generation
loading = true;
updatePhaseMsg(`Loading H3 res${res}...`, 'loading');
try {
performance.mark(`r${res}-s`);
const data = await db.query(`
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;
for (const row of data) {
total += row.sample_count;
const size = Math.min(3 + Math.log10(row.sample_count) * 3.5, 18);
viewer.h3Points.add({
id: { count: row.sample_count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: res },
position: Cesium.Cartesian3.fromDegrees(row.center_lng, row.center_lat, 0),
pixelSize: size,
color: Cesium.Color.fromCssColorString(SOURCE_COLORS[row.dominant_source] || '#666').withAlpha(0.85),
scaleByDistance: scalar,
});
}
// Cache for viewport counting
viewer._clusterData = Array.from(data);
viewer._clusterTotal = { clusters: data.length, samples: total };
performance.mark(`r${res}-e`);
performance.measure(`r${res}`, `r${res}-s`, `r${res}-e`);
const elapsed = performance.getEntriesByName(`r${res}`).pop().duration;
// 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`, '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;
console.log(`Res${res}: ${data.length} clusters in ${elapsed.toFixed(0)}ms`);
} catch(err) {
console.error(`Failed to load res${res}:`, err);
updatePhaseMsg(`Failed to load H3 res${res} — try zooming again.`, 'loading');
} finally {
loading = false;
}
};
// --- Get camera viewport bounds ---
function getViewportBounds() {
const rect = viewer.camera.computeViewRectangle(viewer.scene.globe.ellipsoid);
if (!rect) return null;
return {
south: Cesium.Math.toDegrees(rect.south),
north: Cesium.Math.toDegrees(rect.north),
west: Cesium.Math.toDegrees(rect.west),
east: Cesium.Math.toDegrees(rect.east)
};
}
// --- Count clusters visible in current viewport (from cached array) ---
function countInViewport(bounds) {
const cache = viewer._clusterData;
if (!bounds || !cache || cache.length === 0) return { clusters: 0, samples: 0 };
const { south, north, west, east } = bounds;
const wrapLng = west > east; // dateline crossing
let clusters = 0, samples = 0;
for (const row of cache) {
if (row.center_lat < south || row.center_lat > north) continue;
if (wrapLng ? (row.center_lng < west && row.center_lng > east) : (row.center_lng < west || row.center_lng > east)) continue;
clusters++;
samples += row.sample_count;
}
return { clusters, samples };
}
// --- Check if viewport is within cached bounds ---
function isWithinCache(bounds) {
if (!cachedBounds || !bounds) return false;
return bounds.south >= cachedBounds.south &&
bounds.north <= cachedBounds.north &&
bounds.west >= cachedBounds.west &&
bounds.east <= cachedBounds.east;
}
// --- Load individual samples for current viewport ---
async function loadViewportSamples() {
const myReqId = ++requestId;
const bounds = getViewportBounds();
if (!bounds) return;
// If viewport is within cached area, just re-render from cache
if (isWithinCache(bounds) && cachedData) {
renderSamplePoints(cachedData, bounds);
return;
}
// Fetch with 30% padding for smooth panning
const latPad = (bounds.north - bounds.south) * 0.3;
const lngPad = (bounds.east - bounds.west) * 0.3;
const padded = {
south: bounds.south - latPad,
north: bounds.north + latPad,
west: bounds.west - lngPad,
east: bounds.east + lngPad
};
updatePhaseMsg('Loading individual samples...', 'loading');
try {
performance.mark('sp-s');
const facetActive = hasFacetFilters();
const facetSQL = facetActive ? facetFilterSQL() : '';
let query;
if (facetActive) {
query = `
SELECT l.pid, l.label, l.source, l.latitude, l.longitude,
l.place_name, l.result_time, f.material, f.context
FROM read_parquet('${lite_url}') l
JOIN read_parquet('${facets_url}') f ON l.pid = f.pid
WHERE l.latitude BETWEEN ${padded.south} AND ${padded.north}
AND l.longitude BETWEEN ${padded.west} AND ${padded.east}
${sourceFilterSQL('l.source')}
${facetSQL}
LIMIT ${POINT_BUDGET}
`;
} else {
query = `
SELECT pid, label, source, latitude, longitude,
place_name, result_time
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}
`;
}
const data = await db.query(query);
performance.mark('sp-e');
performance.measure('sp', 'sp-s', 'sp-e');
const elapsed = performance.getEntriesByName('sp').pop().duration;
// Stale guard: discard if a newer request was issued
if (myReqId !== requestId) {
console.log(`Discarding stale sample response (req ${myReqId}, current ${requestId})`);
return;
}
// Cache the padded bounds + data
cachedBounds = padded;
cachedData = Array.from(data);
renderSamplePoints(cachedData, bounds);
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`);
} catch(err) {
if (myReqId !== requestId) return;
console.error("Viewport sample query failed:", err);
updatePhaseMsg('Sample query failed — try again.', 'loading');
}
}
// --- Render sample points on globe ---
function renderSamplePoints(data, bounds) {
viewer.samplePoints.removeAll();
const scalar = new Cesium.NearFarScalar(1e2, 8, 2e5, 3);
for (const row of data) {
const color = SOURCE_COLORS[row.source] || '#666';
viewer.samplePoints.add({
id: {
type: 'sample',
pid: row.pid,
label: row.label,
source: row.source,
lat: row.latitude,
lng: row.longitude,
place_name: row.place_name,
result_time: row.result_time
},
position: Cesium.Cartesian3.fromDegrees(row.longitude, row.latitude, 0),
pixelSize: 6,
color: Cesium.Color.fromCssColorString(color).withAlpha(0.9),
scaleByDistance: scalar,
});
}
}
// --- Mode transitions ---
function enterPointMode(pushHistory) {
mode = 'point';
viewer._globeState.mode = 'point';
viewer.h3Points.show = false;
viewer.samplePoints.show = true;
if (pushHistory !== false) history.pushState(null, '', buildHash(viewer));
loadViewportSamples();
console.log('Entered point mode');
}
function exitPointMode(pushHistory) {
mode = 'cluster';
viewer._globeState.mode = 'cluster';
viewer.samplePoints.show = false;
viewer.samplePoints.removeAll();
viewer.h3Points.show = true;
if (pushHistory !== false) history.pushState(null, '', buildHash(viewer));
cachedBounds = null;
cachedData = null;
// Restore cluster stats with viewport count
const bounds = getViewportBounds();
const inView = countInViewport(bounds);
const total = viewer._clusterTotal;
if (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, '—', '—', '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();
}
});
// --- Material/Context filter change handler ---
const facetNote = document.getElementById('facetNote');
function handleFacetFilterChange() {
const active = hasFacetFilters();
if (facetNote) facetNote.style.display = (active && mode === 'cluster') ? 'block' : 'none';
if (mode === 'point') {
cachedBounds = null;
loadViewportSamples();
}
}
document.getElementById('materialFilterBody').addEventListener('change', handleFacetFilterChange);
document.getElementById('contextFilterBody').addEventListener('change', handleFacetFilterChange);
// --- Camera change handler ---
let timer = null;
viewer.camera.changed.addEventListener(() => {
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
const h = viewer.camera.positionCartographic.height;
// Determine target mode with hysteresis
const targetMode = h < ENTER_POINT_ALT ? 'point'
: h > EXIT_POINT_ALT ? 'cluster'
: mode;
if (targetMode === 'point' && mode !== 'point') {
// Make sure we're at res8 clusters before transitioning
if (currentRes !== 8 && !loading) {
await loadRes(8, h3_res8_url);
}
enterPointMode();
} else if (targetMode === 'cluster' && mode !== 'cluster') {
exitPointMode();
// Reload appropriate resolution
const target = h > 3000000 ? 4 : h > 300000 ? 6 : 8;
if (target !== currentRes && !loading) {
await loadRes(target, { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }[target]);
}
} else if (targetMode === 'point') {
// Already in point mode — update viewport samples
loadViewportSamples();
} else {
// Cluster mode — check if resolution should change
const target = h > 3000000 ? 4 : h > 300000 ? 6 : 8;
if (target !== currentRes && !loading) {
await loadRes(target, { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }[target]);
}
}
// Update viewport cluster count (cluster mode only; point mode already shows viewport count)
if (mode === 'cluster' && viewer._clusterData) {
const bounds = getViewportBounds();
const inView = countInViewport(bounds);
const total = viewer._clusterTotal;
if (total) {
updateStats(`H3 Res${currentRes}`, `${inView.clusters.toLocaleString()} / ${total.clusters.toLocaleString()}`, inView.samples.toLocaleString(), null, 'Clusters in View / Loaded', 'Samples in View');
}
}
// Update URL hash (replaceState for continuous movement)
if (!viewer._suppressHashWrite) {
history.replaceState(null, '', buildHash(viewer));
}
}, 600);
});
viewer.camera.percentageChanged = 0.1;
// --- Handle browser back/forward ---
window.addEventListener('hashchange', async () => {
const state = readHash();
if (state.lat == null || state.lng == null) return;
viewer._suppressHashWrite = true;
clearTimeout(viewer._suppressTimer);
viewer.camera.cancelFlight();
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(state.lng, state.lat, state.alt || 20000000),
orientation: {
heading: Cesium.Math.toRadians(state.heading),
pitch: Cesium.Math.toRadians(state.pitch)
},
duration: 1.5,
});
// After flight settles, force mode and clear suppress flag
viewer._suppressTimer = setTimeout(() => {
viewer._suppressHashWrite = false;
const s = readHash();
if (s.mode === 'point' && mode !== 'point') enterPointMode(false);
else if (s.mode !== 'point' && mode === 'point') exitPointMode(false);
}, 2000);
// Handle pid selection
if (state.pid) {
viewer._globeState.selectedPid = state.pid;
try {
const sample = await db.query(`
SELECT pid, label, source, latitude, longitude, place_name, result_time
FROM read_parquet('${lite_url}')
WHERE pid = '${state.pid.replace(/'/g, "''")}'
LIMIT 1
`);
if (sample && sample.length > 0) {
const s = sample[0];
updateSampleCard({
pid: s.pid, label: s.label, source: s.source,
lat: s.latitude, lng: s.longitude,
place_name: s.place_name, result_time: s.result_time
});
}
} catch(err) {
console.error("Hash pid query failed:", err);
}
} else {
viewer._globeState.selectedPid = null;
updateClusterCard(null);
}
});
// --- Share button ---
const shareBtn = document.getElementById('shareBtn');
if (shareBtn) {
shareBtn.addEventListener('click', async () => {
history.replaceState(null, '', buildHash(viewer));
try {
await navigator.clipboard.writeText(location.href);
const toast = document.getElementById('shareToast');
if (toast) {
toast.style.opacity = '1';
setTimeout(() => { toast.style.opacity = '0'; }, 2000);
}
} catch(err) {
prompt('Copy this link:', location.href);
}
});
}
// --- Search handler ---
const searchBtn = document.getElementById('searchBtn');
const searchInput = document.getElementById('sampleSearch');
const searchResults = document.getElementById('searchResults');
async function doSearch() {
const term = searchInput.value.trim();
if (!term || term.length < 2) {
searchResults.textContent = 'Type at least 2 characters';
return;
}
searchResults.textContent = 'Searching...';
try {
const escaped = term.replace(/'/g, "''");
const results = await db.query(`
SELECT pid, label, source, latitude, longitude, place_name
FROM read_parquet('${lite_url}')
WHERE label ILIKE '%${escaped}%'
${sourceFilterSQL('source')}
LIMIT 50
`);
if (results.length === 0) {
searchResults.textContent = `No results for "${term}"`;
return;
}
searchResults.textContent = `${results.length}${results.length === 50 ? '+' : ''} results for "${term}"`;
// Show results in the samples panel
const sampEl = document.getElementById('samplesSection');
if (sampEl) {
let h = `<h4>Search: "${term}" (${results.length})</h4>`;
for (const s of results) {
const color = SOURCE_COLORS[s.source] || '#666';
const name = SOURCE_NAMES[s.source] || s.source;
const sUrl = sourceUrl(s.pid);
h += `<div class="sample-row" style="cursor: pointer;" data-lat="${s.latitude}" data-lng="${s.longitude}" data-pid="${s.pid}">
<div style="display: flex; align-items: center; gap: 6px;">
${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>
</div>`;
}
sampEl.innerHTML = h;
// Click search result → fly to it
sampEl.querySelectorAll('.sample-row[data-lat]').forEach(row => {
row.addEventListener('click', (e) => {
if (e.target.tagName === 'A') return; // let links work
const lat = parseFloat(row.dataset.lat);
const lng = parseFloat(row.dataset.lng);
const pid = row.dataset.pid;
if (!isNaN(lat) && !isNaN(lng)) {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lng, lat, 50000),
duration: 1.5
});
}
});
});
}
// Fly to the first result
if (results[0].latitude && results[0].longitude) {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(results[0].longitude, results[0].latitude, 200000),
duration: 1.5
});
}
} catch(err) {
console.error("Search failed:", err);
searchResults.textContent = `Search error: ${err.message}`;
}
}
if (searchBtn) searchBtn.addEventListener('click', doSearch);
if (searchInput) searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doSearch();
});
// --- Deep-link: restore selection from initial hash ---
const ih = viewer._initialHash;
if (ih.pid) {
viewer._globeState.selectedPid = ih.pid;
try {
const sample = await db.query(`
SELECT pid, label, source, latitude, longitude, place_name, result_time
FROM read_parquet('${lite_url}')
WHERE pid = '${ih.pid.replace(/'/g, "''")}'
LIMIT 1
`);
if (sample && sample.length > 0) {
const s = sample[0];
updateSampleCard({
pid: s.pid, label: s.label, source: s.source,
lat: s.latitude, lng: s.longitude,
place_name: s.place_name, result_time: s.result_time
});
const detail = await db.query(`
SELECT description FROM read_parquet('${wide_url}')
WHERE pid = '${ih.pid.replace(/'/g, "''")}'
LIMIT 1
`);
if (detail && detail.length > 0) updateSampleDetail(detail[0]);
else updateSampleDetail({ description: '' });
}
} catch(err) {
console.error("Deep-link pid query failed:", err);
}
}
// Enable hash writing now that everything is initialized
viewer._suppressHashWrite = false;
return "active";
}