diff --git a/explorer.qmd b/explorer.qmd index d73f9e3..b3cb2d8 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -1368,8 +1368,10 @@ zoomWatcher = { const POINT_BUDGET = DEFAULT_POINT_BUDGET; // Viewport cache: avoid re-querying same area - let cachedBounds = null; // { south, north, west, east } - let cachedData = null; // array of rows + let cachedBounds = null; // { south, north, west, east } + let cachedData = null; // array of rows + let cachedTotalCount = null; // real in-view count (issue #206) + let cachedCapReached = false; // whether the LIMIT was hit (issue #206) // --- H3 cluster loading (existing logic) --- // @@ -1505,9 +1507,14 @@ zoomWatcher = { const bounds = getViewportBounds(); if (!bounds) return; - // If viewport is within cached area, just re-render from cache + // If viewport is within cached area, just re-render from cache. + // Re-apply the cached count/cap state to the stat boxes so panning + // inside the padded cache window doesn't leave stale figures on + // screen (#206 Codex review). if (isWithinCache(bounds) && cachedData) { renderSamplePoints(cachedData, bounds); + const total = cachedTotalCount != null ? cachedTotalCount : cachedData.length; + updateStats('Samples', cachedData.length, total, null, 'Samples Rendered', 'Samples in View'); return; } @@ -1527,14 +1534,22 @@ zoomWatcher = { performance.mark('sp-s'); // facetFilterSQL() returns a portable `pid IN (...)` predicate, // so the same query works whether or not facet filters are active. - const query = ` - SELECT pid, label, source, latitude, longitude, - place_name, result_time - FROM read_parquet('${lite_url}') + // + // ORDER BY pid (issue #206): without explicit ordering, which + // POINT_BUDGET-worth of rows the LIMIT returns is undefined and + // can differ across browsers/sessions for the same query. + const whereClause = ` WHERE latitude BETWEEN ${padded.south} AND ${padded.north} AND longitude BETWEEN ${padded.west} AND ${padded.east} ${sourceFilterSQL('source')} ${facetFilterSQL()} + `; + const query = ` + SELECT pid, label, source, latitude, longitude, + place_name, result_time + FROM read_parquet('${lite_url}') + ${whereClause} + ORDER BY pid LIMIT ${POINT_BUDGET} `; const data = await db.query(query); @@ -1548,15 +1563,55 @@ zoomWatcher = { return; } - // Cache the padded bounds + data + // Real in-view count (issue #206). Without this, the "Samples in + // View" counter caps at POINT_BUDGET, silently understating dense + // regions (Cyprus/Polis: real count 23,421 vs displayed 5,000). + // Only query when we hit the cap — when LIMIT returned fewer + // rows than the budget, that count IS the real count and a + // second query would be wasteful. + let totalCount = data.length; + let capReached = false; + if (data.length >= POINT_BUDGET) { + try { + const countRow = await db.query(` + SELECT count(*) AS n + FROM read_parquet('${lite_url}') + ${whereClause} + `); + if (myReqId !== requestId) return; // stale guard + totalCount = Number(countRow[0]?.n ?? data.length); + capReached = totalCount > data.length; + } catch(err) { + // Stale guard before any state mutation/logging: + // a newer request may have started while count was in + // flight (Codex review of PR #210). + if (myReqId !== requestId) return; + // Don't fail the whole load if the count query fails; + // just fall back to the displayed-count behavior. + console.warn("Real-count query failed; falling back to rendered count:", err); + } + } + + // Cache the padded bounds + data + count state so cache-hit + // pans preserve the same stat-box display (Codex review). cachedBounds = padded; cachedData = Array.from(data); + cachedTotalCount = totalCount; + cachedCapReached = capReached; 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`); + // Stat boxes: LEFT (sPoints) always shows rendered count under + // "Samples Rendered", RIGHT (sSamples) always shows real in-view + // total under "Samples in View". Stable labels even when both + // numbers are equal (Codex review preference: avoids label + // flipping as the user zooms across the cap boundary). + updateStats('Samples', cachedData.length, totalCount, `${(elapsed/1000).toFixed(1)}s`, 'Samples Rendered', 'Samples in View'); + const phaseMsg = capReached + ? `${totalCount.toLocaleString()} samples in view (showing ${cachedData.length.toLocaleString()} — zoom in for more). Click one for details.` + : `${cachedData.length.toLocaleString()} individual samples. Click one for details.`; + updatePhaseMsg(phaseMsg, 'done'); + console.log(`Point mode: rendered ${cachedData.length} of ${totalCount} samples in ${elapsed.toFixed(0)}ms${capReached ? ' (cap reached)' : ''}`); } catch(err) { if (myReqId !== requestId) return; @@ -1614,6 +1669,8 @@ zoomWatcher = { if (pushHistory !== false) history.pushState(null, '', buildHash(viewer)); cachedBounds = null; cachedData = null; + cachedTotalCount = null; + cachedCapReached = false; // Restore cluster stats with viewport count const bounds = getViewportBounds(); @@ -1883,6 +1940,8 @@ zoomWatcher = { if (applied) await tryEnterPointModeIfNeeded(); } else { cachedBounds = null; + cachedTotalCount = null; + cachedCapReached = false; await loadViewportSamples(); } refreshFacetCounts(); @@ -1948,6 +2007,8 @@ zoomWatcher = { writeQueryState(); if (mode === 'point') { cachedBounds = null; + cachedTotalCount = null; + cachedCapReached = false; await loadViewportSamples(); } refreshFacetCounts();