Skip to content
Open
Show file tree
Hide file tree
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
146 changes: 135 additions & 11 deletions explorer.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,50 @@ function viewerBboxSQL(latCol, lngCol, padFactor) {
return ` AND ${latCol} BETWEEN ${south} AND ${north} AND ${lngClause}`;
}

// True when the current camera shows ≈the whole world (initial zoom-out
// state), or when the camera can't compute a rectangle at all (off-globe).
// In either case a viewport bbox predicate would be a no-op, so fast-paths
// that depend on "no spatial constraint" (e.g. the pre-aggregated facet
// cross-filter cube) remain valid and can be used.
//
// Used by `updateCrossFilteredCounts` (B1, issue #234 step 3) to decide
// whether to gate the cube fast-path off and JOIN to `lite_url` for a
// bbox-scoped slow-path query.
//
// Implementation note — why an altitude shortcut:
// At high altitudes Cesium's `computeViewRectangle` saturates at the
// sphere's extent for some camera angles but returns a tight bounding
// rect for others (e.g. at alt=5000 km over the equator the rect is
// ≈hemispheric, not full). The user's intuition is "max zoom-out = global
// counts," and at the explorer's default `globalRect` (which fits the
// `[-180,-60,180,80]` data extent and sits roughly at altitude ≈12000 km)
// the per-source legend totals should match the baselines, not jiggle on
// tiny camera rotations. The altitude check captures that "I'm zoomed all
// the way out" intent regardless of the exact rect Cesium reports. Below
// the altitude threshold we still let the rect vote "global" so flyTo
// to a true world-rect destination keeps working.
//
// Constants live inside the function (not top-level `const`) because
// Quarto `{ojs}` cells reject top-level `const`/`let`/`var` and a bad
// declaration cascades — breaking every dependent cell (memory note
// `feedback_qmd_ojs_top_level`).
function isGlobalView() {
const ALT_THRESHOLD_M = 1.0e7; // 10,000 km
const LNG_PAD_DEG = 2;
const LAT_PAD_DEG = 2;
if (typeof viewer === 'undefined') return true;
let h = NaN;
try { h = viewer.camera.positionCartographic.height; } catch { /* mid-init */ }
if (Number.isFinite(h) && h > ALT_THRESHOLD_M) return true;
const b = paddedViewportBounds(0);
if (!b) return true; // off-globe → no meaningful bbox
if (b.west > b.east) return false; // antimeridian-wrapping is never "global"
return b.west <= -180 + LNG_PAD_DEG
&& b.east >= 180 - LNG_PAD_DEG
&& b.south <= -90 + LAT_PAD_DEG
&& b.north >= 90 - LAT_PAD_DEG;
}

// === Cross-filter facet count UI helpers ===
function applyFacetCounts(facetKey, countsMap) {
const baseline = (viewer && viewer._baselineCounts) ? viewer._baselineCounts[facetKey] : null;
Expand Down Expand Up @@ -2644,15 +2688,18 @@ zoomWatcher = {
};
}

