@@ -99,7 +99,7 @@ Circle size = log(sample count). Color = dominant data source.
9999<div class =" panel-section " >
100100<div class =" stats-compact " >
101101<div class =" stat-box " ><span id =" sPhase " class =" val " >Loading...</span ><span class =" lbl " >Resolution</span ></div >
102- <div class =" stat-box " ><span id =" sPoints " class =" val " >0</span ><span class =" lbl " >Clusters</span ></div >
102+ <div class =" stat-box " ><span id =" sPoints " class =" val " >0</span ><span id = " sPointsLbl " class =" lbl " >Clusters</span ></div >
103103<div class =" stat-box " ><span id =" sSamples " class =" val " >0</span ><span class =" lbl " >Samples</span ></div >
104104<div class =" stat-box " ><span id =" sTime " class =" val " >-</span ><span class =" lbl " >Load Time</span ></div >
105105</div >
@@ -195,12 +195,13 @@ function buildHash(v) {
195195}
196196
197197// === Helpers: update DOM imperatively (no OJS reactivity) ===
198- function updateStats(phase, points, samples, time) {
198+ function updateStats(phase, points, samples, time, pointsLabel ) {
199199 const s = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; };
200200 s('sPhase', phase);
201- s('sPoints', points.toLocaleString());
202- s('sSamples', samples.toLocaleString());
203- s('sTime', time);
201+ s('sPoints', typeof points === 'string' ? points : points.toLocaleString());
202+ s('sSamples', typeof samples === 'string' ? samples : samples.toLocaleString());
203+ if (time != null) s('sTime', time);
204+ if (pointsLabel) s('sPointsLbl', pointsLabel);
204205}
205206
206207function updatePhaseMsg(text, type) {
@@ -485,11 +486,15 @@ phase1 = {
485486 });
486487 }
487488
489+ // Cache cluster data for viewport counting
490+ viewer._clusterData = Array.from(data);
491+ viewer._clusterTotal = { clusters: data.length, samples: totalSamples };
492+
488493 performance.mark('p1-end');
489494 performance.measure('p1', 'p1-start', 'p1-end');
490495 const elapsed = performance.getEntriesByName('p1').pop().duration;
491496
492- updateStats('H3 Res4', data.length, totalSamples, `${(elapsed/1000).toFixed(1)}s`);
497+ updateStats('H3 Res4', data.length, totalSamples, `${(elapsed/1000).toFixed(1)}s`, 'Global Clusters' );
493498 updatePhaseMsg(`${data.length.toLocaleString()} clusters, ${totalSamples.toLocaleString()} samples. Zoom in for finer detail.`, 'done');
494499 console.log(`Phase 1: ${data.length} clusters in ${elapsed.toFixed(0)}ms`);
495500
@@ -510,6 +515,7 @@ zoomWatcher = {
510515 let currentRes = 4;
511516 let loading = false;
512517 let requestId = 0; // stale-request guard
518+ // clusterDataCache stored on viewer._clusterData (set by phase1 and loadRes)
513519
514520 // Hysteresis thresholds to avoid flicker
515521 const ENTER_POINT_ALT = 120000; // 120 km → enter point mode
@@ -550,11 +556,18 @@ zoomWatcher = {
550556 });
551557 }
552558
559+ // Cache for viewport counting
560+ viewer._clusterData = Array.from(data);
561+ viewer._clusterTotal = { clusters: data.length, samples: total };
562+
553563 performance.mark(`r${res}-e`);
554564 performance.measure(`r${res}`, `r${res}-s`, `r${res}-e`);
555565 const elapsed = performance.getEntriesByName(`r${res}`).pop().duration;
556566
557- updateStats(`H3 Res${res}`, data.length, total, `${(elapsed/1000).toFixed(1)}s`);
567+ // Show viewport count immediately
568+ const bounds = getViewportBounds();
569+ const inView = countInViewport(bounds);
570+ updateStats(`H3 Res${res}`, `${inView.clusters.toLocaleString()} / ${data.length.toLocaleString()}`, inView.samples.toLocaleString(), `${(elapsed/1000).toFixed(1)}s`, 'In View / Total');
558571 updatePhaseMsg(`${data.length.toLocaleString()} clusters, ${total.toLocaleString()} samples. ${res < 8 ? 'Zoom in for finer detail.' : 'Zoom closer for individual samples.'}`, 'done');
559572
560573 currentRes = res;
@@ -579,6 +592,22 @@ zoomWatcher = {
579592 };
580593 }
581594
595+ // --- Count clusters visible in current viewport (from cached array) ---
596+ function countInViewport(bounds) {
597+ const cache = viewer._clusterData;
598+ if (!bounds || !cache || cache.length === 0) return { clusters: 0, samples: 0 };
599+ const { south, north, west, east } = bounds;
600+ const wrapLng = west > east; // dateline crossing
601+ let clusters = 0, samples = 0;
602+ for (const row of cache) {
603+ if (row.center_lat < south || row.center_lat > north) continue;
604+ if (wrapLng ? (row.center_lng < west && row.center_lng > east) : (row.center_lng < west || row.center_lng > east)) continue;
605+ clusters++;
606+ samples += row.sample_count;
607+ }
608+ return { clusters, samples };
609+ }
610+
582611 // --- Check if viewport is within cached bounds ---
583612 function isWithinCache(bounds) {
584613 if (!cachedBounds || !bounds) return false;
@@ -638,7 +667,7 @@ zoomWatcher = {
638667
639668 renderSamplePoints(cachedData, bounds);
640669
641- updateStats('Samples', cachedData.length, cachedData.length, `${(elapsed/1000).toFixed(1)}s`);
670+ updateStats('Samples', cachedData.length, cachedData.length, `${(elapsed/1000).toFixed(1)}s`, 'In View' );
642671 updatePhaseMsg(`${cachedData.length.toLocaleString()} individual samples. Click one for details.`, 'done');
643672 console.log(`Point mode: ${cachedData.length} samples in ${elapsed.toFixed(0)}ms`);
644673
@@ -696,10 +725,16 @@ zoomWatcher = {
696725 cachedBounds = null;
697726 cachedData = null;
698727
699- // Restore cluster stats
700- let clusterCount = viewer.h3Points.length;
701- updateStats(`H3 Res${currentRes}`, clusterCount, '—', '—');
702- updatePhaseMsg(`${clusterCount.toLocaleString()} clusters. Zoom closer for individual samples.`, 'done');
728+ // Restore cluster stats with viewport count
729+ const bounds = getViewportBounds();
730+ const inView = countInViewport(bounds);
731+ const total = viewer._clusterTotal;
732+ if (total) {
733+ updateStats(`H3 Res${currentRes}`, `${inView.clusters.toLocaleString()} / ${total.clusters.toLocaleString()}`, inView.samples.toLocaleString(), '—', 'In View / Total');
734+ } else {
735+ updateStats(`H3 Res${currentRes}`, viewer.h3Points.length, '—', '—', 'Global Clusters');
736+ }
737+ updatePhaseMsg(`${inView.clusters.toLocaleString()} clusters in view. Zoom closer for individual samples.`, 'done');
703738 console.log('Exited point mode');
704739 }
705740
@@ -739,6 +774,16 @@ zoomWatcher = {
739774 }
740775 }
741776
777+ // Update viewport cluster count (cluster mode only; point mode already shows viewport count)
778+ if (mode === 'cluster' && viewer._clusterData) {
779+ const bounds = getViewportBounds();
780+ const inView = countInViewport(bounds);
781+ const total = viewer._clusterTotal;
782+ if (total) {
783+ updateStats(`H3 Res${currentRes}`, `${inView.clusters.toLocaleString()} / ${total.clusters.toLocaleString()}`, inView.samples.toLocaleString(), null, 'In View / Total');
784+ }
785+ }
786+
742787 // Update URL hash (replaceState for continuous movement)
743788 if (!viewer._suppressHashWrite) {
744789 history.replaceState(null, '', buildHash(viewer));
0 commit comments