gramene-search 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16885,6 +16885,14 @@ var $597fe213417ee6ca$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.conn
16885
16885
  // data.hostData.bins = { genomesByTaxon: { [taxonId]: genome }, maxScore }
16886
16886
  // where `genome` is a gramene-bins-client genome (fullGenomeSize,
16887
16887
  // _regionsArray, region.bin(i)/binCount()/size/name, bin.results.count).
16888
+ //
16889
+ // Pan/zoom: a single shared horizontal transform { scale, leftFrac } (fraction
16890
+ // space, so it's width-independent and aligns every genome to the same relative
16891
+ // window) lives in the ephemeral binsUI store. The header toggles between two
16892
+ // interaction modes: 'select' (drag selects a region to count/filter genes,
16893
+ // the original gesture) and 'panzoom' (drag pans, wheel zooms toward the
16894
+ // cursor). All hit-testing/coordinates are in genome-fraction [0,1] so
16895
+ // hovers/selections stay anchored to the genome as you pan and zoom.
16888
16896
 
16889
16897
 
16890
16898
 
@@ -16967,28 +16975,66 @@ function $bfd29d54a0f0e853$var$updateScore(currentScore, baseCount, binScore, bi
16967
16975
  if (typeof currentScore === 'number') return (currentScore * baseCount + binScore * binBasesUsed) / (binBasesUsed + baseCount);
16968
16976
  return binScore;
16969
16977
  }