function buildCrossFilterWhere(excludeFacet) {
// `colPrefix` lets callers qualify column names when the WHERE is going
// to be used inside a JOIN (B1 bbox-aware path). Defaults to '' so the
// pre-B1 single-table call sites keep working unchanged.
function buildCrossFilterWhere(excludeFacet, colPrefix = '') {
const { activeDims, sourceImpossible } = describeCrossFilters();
if (sourceImpossible && excludeFacet !== 'source') return '1=0';

const conds = activeDims
.filter(d => d.key !== excludeFacet)
.map(d => {
const list = d.values.map(v => `'${escSql(v)}'`).join(',');
return `${d.col} IN (${list})`;
return `${colPrefix}${d.col} IN (${list})`;
});

return conds.length > 0 ? conds.join(' AND ') : '1=1';
Expand All @@ -2662,17 +2709,40 @@ zoomWatcher = {
if (myReq !== facetCountsReqId) return;
const { dims, activeDims, totalActiveValues, sourceImpossible } = describeCrossFilters();

if (!sourceImpossible && activeDims.length === 0) {
// --- B1 (issue #234 step 3): bbox-aware counts ---
//
// Snapshot the viewport state at function entry. If the camera moves
// after this, a fresh `refreshFacetCounts()` call (from the moveEnd
// listener) bumps `facetCountsReqId` and supersedes this in-flight
// request — every `await` resume checks `myReq !== facetCountsReqId`.
//
// `isGlobalView()` true → no spatial constraint → cube fast-path and
// baseline early-return remain valid as before.
// `isGlobalView()` false → snapshot a bbox SQL fragment scoped to the
// `lite_url` lat/lon columns (which we JOIN to in the slow path,
// since `facets_url` carries no coordinates today). Cube fast-path
// is unconditionally gated off (it is pre-aggregated globally and
// can't answer viewport-scoped questions).
const isGlobal = isGlobalView();
const bboxSQL = isGlobal ? null : viewerBboxSQL('l.latitude', 'l.longitude', 0);

// Baseline early-return only applies when there is no filter AND no
// spatial constraint. In a non-global view with no facet filter, B1
// still wants per-value counts scoped to what's visible — fall
// through to the slow path with `where = '1=1'`.
if (!sourceImpossible && activeDims.length === 0 && bboxSQL === null) {
for (const d of dims) applyFacetCounts(d.key, null);
return;
}

markFacetCountsRecomputing();

// Cube fast-path: pre-aggregated globally, so it's valid only when
// the camera is at (or close to) the global view.
const singleActiveDim = !sourceImpossible
&& activeDims.length === 1 && activeDims[0].values.length === 1
? activeDims[0] : null;
if (singleActiveDim && totalActiveValues === 1) {
if (singleActiveDim && totalActiveValues === 1 && bboxSQL === null) {
try {
const filterCols = ['filter_source', 'filter_material', 'filter_context', 'filter_object_type'];
const filterColForKey = {
Expand Down Expand Up @@ -2708,21 +2778,40 @@ zoomWatcher = {
}

await Promise.all(dims.map(async (d) => {
const where = buildCrossFilterWhere(d.key);
try {
const rows = await db.query(`
SELECT ${d.col} AS value, COUNT(*) AS count
FROM read_parquet('${facets_url}')
WHERE ${where} AND ${d.col} IS NOT NULL
GROUP BY ${d.col}
`);
let rows;
if (bboxSQL) {
// B1 bbox-scoped slow path: JOIN facets_url to lite_url
// on pid so we can filter by lite.latitude / lite.longitude.
// facets_url has no coordinates of its own. Per-PR follow-up:
// if this JOIN turns out too slow in practice, bake a
// pre-joined parquet (decision Q1 in plan).
const where = buildCrossFilterWhere(d.key, 'f.');
rows = await db.query(`
SELECT f.${d.col} AS value, COUNT(*) AS count
FROM read_parquet('${facets_url}') f
JOIN read_parquet('${lite_url}') l ON l.pid = f.pid
WHERE ${where} AND f.${d.col} IS NOT NULL${bboxSQL}
GROUP BY f.${d.col}
`);
} else {
const where = buildCrossFilterWhere(d.key);
rows = await db.query(`
SELECT ${d.col} AS value, COUNT(*) AS count
FROM read_parquet('${facets_url}')
WHERE ${where} AND ${d.col} IS NOT NULL
GROUP BY ${d.col}
`);
}
if (myReq !== facetCountsReqId) return;
const map = new Map();
for (const r of rows) map.set(r.value, Number(r.count));
applyFacetCounts(d.key, map);
} catch (err) {
if (myReq !== facetCountsReqId) return;
console.warn(`Cross-filter count query failed for ${d.key}:`, err);
// Q3 in plan: on bbox-query throw, fall back to global
// baseline rather than clobber with an error indicator.
applyFacetCounts(d.key, null);
}
}));
Expand Down Expand Up @@ -2971,10 +3060,45 @@ zoomWatcher = {
// the "Samples in View" stat / phase-msg stay in lockstep with the
// table's row count even on small sub-10% pans that `camera.changed`
// doesn't fire for.
// B1 (issue #234 step 3): on the very first sign of a camera move,
// (a) mark the legend italic-stale so the user sees "checking…" the
// moment they grab the globe (instead of waiting moveEnd + 250 ms);
// (b) bump `facetCountsReqId` and clear the pending debounce so any
// in-flight or debounced `updateCrossFilteredCounts` for the
// PRE-MOVE viewport is invalidated.
//
// Without (b), a query that was already in flight at moveStart could
// resume after its SELECT lands, pass the stale guard, and call
// `applyFacetCounts` — which would WRITE OLD COUNTS for a viewport the
// user has already abandoned AND clear `.recomputing` before moveEnd
// schedules the new query, leaving the legend looking authoritative
// (no italic) but stale until moveEnd's 250 ms debounce fires.
// Codex round-1 review of PR #237 caught this.
//
// moveEnd's `refreshFacetCounts()` below will bump `facetCountsReqId`
// AGAIN — that's intentional and harmless: a second supersession of
// already-superseded work, debounce coalesced.
//
// We skip the mark/bump when there are no facet-count spans rendered
// yet (initial boot, before facet UI hydrates) to avoid DOM thrash
// and a no-op debounce reset during the very first camera-position
// events that fire before the OJS facet cells have settled.
viewer.camera.moveStart.addEventListener(() => {
if (!document.querySelector('.facet-count')) return;
markFacetCountsRecomputing();
clearTimeout(facetCountsDebounce);
++facetCountsReqId;
});

viewer.camera.moveEnd.addEventListener(() => {
if (!viewer._suppressHashWrite) {
history.replaceState(null, '', buildHash(viewer));
}
// B1: viewport-aware facet counts. Bouncing through refreshFacetCounts
// reuses the existing 250ms debounce + facetCountsReqId stale-guard,
// so bursts of moveEnd (drag-pan, wheel-zoom) coalesce into one query
// and any in-flight superseded query discards its result on resume.
refreshFacetCounts();
if (getMode() !== 'point') return;
const h = viewer.camera.positionCartographic.height;
if (h > EXIT_POINT_ALT) {
Expand Down
Loading
Loading