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";
}