16970
- // Draw one genome's distribution into [x, x+width) at vertical [y, y+height).
16971
- // Ported from drawGenome() in gramene-search-vis, collapsed to a single row.
16972
- function $bfd29d54a0f0e853$var$drawGenomeRow(ctx, genome, x, y, width, height, maxScore) {
16978
+ const $bfd29d54a0f0e853$var$clamp = (v, lo, hi)=>Math.max(lo, Math.min(hi, v));
16979
+ const $bfd29d54a0f0e853$var$MAX_SCALE = 1000; // zoom far enough to isolate a single bin
16980
+ // Locate the region/bin cursor at a target base offset, so a zoomed draw can
16981
+ // start partway into the genome without iterating skipped pixels. Returns null
16982
+ // past the end of the genome.
16983
+ function $bfd29d54a0f0e853$var$seekCursor(regions, targetBase) {
16984
+ let acc = 0;
16985
+ for(let regionIdx = 0; regionIdx < regions.length; regionIdx++){
16986
+ const region = regions[regionIdx];
16987
+ const n = region.binCount();
16988
+ for(let binIdx = 0; binIdx < n; binIdx++){
16989
+ const bin = region.bin(binIdx);
16990
+ const size = bin.end - bin.start + 1;
16991
+ if (acc + size > targetBase) return {
16992
+ regionIdx: regionIdx,
16993
+ binIdx: binIdx,
16994
+ basesInBinUsedAlready: Math.max(0, targetBase - acc)
16995
+ };
16996
+ acc += size;
16997
+ }
16998
+ }
16999
+ return null;
17000
+ }
17001
+ // Draw one genome's distribution into the visible [0, widthPx) strip at
17002
+ // vertical [y, y+height), under the shared { scale, leftFrac } transform. Only
17003
+ // the visible pixels are drawn (the cursor is fast-forwarded to the window's
17004
+ // left edge); consecutive equal-colour columns are batched into one fillRect,
17005
+ // which makes zoomed-in draws (where each bin spans many px) cheap. Ported from
17006
+ // drawGenome() in gramene-search-vis, generalised for pan/zoom.
17007
+ function $bfd29d54a0f0e853$var$drawGenomeRow(ctx, genome, y, widthPx, height, maxScore, scale, leftFrac) {
16973
17008
  const regions = genome && genome._regionsArray;
16974
- if (!regions || regions.length === 0 || !genome.fullGenomeSize) return;
16975
- const basesPerPx = genome.fullGenomeSize / width;
17009
+ const full = genome && genome.fullGenomeSize;
17010
+ if (!regions || regions.length === 0 || !full) return;
17011
+ const virtualWidth = widthPx * scale; // full genome spans this many px when zoomed
17012
+ const basesPerPx = full / virtualWidth;
16976
17013
  if (!(basesPerPx > 0)) return;
16977
- let binIdx = 0;
16978
- let basesInBinUsedAlready = 0;
16979
- let regionIdx = 0;
17014
+ const cursor = $bfd29d54a0f0e853$var$seekCursor(regions, leftFrac * full);
17015
+ if (!cursor) return;
17016
+ let { regionIdx: regionIdx, binIdx: binIdx, basesInBinUsedAlready: basesInBinUsedAlready } = cursor;
16980
17017
  let region = regions[regionIdx];
16981
17018
  let regionUnanchored = region.name === 'UNANCHORED';
16982
- for(let px = 0; px < width; px++){
17019
+ let runColor = null;
17020
+ let runStart = 0;
17021
+ const flush = (endExclusive)=>{
17022
+ if (runColor !== null && endExclusive > runStart) {
17023
+ ctx.fillStyle = runColor;
17024
+ ctx.fillRect(runStart, y, endExclusive - runStart, height);
17025
+ }
17026
+ };
17027
+ let px = 0;
17028
+ for(; px < widthPx; px++){
16983
17029
  let baseCount = 0;
16984
17030
  let score;
16985
- let basesAvailableInBin = 0;
17031
+ let ended = false;
16986
17032
  while(baseCount < basesPerPx){
16987
17033
  const basesNeededByThisPixel = basesPerPx - baseCount;
16988
17034
  const bin = region.bin(binIdx);
16989
17035
  const binSize = bin.end - bin.start + 1;
16990
17036
  const binScore = maxScore ? (bin.results ? bin.results.count : 0) / maxScore : 0;
16991
- basesAvailableInBin = binSize - basesInBinUsedAlready;
17037
+ const basesAvailableInBin = binSize - basesInBinUsedAlready;
16992
17038
  let binBasesUsed;
16993
17039
  if (basesAvailableInBin <= basesNeededByThisPixel) {
16994
17040
  binIdx++;
@@ -17003,14 +17049,30 @@ function $bfd29d54a0f0e853$var$drawGenomeRow(ctx, genome, x, y, width, height, m
17003
17049
  if (binIdx === region.binCount()) {
17004
17050
  binIdx = 0;
17005
17051
  regionIdx++;
17006
- if (regionIdx === regions.length) break;
17052
+ if (regionIdx === regions.length) {
17053
+ ended = true;
17054
+ break;
17055
+ }
17007
17056
  region = regions[regionIdx];
17008
17057
  regionUnanchored = region.name === 'UNANCHORED';
17009
17058
  }
17010
17059
  }
17011
- ctx.fillStyle = $bfd29d54a0f0e853$var$binColor(regionIdx, score || 0, regionUnanchored);
17012
- ctx.fillRect(x + px, y, 1, height);
17060
+ const idxForColor = regionIdx >= regions.length ? regions.length - 1 : regionIdx;
17061
+ const color = $bfd29d54a0f0e853$var$binColor(idxForColor, score || 0, regionUnanchored);
17062
+ if (runColor === null) {
17063
+ runColor = color;
17064
+ runStart = px;
17065
+ } else if (color !== runColor) {
17066
+ flush(px);
17067
+ runColor = color;
17068
+ runStart = px;
17069
+ }
17070
+ if (ended) {
17071
+ px++;
17072
+ break;
17073
+ }
17013
17074
  }
17075
+ flush(px);
17014
17076
  }
17015
17077
  function $bfd29d54a0f0e853$export$390e10a52782f018(taxDist) {
17016
17078
  if (!taxDist || typeof taxDist.leafNodes !== 'function') return null;
@@ -17030,12 +17092,18 @@ function $bfd29d54a0f0e853$export$390e10a52782f018(taxDist) {
17030
17092
  }
17031
17093
  // ── interaction store (shared between Header and Body via hostData) ──────────
17032
17094
  // A tiny pub/sub kept OUT of the controlled tbrowse viewState so per-mousemove
17033
- // hover/drag updates don't churn the whole tree. Holds the hovered chromosome,
17034
- // the in-progress drag, and the committed drag-selections.
17095
+ // hover/drag/pan/zoom updates don't churn the whole tree. Holds the hovered
17096
+ // chromosome, the in-progress select-drag, the committed drag-selections, and
17097
+ // the shared horizontal pan/zoom transform.
17098
+ const $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM = {
17099
+ scale: 1,
17100
+ leftFrac: 0
17101
+ };
17035
17102
  const $bfd29d54a0f0e853$var$EMPTY_UI = {
17036
17103
  hovered: null,
17037
17104
  inProgress: null,
17038
- selections: []
17105
+ selections: [],
17106
+ transform: $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM
17039
17107
  };
17040
17108
  const $bfd29d54a0f0e853$var$NOOP_SUB = ()=>()=>{};
17041
17109
  const $bfd29d54a0f0e853$var$NOOP_GET = ()=>$bfd29d54a0f0e853$var$EMPTY_UI;
@@ -17063,21 +17131,35 @@ function $bfd29d54a0f0e853$export$f06340802363875a() {
17063
17131
  };
17064
17132
  emit();
17065
17133
  },
17134
+ setTransform: (t)=>{
17135
+ state = {
17136
+ ...state,
17137
+ transform: t
17138
+ };
17139
+ emit();
17140
+ },
17141
+ resetTransform: ()=>{
17142
+ state = {
17143
+ ...state,
17144
+ transform: $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM
17145
+ };
17146
+ emit();
17147
+ },
17066
17148
  // Add a selection, merging it with any existing selection on the SAME
17067
- // genome whose pixel range overlaps — so overlapping drags become one
17149
+ // genome whose fraction range overlaps — so overlapping drags become one
17068
17150
  // region and shared bins aren't double-counted. Bins are unioned by their
17069
17151
  // global index.
17070
17152
  addSelection: (sel)=>{
17071
17153
  let merged = sel;
17072
17154
  const rest = [];
17073
- for (const s of state.selections)if (s.taxonId === merged.taxonId && s.x0 <= merged.x1 && s.x1 >= merged.x0) {
17155
+ for (const s of state.selections)if (s.taxonId === merged.taxonId && s.f0 <= merged.f1 && s.f1 >= merged.f0) {
17074
17156
  const byIdx = new Map();
17075
17157
  for (const b of s.bins)byIdx.set(b.idx, b.count);
17076
17158
  for (const b of merged.bins)byIdx.set(b.idx, b.count);
17077
17159
  merged = {
17078
17160
  taxonId: merged.taxonId,
17079
- x0: Math.min(s.x0, merged.x0),
17080
- x1: Math.max(s.x1, merged.x1),
17161
+ f0: Math.min(s.f0, merged.f0),
17162
+ f1: Math.max(s.f1, merged.f1),
17081
17163
  bins: [
17082
17164
  ...byIdx
17083
17165
  ].map(([idx, count])=>({
@@ -17095,7 +17177,17 @@ function $bfd29d54a0f0e853$export$f06340802363875a() {
17095
17177
  };
17096
17178
  emit();
17097
17179
  },
17180
+ // Clear hover/selections but keep the current zoom/pan transform — clearing
17181
+ // selections shouldn't yank the user back out of their zoom.
17098
17182
  clear: ()=>{
17183
+ state = {
17184
+ ...$bfd29d54a0f0e853$var$EMPTY_UI,
17185
+ transform: state.transform
17186
+ };
17187
+ emit();
17188
+ },
17189
+ // Full reset (new result set): drop everything including the transform.
17190
+ reset: ()=>{
17099
17191
  state = $bfd29d54a0f0e853$var$EMPTY_UI;
17100
17192
  emit();
17101
17193
  }
@@ -17115,55 +17207,56 @@ function $bfd29d54a0f0e853$export$861100744513cc3(selections) {
17115
17207
  ...set
17116
17208
  ];
17117
17209
  }
17118
- // Per-genome pixel layout across the [0,width) strip: region spans (for
17210
+ // Per-genome layout in genome-fraction space [0,1]: region spans (for
17119
17211
  // chromosome hit-testing) and bin spans with gene counts (for summing a
17120
- // drag-selected range). Mirrors drawGenomeRow's region/bin walk.
17121
- function $bfd29d54a0f0e853$var$buildGenomeLayout(genome, width) {
17212
+ // drag-selected range). Width/zoom-independent the transform maps fractions
17213
+ // to screen pixels at render time.
17214
+ function $bfd29d54a0f0e853$var$buildGenomeLayout(genome) {
17122
17215
  const out = {
17123
17216
  regions: []
17124
17217
  };
17125
17218
  const regions = genome && genome._regionsArray;
17126
- if (!regions || !genome.fullGenomeSize || !(width > 0)) return out;
17127
- const basesPerPx = genome.fullGenomeSize / width;
17128
- if (!(basesPerPx > 0)) return out;
17129
- let px = 0;
17219
+ const full = genome && genome.fullGenomeSize;
17220
+ if (!regions || !full) return out;
17221
+ let base = 0;
17130
17222
  for (const region of regions){
17131
- const x0 = px;
17223
+ const f0 = base / full;
17132
17224
  const binsArr = [];
17133
17225
  const n = region.binCount();
17134
17226
  for(let i = 0; i < n; i++){
17135
17227
  const bin = region.bin(i);
17136
- const bw = (bin.end - bin.start + 1) / basesPerPx;
17228
+ const size = bin.end - bin.start + 1;
17229
+ const bf0 = base / full;
17230
+ base += size;
17137
17231
  binsArr.push({
17138
- x0: px,
17139
- x1: px + bw,
17232
+ f0: bf0,
17233
+ f1: base / full,
17140
17234
  count: bin.results && bin.results.count || 0,
17141
17235
  idx: bin.idx
17142
17236
  });
17143
- px += bw;
17144
17237
  }
17145
17238
  out.regions.push({
17146
17239
  name: region.name,
17147
- x0: x0,
17148
- x1: px,
17240
+ f0: f0,
17241
+ f1: base / full,
17149
17242
  bins: binsArr
17150
17243
  });
17151
17244
  }
17152
17245
  return out;
17153
17246
  }
17154
- function $bfd29d54a0f0e853$var$regionAtPx(layout, mx) {
17155
- for (const r of layout.regions)if (mx >= r.x0 && mx < r.x1) return r;
17247
+ function $bfd29d54a0f0e853$var$regionAtFrac(layout, f) {
17248
+ for (const r of layout.regions)if (f >= r.f0 && f < r.f1) return r;
17156
17249
  return null;
17157
17250
  }
17158
- // Non-empty bins overlapping a pixel range, as {idx, count}. Empty bins are
17251
+ // Non-empty bins overlapping a fraction range, as {idx, count}. Empty bins are
17159
17252
  // excluded by design (the selection / filter only covers bins with genes).
17160
- function $bfd29d54a0f0e853$var$selectedBinsInPxRange(layout, a, b) {
17253
+ function $bfd29d54a0f0e853$var$selectedBinsInFracRange(layout, a, b) {
17161
17254
  const lo = Math.min(a, b);
17162
17255
  const hi = Math.max(a, b);
17163
17256
  const bins = [];
17164
17257
  for (const r of layout.regions){
17165
- if (r.x1 < lo || r.x0 > hi) continue;
17166
- for (const bin of r.bins)if (bin.x1 > lo && bin.x0 < hi && bin.count > 0) bins.push({
17258
+ if (r.f1 < lo || r.f0 > hi) continue;
17259
+ for (const bin of r.bins)if (bin.f1 > lo && bin.f0 < hi && bin.count > 0) bins.push({
17167
17260
  idx: bin.idx,
17168
17261
  count: bin.count
17169
17262
  });
@@ -17177,11 +17270,46 @@ const $bfd29d54a0f0e853$var$HEADER_BTN = {
17177
17270
  padding: '1px 6px',
17178
17271
  cursor: 'pointer'
17179
17272
  };
17273
+ function $bfd29d54a0f0e853$var$ModeToggle({ mode: mode, setMode: setMode }) {
17274
+ const seg = (id, label)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
17275
+ type: "button",
17276
+ onClick: ()=>setMode(id),
17277
+ title: id === 'select' ? 'Drag to select a region' : 'Drag to pan, scroll to zoom',
17278
+ style: {
17279
+ fontSize: 11,
17280
+ lineHeight: 1,
17281
+ padding: '2px 7px',
17282
+ cursor: 'pointer',
17283
+ border: 'none',
17284
+ background: mode === id ? 'var(--tbrowse-accent, #2878dc)' : 'transparent',
17285
+ color: mode === id ? '#fff' : 'var(--tbrowse-text-muted)'
17286
+ },
17287
+ children: label
17288
+ });
17289
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
17290
+ style: {
17291
+ display: 'inline-flex',
17292
+ border: '1px solid var(--tbrowse-border-soft)',
17293
+ borderRadius: 4,
17294
+ overflow: 'hidden'
17295
+ },
17296
+ children: [
17297
+ seg('select', 'Select'),
17298
+ seg('panzoom', 'Pan/Zoom')
17299
+ ]
17300
+ });
17301
+ }
17180
17302
  const $bfd29d54a0f0e853$var$BinsHeader = ({ data: data, zoneState: zoneState, setZoneState: setZoneState })=>{
17181
17303
  const store = data.hostData && data.hostData.binsUI;
17182
17304
  const snap = (0, $gXNCa$react.useSyncExternalStore)(store ? store.subscribe : $bfd29d54a0f0e853$var$NOOP_SUB, store ? store.getState : $bfd29d54a0f0e853$var$NOOP_GET);
17183
17305
  const total = $bfd29d54a0f0e853$export$cf7300886d7d9e1e(snap.selections);
17184
17306
  const hovered = snap.hovered;
17307
+ const scale = snap.transform ? snap.transform.scale : 1;
17308
+ const mode = zoneState && zoneState.mode || 'select';
17309
+ const setMode = (m)=>setZoneState((s)=>({
17310
+ ...s ?? {},
17311
+ mode: m
17312
+ }));
17185
17313
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
17186
17314
  style: {
17187
17315
  height: '100%',
@@ -17199,7 +17327,8 @@ const $bfd29d54a0f0e853$var$BinsHeader = ({ data: data, zoneState: zoneState, se
17199
17327
  display: 'flex',
17200
17328
  alignItems: 'center',
17201
17329
  gap: 8,
17202
- minHeight: 0
17330
+ minHeight: 0,
17331
+ flexWrap: 'wrap'
17203
17332
  },
17204
17333
  children: [
17205
17334
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$tbrowse.EditableZoneName), {
@@ -17210,6 +17339,32 @@ const $bfd29d54a0f0e853$var$BinsHeader = ({ data: data, zoneState: zoneState, se
17210
17339
  name: next
17211
17340
  }))
17212
17341
  }),
17342
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($bfd29d54a0f0e853$var$ModeToggle, {
17343
+ mode: mode,
17344
+ setMode: setMode
17345
+ }),
17346
+ scale > 1.0001 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
17347
+ children: [
17348
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("span", {
17349
+ style: {
17350
+ color: 'var(--tbrowse-text-muted)',
17351
+ fontSize: 11,
17352
+ whiteSpace: 'nowrap'
17353
+ },
17354
+ children: [
17355
+ scale.toFixed(scale < 10 ? 1 : 0),
17356
+ "\xd7"
17357
+ ]
17358
+ }),
17359
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
17360
+ type: "button",
17361
+ style: $bfd29d54a0f0e853$var$HEADER_BTN,
17362
+ title: "Reset zoom",
17363
+ onClick: ()=>store && store.resetTransform(),
17364
+ children: "reset zoom"
17365
+ })
17366
+ ]
17367
+ }),
17213
17368
  snap.selections.length > 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
17214
17369
  children: [
17215
17370
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("span", {
@@ -17275,7 +17430,7 @@ function $bfd29d54a0f0e853$var$rowHighlight(isSelected, isExactHover, isInHovere
17275
17430
  return 'transparent';
17276
17431
  }
17277
17432
  const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17278
- const { visibleRows: visibleRows, rowRange: rowRange, width: width, data: data, hoveredNodeId: hoveredNodeId, hoveredSubtreeIds: hoveredSubtreeIds, selectedNodeId: selectedNodeId, onHoverNode: onHoverNode, onSelectNode: onSelectNode } = props;
17433
+ const { visibleRows: visibleRows, rowRange: rowRange, width: width, data: data, zoneState: zoneState, hoveredNodeId: hoveredNodeId, hoveredSubtreeIds: hoveredSubtreeIds, selectedNodeId: selectedNodeId, onHoverNode: onHoverNode, onSelectNode: onSelectNode } = props;
17279
17434
  const canvasRef = (0, $gXNCa$react.useRef)(null);
17280
17435
  const containerRef = (0, $gXNCa$react.useRef)(null);
17281
17436
  const dragRef = (0, $gXNCa$react.useRef)(null);
@@ -17285,27 +17440,33 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17285
17440
  const suppressClickRef = (0, $gXNCa$react.useRef)(false);
17286
17441
  const store = data.hostData && data.hostData.binsUI;
17287
17442
  const snap = (0, $gXNCa$react.useSyncExternalStore)(store ? store.subscribe : $bfd29d54a0f0e853$var$NOOP_SUB, store ? store.getState : $bfd29d54a0f0e853$var$NOOP_GET);
17443
+ const transform = snap.transform || $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM;
17444
+ const mode = zoneState && zoneState.mode || 'select';
17288
17445
  const totalHeight = visibleRows.length ? visibleRows[visibleRows.length - 1].y + visibleRows[visibleRows.length - 1].height : 0;
17289
17446
  const bins = data.hostData && data.hostData.bins;
17290
17447
  const rows = visibleRows.slice(rowRange.startIndex, rowRange.endIndex);
17291
17448
  const w = Math.max(1, Math.floor(width));
17292
- // Pixel layout per visible genome row (regions + bins), for hit-testing.
17449
+ // Fraction-space layout per visible genome row (regions + bins), for
17450
+ // hit-testing. Independent of width/zoom, so it only rebuilds when the row
17451
+ // set changes.
17293
17452
  const layouts = (0, $gXNCa$react.useMemo)(()=>{
17294
17453
  const m = {};
17295
17454
  if (bins) for (const r of rows){
17296
17455
  if (r.kind !== 'leaf') continue;
17297
17456
  const g = bins.genomesByTaxon[r.nodeId];
17298
- if (g) m[r.nodeId] = $bfd29d54a0f0e853$var$buildGenomeLayout(g, w);
17457
+ if (g) m[r.nodeId] = $bfd29d54a0f0e853$var$buildGenomeLayout(g);
17299
17458
  }
17300
17459
  return m;
17301
17460
  // eslint-disable-next-line react-hooks/exhaustive-deps
17302
17461
  }, [
17303
17462
  bins,
17304
- w,
17305
17463
  rowRange.startIndex,
17306
17464
  rowRange.endIndex,
17307
17465
  visibleRows
17308
17466
  ]);
17467
+ // Fraction <-> screen-pixel helpers under the current transform.
17468
+ const fracToScreen = (f)=>(f - transform.leftFrac) * transform.scale * w;
17469
+ const screenToFrac = (px)=>transform.leftFrac + px / w / transform.scale;
17309
17470
  (0, $gXNCa$react.useEffect)(()=>{
17310
17471
  const canvas = canvasRef.current;
17311
17472
  if (!canvas || !bins) return;
@@ -17321,7 +17482,7 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17321
17482
  const genome = bins.genomesByTaxon[r.nodeId];
17322
17483
  if (!genome) continue;
17323
17484
  const innerH = Math.max(1, r.height - 2 * $bfd29d54a0f0e853$var$ROW_PAD_Y);
17324
- $bfd29d54a0f0e853$var$drawGenomeRow(ctx, genome, 0, r.y + $bfd29d54a0f0e853$var$ROW_PAD_Y, w, innerH, bins.maxScore);
17485
+ $bfd29d54a0f0e853$var$drawGenomeRow(ctx, genome, r.y + $bfd29d54a0f0e853$var$ROW_PAD_Y, w, innerH, bins.maxScore, transform.scale, transform.leftFrac);
17325
17486
  }
17326
17487
  }, [
17327
17488
  visibleRows,
@@ -17329,21 +17490,53 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17329
17490
  rowRange.endIndex,
17330
17491
  w,
17331
17492
  totalHeight,
17332
- bins
17493
+ bins,
17494
+ transform.scale,
17495
+ transform.leftFrac
17333
17496
  ]);
17334
17497
  const pxFromEvent = (e)=>{
17335
17498
  const el = containerRef.current;
17336
17499
  if (!el) return 0;
17337
17500
  const rect = el.getBoundingClientRect();
17338
- return Math.max(0, Math.min(w, e.clientX - rect.left));
17501
+ return $bfd29d54a0f0e853$var$clamp(e.clientX - rect.left, 0, w);
17339
17502
  };
17503
+ // Wheel-to-zoom (panzoom mode only), centred on the cursor. Native non-passive
17504
+ // listener so we can preventDefault the page scroll; re-attached when mode/
17505
+ // width change. Transform is read live from the store to avoid stale closures.
17506
+ (0, $gXNCa$react.useEffect)(()=>{
17507
+ const el = containerRef.current;
17508
+ if (!el || !store) return undefined;
17509
+ const onWheel = (e)=>{
17510
+ if (mode !== 'panzoom') return; // let the page scroll normally
17511
+ e.preventDefault();
17512
+ const t = store.getState().transform || $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM;
17513
+ const rect = el.getBoundingClientRect();
17514
+ const cursorPx = $bfd29d54a0f0e853$var$clamp(e.clientX - rect.left, 0, w);
17515
+ const fracAtCursor = t.leftFrac + cursorPx / w / t.scale;
17516
+ const factor = Math.exp(-e.deltaY * 0.0015);
17517
+ const newScale = $bfd29d54a0f0e853$var$clamp(t.scale * factor, 1, $bfd29d54a0f0e853$var$MAX_SCALE);
17518
+ const newLeft = $bfd29d54a0f0e853$var$clamp(fracAtCursor - cursorPx / w / newScale, 0, 1 - 1 / newScale);
17519
+ store.setTransform({
17520
+ scale: newScale,
17521
+ leftFrac: newLeft
17522
+ });
17523
+ };
17524
+ el.addEventListener('wheel', onWheel, {
17525
+ passive: false
17526
+ });
17527
+ return ()=>el.removeEventListener('wheel', onWheel);
17528
+ }, [
17529
+ store,
17530
+ w,
17531
+ mode
17532
+ ]);
17340
17533
  const genomeName = (taxonId)=>{
17341
17534
  const tax = data.taxonomy && data.taxonomy[taxonId];
17342
17535
  return tax && (tax.commonName || tax.scientificName) || String(taxonId);
17343
17536
  };
17344
17537
  const onRowMouseMove = (e, r)=>{
17345
17538
  if (!store || dragRef.current || !layouts[r.nodeId]) return;
17346
- const reg = $bfd29d54a0f0e853$var$regionAtPx(layouts[r.nodeId], pxFromEvent(e));
17539
+ const reg = $bfd29d54a0f0e853$var$regionAtFrac(layouts[r.nodeId], screenToFrac(pxFromEvent(e)));
17347
17540
  if (!reg) {
17348
17541
  store.setHovered(null);
17349
17542
  return;
@@ -17354,33 +17547,60 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17354
17547
  genomeName: genomeName(r.nodeId),
17355
17548
  regionName: reg.name,
17356
17549
  geneCount: geneCount,
17357
- x0: reg.x0,
17358
- x1: reg.x1
17550
+ f0: reg.f0,
17551
+ f1: reg.f1
17359
17552
  });
17360
17553
  };
17361
- const onRowPointerDown = (e, r)=>{
17362
- if (!store || !layouts[r.nodeId]) return;
17554
+ // Pan drag (panzoom mode): translate leftFrac by the cursor delta, scaled by
17555
+ // the current zoom. Shared transform → every genome row pans together.
17556
+ const startPan = (e)=>{
17363
17557
  e.preventDefault();
17364
17558
  const startPx = pxFromEvent(e);
17559
+ const t0 = store.getState().transform || $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM;
17560
+ let moved = false;
17561
+ const onMove = (ev)=>{
17562
+ const dx = pxFromEvent(ev) - startPx;
17563
+ if (Math.abs(dx) > 2) moved = true;
17564
+ const t = store.getState().transform || $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM;
17565
+ const newLeft = $bfd29d54a0f0e853$var$clamp(t0.leftFrac - dx / w / t.scale, 0, 1 - 1 / t.scale);
17566
+ store.setTransform({
17567
+ scale: t.scale,
17568
+ leftFrac: newLeft
17569
+ });
17570
+ };
17571
+ const onUp = ()=>{
17572
+ document.removeEventListener('pointermove', onMove);
17573
+ document.removeEventListener('pointerup', onUp);
17574
+ if (moved) suppressClickRef.current = true;
17575
+ };
17576
+ document.addEventListener('pointermove', onMove);
17577
+ document.addEventListener('pointerup', onUp);
17578
+ };
17579
+ // Select drag (select mode): brush a fraction range and add the non-empty
17580
+ // bins under it as a committed selection.
17581
+ const startSelect = (e, r)=>{
17582
+ if (!layouts[r.nodeId]) return;
17583
+ e.preventDefault();
17584
+ const startFrac = screenToFrac(pxFromEvent(e));
17365
17585
  dragRef.current = {
17366
17586
  taxonId: r.nodeId,
17367
- startPx: startPx,
17587
+ startFrac: startFrac,
17368
17588
  moved: false
17369
17589
  };
17370
17590
  store.setInProgress({
17371
17591
  taxonId: r.nodeId,
17372
- x0: startPx,
17373
- x1: startPx
17592
+ f0: startFrac,
17593
+ f1: startFrac
17374
17594
  });
17375
17595
  const onMove = (ev)=>{
17376
17596
  const d = dragRef.current;
17377
17597
  if (!d) return;
17378
- const cur = pxFromEvent(ev);
17379
- if (Math.abs(cur - d.startPx) > 2) d.moved = true;
17598
+ const cur = screenToFrac(pxFromEvent(ev));
17599
+ if (Math.abs(cur - d.startFrac) * w * transform.scale > 2) d.moved = true;
17380
17600
  store.setInProgress({
17381
17601
  taxonId: d.taxonId,
17382
- x0: Math.min(d.startPx, cur),
17383
- x1: Math.max(d.startPx, cur)
17602
+ f0: Math.min(d.startFrac, cur),
17603
+ f1: Math.max(d.startFrac, cur)
17384
17604
  });
17385
17605
  };
17386
17606
  const onUp = (ev)=>{
@@ -17391,17 +17611,17 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17391
17611
  store.setInProgress(null);
17392
17612
  if (d && d.moved) {
17393
17613
  suppressClickRef.current = true; // swallow the click that follows a drag
17394
- const cur = pxFromEvent(ev);
17395
- const x0 = Math.min(d.startPx, cur);
17396
- const x1 = Math.max(d.startPx, cur);
17614
+ const cur = screenToFrac(pxFromEvent(ev));
17615
+ const f0 = Math.min(d.startFrac, cur);
17616
+ const f1 = Math.max(d.startFrac, cur);
17397
17617
  const lay = layouts[d.taxonId];
17398
17618
  if (lay) {
17399
- const bins = $bfd29d54a0f0e853$var$selectedBinsInPxRange(lay, x0, x1);
17400
- if (bins.length) store.addSelection({
17619
+ const selBins = $bfd29d54a0f0e853$var$selectedBinsInFracRange(lay, f0, f1);
17620
+ if (selBins.length) store.addSelection({
17401
17621
  taxonId: d.taxonId,
17402
- x0: x0,
17403
- x1: x1,
17404
- bins: bins
17622
+ f0: f0,
17623
+ f1: f1,
17624
+ bins: selBins
17405
17625
  });
17406
17626
  }
17407
17627
  }
@@ -17409,15 +17629,29 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17409
17629
  document.addEventListener('pointermove', onMove);
17410
17630
  document.addEventListener('pointerup', onUp);
17411
17631
  };
17632
+ const onRowPointerDown = (e, r)=>{
17633
+ if (!store) return;
17634
+ if (mode === 'panzoom') {
17635
+ startPan(e);
17636
+ return;
17637
+ }
17638
+ startSelect(e, r);
17639
+ };
17412
17640
  const rowByTaxon = new Map(rows.map((r)=>[
17413
17641
  r.nodeId,
17414
17642
  r
17415
17643
  ]));
17416
- // Outline rectangles (drawn above the canvas; pointer-events:none). The y
17417
- // comes from the current row so they track vertical scroll.
17418
- const outline = (key, taxonId, x0, x1, color, dashed)=>{
17644
+ // Outline rectangles (drawn above the canvas; pointer-events:none). Stored in
17645
+ // fraction space and projected to screen px under the transform, clipped to
17646
+ // the visible strip. The y comes from the current row so they track scroll.
17647
+ const outline = (key, taxonId, f0, f1, color, dashed)=>{
17419
17648
  const r = rowByTaxon.get(taxonId);
17420
17649
  if (!r) return null;
17650
+ let x0 = fracToScreen(f0);
17651
+ let x1 = fracToScreen(f1);
17652
+ if (x1 <= 0 || x0 >= w) return null; // fully outside the visible window
17653
+ x0 = Math.max(0, x0);
17654
+ x1 = Math.min(w, x1);
17421
17655
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
17422
17656
  style: {
17423
17657
  position: 'absolute',
@@ -17431,6 +17665,7 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17431
17665
  }
17432
17666
  }, key);
17433
17667
  };
17668
+ const rowCursor = mode === 'panzoom' ? 'grab' : 'crosshair';
17434
17669
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
17435
17670
  ref: containerRef,
17436
17671
  style: {
@@ -17461,7 +17696,7 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17461
17696
  right: 0,
17462
17697
  height: r.height,
17463
17698
  background: $bfd29d54a0f0e853$var$rowHighlight(selectedNodeId === r.nodeId, hoveredNodeId === r.nodeId, !!(hoveredSubtreeIds && hoveredSubtreeIds.has(r.nodeId))),
17464
- cursor: 'crosshair',
17699
+ cursor: rowCursor,
17465
17700
  opacity: r.opacity ?? 1
17466
17701
  }
17467
17702
  }, r.nodeId)),
@@ -17476,9 +17711,9 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17476
17711
  pointerEvents: 'none'
17477
17712
  }
17478
17713
  }),
17479
- snap.hovered && !snap.inProgress && outline('hover', snap.hovered.taxonId, snap.hovered.x0, snap.hovered.x1, 'var(--tbrowse-accent, #2878dc)', false),
17480
- snap.selections.map((s, i)=>outline(`sel-${i}`, s.taxonId, s.x0, s.x1, '#d62728', false)),
17481
- snap.inProgress && outline('drag', snap.inProgress.taxonId, snap.inProgress.x0, snap.inProgress.x1, '#d62728', true)
17714
+ snap.hovered && !snap.inProgress && outline('hover', snap.hovered.taxonId, snap.hovered.f0, snap.hovered.f1, 'var(--tbrowse-accent, #2878dc)', false),
17715
+ snap.selections.map((s, i)=>outline(`sel-${i}`, s.taxonId, s.f0, s.f1, '#d62728', false)),
17716
+ snap.inProgress && outline('drag', snap.inProgress.taxonId, snap.inProgress.f0, snap.inProgress.f1, '#d62728', true)
17482
17717
  ]
17483
17718
  });
17484
17719
  };
@@ -17489,7 +17724,9 @@ const $bfd29d54a0f0e853$export$d85d069f2280460c = {
17489
17724
  Body: $bfd29d54a0f0e853$var$BinsBody,
17490
17725
  defaultWidth: 60,
17491
17726
  minWidth: 200,
17492
- defaultZoneState: {},
17727
+ defaultZoneState: {
17728
+ mode: 'select'
17729
+ },
17493
17730
  isAvailable: (data)=>Boolean(data.hostData && data.hostData.bins && data.hostData.bins.genomesByTaxon && Object.keys(data.hostData.bins.genomesByTaxon).length > 0),
17494
17731
  defaultVisible: true
17495
17732
  };
@@ -17724,7 +17961,7 @@ const $15504f5eba8e73bd$var$TaxDistTbrowse = (props)=>{
17724
17961
  const binsUiRef = (0, $gXNCa$react.useRef)(null);
17725
17962
  if (!binsUiRef.current) binsUiRef.current = (0, $bfd29d54a0f0e853$export$f06340802363875a)();
17726
17963
  (0, $gXNCa$react.useEffect)(()=>{
17727
- binsUiRef.current.clear();
17964
+ binsUiRef.current.reset();
17728
17965
  }, [
17729
17966
  grameneTaxDist
17730
17967
  ]);