Skip to content
Merged
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
57 changes: 42 additions & 15 deletions explorer.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -3679,6 +3679,37 @@ zoomWatcher = {
document.getElementById('contextFilterBody').addEventListener('change', handleFacetFilterChange);
document.getElementById('objectTypeFilterBody').addEventListener('change', handleFacetFilterChange);

// --- Shared settled-camera tail (#208 smell 1b) ---
// The single reconciliation entry point both settled-camera listeners run
// once the camera has come to rest: refresh the cluster-mode "Samples in
// View" stat for the current viewport, then write the URL hash. Extracted
// so `moveEnd` runs the SAME cluster-stat refresh that `camera.changed`
// does — closing the sub-10%-pan gap where a small drag in cluster mode
// updated the URL (moveEnd fires) but left the "Samples in View" count
// stale (camera.changed's `percentageChanged=0.1` debounce didn't fire).
//
// Scope is deliberately minimal (Codex Q3, REFACTOR_PR4_PLAN.md §3): this
// touches NEITHER the mode-transition / resolution-reload logic (which
// stays camera.changed-only) NOR the facet/heatmap/point-exit logic (which
// stays moveEnd-only). The cluster-stat read is synchronous and cheap
// (counts already-loaded `_clusterData` against the padded viewport) and
// no-ops unless we're in cluster mode with data loaded.
function reconcileSettledCamera(v) {
// Cluster-mode viewport count only — point mode shows its own count via
// loadViewportSamples(). Padded bbox so the cluster "Samples in View"
// stat matches the samples table row total (issue #221 round 2).
if (getMode() === 'cluster' && v._clusterData) {
const inView = countInViewport(paddedViewportBounds(VIEWPORT_PAD_FACTOR));
const total = v._clusterTotal;
if (total) {
updateStats(`H3 Res${currentRes}`, `${inView.clusters.toLocaleString()} / ${total.clusters.toLocaleString()}`, inView.samples.toLocaleString(), null, 'Clusters in View / Loaded', 'Samples in View');
}
}
// _suppressHashWrite-gated default — the hashchange-flight path stays
// unaffected (same gate the two raw writers honored before).
writeGlobeHash(v);
}

// --- Camera change handler ---
let timer = null;
viewer.camera.changed.addEventListener(() => {
Expand Down Expand Up @@ -3756,20 +3787,11 @@ zoomWatcher = {
}
}

// Update viewport cluster count (cluster mode only; point mode
// already shows viewport count). Padded bbox so the cluster
// "Samples in View" stat matches the samples table row total
// (issue #221 round 2).
if (getMode() === 'cluster' && viewer._clusterData) {
const inView = countInViewport(paddedViewportBounds(VIEWPORT_PAD_FACTOR));
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)
writeGlobeHash(viewer);
// Settled-camera tail: cluster "Samples in View" stat (cluster
// mode only; point mode already shows viewport count) + URL-hash
// replaceState for continuous movement. Shared with `moveEnd` via
// reconcileSettledCamera() (#208 smell 1b).
reconcileSettledCamera(viewer);
}, 600);
});
viewer.camera.percentageChanged = 0.1;
Expand Down Expand Up @@ -3833,7 +3855,12 @@ zoomWatcher = {
});

viewer.camera.moveEnd.addEventListener(() => {
writeGlobeHash(viewer);
// Settled-camera tail shared with `camera.changed` (#208 smell 1b):
// URL-hash write + cluster "Samples in View" refresh. moveEnd fires on
// every discrete settle including sub-10% pans that `camera.changed`
// debounces away, so routing through here keeps the cluster stat in
// lockstep with the URL (which moveEnd already kept fresh via #204).
reconcileSettledCamera(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
Expand Down
15 changes: 15 additions & 0 deletions tests/playwright/url-roundtrip.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,4 +298,19 @@ test.describe('Explorer URL state round-trip (issue #209)', () => {
const s = await snapshot(page);
expect(s.selectedH3).toBeNull();
});

// NOTE (PR4b, #208 smell 1b): a dedicated headless regression for the new
// `moveEnd` cluster "Samples in View" refresh was attempted but proved
// unreliable. The explorer OJS cell re-evaluates repeatedly during headless
// boot, so `_ojs...value('viewer')` returns different `viewer` instances
// across calls — the one reachable at interaction time frequently has zero
// camera listeners attached, so a forced `moveEnd.raiseEvent()` (or `flyTo`)
// never reaches reconcileSettledCamera. A flaky test being worse than none,
// PR4b instead rests on: (1) the existing url-roundtrip + characterization
// suite proving behavior-neutrality of the shared URL write from BOTH the
// camera.changed and moveEnd handlers; (2) the change being mechanically
// trivial — `moveEnd` now invokes the IDENTICAL cluster-stat block that
// `camera.changed` already ran (covered by cluster-mode boot in the
// characterization specs); (3) a manual probe confirming a settled cluster
// camera writes "<count> | Samples in View" via the shared tail.
});
Loading