tfrere HF Staff Cursor commited on
Commit
451c3d8
·
1 Parent(s): b64beb5

§6 UMAP: bias default view toward the dense bottom-right clusters

Browse files

Lock min zoom to ~1.35 with a bottom-right offset (tx=-0.18, ty=0.15) so
the landing frame skips the wide empty band along the top and ~40% of the
viewport stops going to whitespace. Min-zoom matches the default view,
so visitors can pan/zoom around but never reach the fit-everything view
where the clusters drown in margin.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (1) hide show
  1. assets/js/sections/umap.js +26 -13
assets/js/sections/umap.js CHANGED
@@ -152,8 +152,17 @@
152
  let bounds = [0,0,0,0];
153
  let colorBy = "species";
154
  // Viewport: translate (tx, ty) + scale around origin, in NDC space.
155
- // The whole world is fit into [-0.95, 0.95]² at initial zoom.
156
- let view = { tx: 0, ty: 0, scale: 1 };
 
 
 
 
 
 
 
 
 
157
  let dpr = Math.max(1, window.devicePixelRatio || 1);
158
  let needsRedraw = false;
159
  // Spatial grid for hover (built once after data load, in normalized world space).
@@ -687,11 +696,14 @@
687
  // distracting always-active control on first paint.
688
  function updateResetEnabled() {
689
  if (!resetBtn) return;
690
- const atDefault = view.tx === 0 && view.ty === 0 && view.scale === 1;
 
 
 
691
  resetBtn.disabled = atDefault;
692
  }
693
  function resetView() {
694
- view = { tx: 0, ty: 0, scale: 1 };
695
  updateResetEnabled();
696
  requestRedraw();
697
  }
@@ -699,10 +711,10 @@
699
  // Keep the viewport always full of data. The data spans [-0.92, 0.92]·scale
700
  // in world space; the viewport spans [-1, 1]. As long as 0.92·scale ≥ 1
701
  // (zoom ≥ ~1.087), there's "slack" we can pan within: |tx| ≤ 0.92·scale-1.
702
- // Below that — i.e. at minimum zoom where the UMAP fits the viewport with
703
- // margin — we snap to (0, 0) so the data stays centered and no white edge
704
- // creeps in. Paired with the scale clamp in the wheel handler, this means
705
- // "fully zoomed out" = "UMAP exactly fit, perfectly centered".
706
  function clampPan() {
707
  const m = Math.max(0, 0.92 * view.scale - 1);
708
  if (m === 0) {
@@ -766,11 +778,12 @@
766
  const ndc = clientToNDC(e);
767
  // Zoom factor — natural feeling on both trackpad and mouse wheel.
768
  const factor = Math.exp(-e.deltaY * 0.0018);
769
- // Min scale = 1 means "fully zoomed out = UMAP fits the viewport". We
770
- // intentionally don't let the visitor zoom out further: there's no
771
- // information past the data bounds, and the empty margin makes the
772
- // dataset feel small. Max 50× keeps individual points pickable.
773
- const newScale = Math.min(50, Math.max(1, view.scale * factor));
 
774
  const k = newScale / view.scale;
775
  // Zoom around the cursor: shift translate so the point under the cursor
776
  // stays under the cursor.
 
152
  let bounds = [0,0,0,0];
153
  let colorBy = "species";
154
  // Viewport: translate (tx, ty) + scale around origin, in NDC space.
155
+ // We *don't* show the data fully fit-to-canvas at zoom 1: the UMAP layout
156
+ // has a wide empty band along the top edge and a smaller one on the left,
157
+ // and fit-all wastes ~40% of the viewport on whitespace. Instead the
158
+ // landing view is biased toward the bottom-right (where Vertebrates, Plants,
159
+ // Fungi/Bacteria/Viruses sit) with a moderate zoom so the dense clusters
160
+ // fill the frame on first paint. The same view doubles as the minimum
161
+ // zoom-out (scale ≥ MIN_SCALE in the wheel handler) so visitors can pan
162
+ // around but can never zoom *back* into the empty-margins view.
163
+ const MIN_SCALE = 1.35;
164
+ const DEFAULT_VIEW = { tx: -0.18, ty: 0.15, scale: MIN_SCALE };
165
+ let view = { ...DEFAULT_VIEW };
166
  let dpr = Math.max(1, window.devicePixelRatio || 1);
167
  let needsRedraw = false;
168
  // Spatial grid for hover (built once after data load, in normalized world space).
 
696
  // distracting always-active control on first paint.
697
  function updateResetEnabled() {
698
  if (!resetBtn) return;
699
+ const atDefault =
700
+ view.tx === DEFAULT_VIEW.tx &&
701
+ view.ty === DEFAULT_VIEW.ty &&
702
+ view.scale === DEFAULT_VIEW.scale;
703
  resetBtn.disabled = atDefault;
704
  }
705
  function resetView() {
706
+ view = { ...DEFAULT_VIEW };
707
  updateResetEnabled();
708
  requestRedraw();
709
  }
 
711
  // Keep the viewport always full of data. The data spans [-0.92, 0.92]·scale
712
  // in world space; the viewport spans [-1, 1]. As long as 0.92·scale ≥ 1
713
  // (zoom ≥ ~1.087), there's "slack" we can pan within: |tx| ≤ 0.92·scale-1.
714
+ // Since MIN_SCALE > 1.087 the slack branch is always taken in practice —
715
+ // the m === 0 fallback only kicks in if somebody later relaxes the wheel-
716
+ // handler clamp below ~1.087, in which case data snaps to (0, 0) so no
717
+ // white edge creeps in.
718
  function clampPan() {
719
  const m = Math.max(0, 0.92 * view.scale - 1);
720
  if (m === 0) {
 
778
  const ndc = clientToNDC(e);
779
  // Zoom factor — natural feeling on both trackpad and mouse wheel.
780
  const factor = Math.exp(-e.deltaY * 0.0018);
781
+ // Min scale = MIN_SCALE (matches DEFAULT_VIEW.scale): we intentionally
782
+ // disallow zooming further out than the bottom-right framing so the user
783
+ // can't reach the fit-everything view where ~40% of the canvas is empty
784
+ // margin (long whitespace band above the clusters). Max 50× keeps
785
+ // individual points pickable.
786
+ const newScale = Math.min(50, Math.max(MIN_SCALE, view.scale * factor));
787
  const k = newScale / view.scale;
788
  // Zoom around the cursor: shift translate so the point under the cursor
789
  // stays under the cursor.