gramene-search 2.3.0 → 2.5.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
@@ -7447,12 +7447,13 @@ const $047461923b1badda$export$964d88edb00bbcaa = (suggestion)=>{
7447
7447
  const $64fad37f770d2bfe$var$genomeZone = (0, $gXNCa$tbrowse.createGenomeZone)({
7448
7448
  id: 'genome'
7449
7449
  });
7450
- const $64fad37f770d2bfe$var$TBROWSE_ZONES = [
7450
+ // The genome (gene-structure) zone is gated behind the site-config flag
7451
+ // `enable_tbrowse_genome_zone` (defaults to false) — see getTbrowseZones().
7452
+ const $64fad37f770d2bfe$var$TBROWSE_BASE_ZONES = [
7451
7453
  (0, $gXNCa$tbrowse.treeZone),
7452
7454
  (0, $gXNCa$tbrowse.labelsZone),
7453
7455
  (0, $gXNCa$tbrowse.msaZone),
7454
- (0, $gXNCa$tbrowse.neighborhoodZone),
7455
- $64fad37f770d2bfe$var$genomeZone
7456
+ (0, $gXNCa$tbrowse.neighborhoodZone)
7456
7457
  ];
7457
7458
  class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$react))).Component {
7458
7459
  constructor(props){
@@ -7485,7 +7486,18 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
7485
7486
  return slice && slice.homology || {};
7486
7487
  }
7487
7488
  getViewer() {
7488
- return this.getHomologySlice().viewer || 'treevis';
7489
+ return this.getHomologySlice().viewer || 'tbrowse';
7490
+ }
7491
+ // The genome (gene-structure) zone is opt-in per site via
7492
+ // `enable_tbrowse_genome_zone` (defaults to false).
7493
+ isGenomeZoneEnabled() {
7494
+ return !!(this.props.configuration && this.props.configuration.enable_tbrowse_genome_zone);
7495
+ }
7496
+ getTbrowseZones() {
7497
+ return this.isGenomeZoneEnabled() ? [
7498
+ ...$64fad37f770d2bfe$var$TBROWSE_BASE_ZONES,
7499
+ $64fad37f770d2bfe$var$genomeZone
7500
+ ] : $64fad37f770d2bfe$var$TBROWSE_BASE_ZONES;
7489
7501
  }
7490
7502
  getHeight() {
7491
7503
  const h = this.getHomologySlice().height;
@@ -7542,7 +7554,7 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
7542
7554
  // logic — zones default to visible unless their definition opts out
7543
7555
  // (e.g. neighborhood and genome opt out so they only appear once
7544
7556
  // their async data lands; tbrowse's Layout auto-flips them on then).
7545
- zones: $64fad37f770d2bfe$var$TBROWSE_ZONES.map((z)=>({
7557
+ zones: this.getTbrowseZones().map((z)=>({
7546
7558
  id: z.id,
7547
7559
  width: z.defaultWidth,
7548
7560
  visible: z.defaultVisible ?? true
@@ -7583,7 +7595,8 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
7583
7595
  ]);
7584
7596
  }
7585
7597
  this.fetchNeighborhood(treeId);
7586
- this.fetchGeneStructures(treeId, this._tbrowseData.tree);
7598
+ // Only pay for gene-structure data when the genome zone is actually enabled.
7599
+ if (this.isGenomeZoneEnabled()) this.fetchGeneStructures(treeId, this._tbrowseData.tree);
7587
7600
  }
7588
7601
  fetchNeighborhood(treeId) {
7589
7602
  if (this._neighborhoodFetchedFor === treeId) return;
@@ -7754,7 +7767,7 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
7754
7767
  exonJunctions: this._tbrowseData.exonJunctions,
7755
7768
  neighborhood: neighborhood,
7756
7769
  geneStructures: geneStructures,
7757
- zones: $64fad37f770d2bfe$var$TBROWSE_ZONES,
7770
+ zones: this.getTbrowseZones(),
7758
7771
  nodeOfInterest: this.gene._id,
7759
7772
  viewState: tbrowseVS,
7760
7773
  onViewStateChange: (next)=>this.setTbrowseViewState(next),
@@ -7791,8 +7804,8 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
7791
7804
  margin: '8px 0'
7792
7805
  },
7793
7806
  children: [
7794
- btn('treevis', 'TreeVis'),
7795
- btn('tbrowse', 'TBrowse (beta)')
7807
+ btn('tbrowse', 'TBrowse'),
7808
+ btn('treevis', 'TreeVis')
7796
7809
  ]
7797
7810
  });
7798
7811
  }
@@ -16885,6 +16898,14 @@ var $597fe213417ee6ca$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.conn
16885
16898
  // data.hostData.bins = { genomesByTaxon: { [taxonId]: genome }, maxScore }
16886
16899
  // where `genome` is a gramene-bins-client genome (fullGenomeSize,
16887
16900
  // _regionsArray, region.bin(i)/binCount()/size/name, bin.results.count).
16901
+ //
16902
+ // Pan/zoom: a single shared horizontal transform { scale, leftFrac } (fraction
16903
+ // space, so it's width-independent and aligns every genome to the same relative
16904
+ // window) lives in the ephemeral binsUI store. The header toggles between two
16905
+ // interaction modes: 'select' (drag selects a region to count/filter genes,
16906
+ // the original gesture) and 'panzoom' (drag pans, wheel zooms toward the
16907
+ // cursor). All hit-testing/coordinates are in genome-fraction [0,1] so
16908
+ // hovers/selections stay anchored to the genome as you pan and zoom.
16888
16909
 
16889
16910
 
16890
16911
 
@@ -16967,28 +16988,66 @@ function $bfd29d54a0f0e853$var$updateScore(currentScore, baseCount, binScore, bi
16967
16988
  if (typeof currentScore === 'number') return (currentScore * baseCount + binScore * binBasesUsed) / (binBasesUsed + baseCount);
16968
16989
  return binScore;
16969
16990
  }
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) {
16991
+ const $bfd29d54a0f0e853$var$clamp = (v, lo, hi)=>Math.max(lo, Math.min(hi, v));
16992
+ const $bfd29d54a0f0e853$var$MAX_SCALE = 1000; // zoom far enough to isolate a single bin
16993
+ // Locate the region/bin cursor at a target base offset, so a zoomed draw can
16994
+ // start partway into the genome without iterating skipped pixels. Returns null
16995
+ // past the end of the genome.
16996
+ function $bfd29d54a0f0e853$var$seekCursor(regions, targetBase) {
16997
+ let acc = 0;
16998
+ for(let regionIdx = 0; regionIdx < regions.length; regionIdx++){
16999
+ const region = regions[regionIdx];
17000
+ const n = region.binCount();
17001
+ for(let binIdx = 0; binIdx < n; binIdx++){
17002
+ const bin = region.bin(binIdx);
17003
+ const size = bin.end - bin.start + 1;
17004
+ if (acc + size > targetBase) return {
17005
+ regionIdx: regionIdx,
17006
+ binIdx: binIdx,
17007
+ basesInBinUsedAlready: Math.max(0, targetBase - acc)
17008
+ };
17009
+ acc += size;
17010
+ }
17011
+ }
17012
+ return null;
17013
+ }
17014
+ // Draw one genome's distribution into the visible [0, widthPx) strip at
17015
+ // vertical [y, y+height), under the shared { scale, leftFrac } transform. Only
17016
+ // the visible pixels are drawn (the cursor is fast-forwarded to the window's
17017
+ // left edge); consecutive equal-colour columns are batched into one fillRect,
17018
+ // which makes zoomed-in draws (where each bin spans many px) cheap. Ported from
17019
+ // drawGenome() in gramene-search-vis, generalised for pan/zoom.
17020
+ function $bfd29d54a0f0e853$var$drawGenomeRow(ctx, genome, y, widthPx, height, maxScore, scale, leftFrac) {
16973
17021
  const regions = genome && genome._regionsArray;
16974
- if (!regions || regions.length === 0 || !genome.fullGenomeSize) return;
16975
- const basesPerPx = genome.fullGenomeSize / width;
17022
+ const full = genome && genome.fullGenomeSize;
17023
+ if (!regions || regions.length === 0 || !full) return;
17024
+ const virtualWidth = widthPx * scale; // full genome spans this many px when zoomed
17025
+ const basesPerPx = full / virtualWidth;
16976
17026
  if (!(basesPerPx > 0)) return;
16977
- let binIdx = 0;
16978
- let basesInBinUsedAlready = 0;
16979
- let regionIdx = 0;
17027
+ const cursor = $bfd29d54a0f0e853$var$seekCursor(regions, leftFrac * full);
17028
+ if (!cursor) return;
17029
+ let { regionIdx: regionIdx, binIdx: binIdx, basesInBinUsedAlready: basesInBinUsedAlready } = cursor;
16980
17030
  let region = regions[regionIdx];
16981
17031
  let regionUnanchored = region.name === 'UNANCHORED';
16982
- for(let px = 0; px < width; px++){
17032
+ let runColor = null;
17033
+ let runStart = 0;
17034
+ const flush = (endExclusive)=>{
17035
+ if (runColor !== null && endExclusive > runStart) {
17036
+ ctx.fillStyle = runColor;
17037
+ ctx.fillRect(runStart, y, endExclusive - runStart, height);
17038
+ }
17039
+ };
17040
+ let px = 0;
17041
+ for(; px < widthPx; px++){
16983
17042
  let baseCount = 0;
16984
17043
  let score;
16985
- let basesAvailableInBin = 0;
17044
+ let ended = false;
16986
17045
  while(baseCount < basesPerPx){
16987
17046
  const basesNeededByThisPixel = basesPerPx - baseCount;
16988
17047
  const bin = region.bin(binIdx);
16989
17048
  const binSize = bin.end - bin.start + 1;
16990
17049
  const binScore = maxScore ? (bin.results ? bin.results.count : 0) / maxScore : 0;
16991
- basesAvailableInBin = binSize - basesInBinUsedAlready;
17050
+ const basesAvailableInBin = binSize - basesInBinUsedAlready;
16992
17051
  let binBasesUsed;
16993
17052
  if (basesAvailableInBin <= basesNeededByThisPixel) {
16994
17053
  binIdx++;
@@ -17003,14 +17062,30 @@ function $bfd29d54a0f0e853$var$drawGenomeRow(ctx, genome, x, y, width, height, m
17003
17062
  if (binIdx === region.binCount()) {
17004
17063
  binIdx = 0;
17005
17064
  regionIdx++;
17006
- if (regionIdx === regions.length) break;
17065
+ if (regionIdx === regions.length) {
17066
+ ended = true;
17067
+ break;
17068
+ }
17007
17069
  region = regions[regionIdx];
17008
17070
  regionUnanchored = region.name === 'UNANCHORED';
17009
17071
  }
17010
17072
  }
17011
- ctx.fillStyle = $bfd29d54a0f0e853$var$binColor(regionIdx, score || 0, regionUnanchored);
17012
- ctx.fillRect(x + px, y, 1, height);
17073
+ const idxForColor = regionIdx >= regions.length ? regions.length - 1 : regionIdx;
17074
+ const color = $bfd29d54a0f0e853$var$binColor(idxForColor, score || 0, regionUnanchored);
17075
+ if (runColor === null) {
17076
+ runColor = color;
17077
+ runStart = px;
17078
+ } else if (color !== runColor) {
17079
+ flush(px);
17080
+ runColor = color;
17081
+ runStart = px;
17082
+ }
17083
+ if (ended) {
17084
+ px++;
17085
+ break;
17086
+ }
17013
17087
  }
17088
+ flush(px);
17014
17089
  }
17015
17090
  function $bfd29d54a0f0e853$export$390e10a52782f018(taxDist) {
17016
17091
  if (!taxDist || typeof taxDist.leafNodes !== 'function') return null;
@@ -17030,12 +17105,18 @@ function $bfd29d54a0f0e853$export$390e10a52782f018(taxDist) {
17030
17105
  }
17031
17106
  // ── interaction store (shared between Header and Body via hostData) ──────────
17032
17107
  // 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.
17108
+ // hover/drag/pan/zoom updates don't churn the whole tree. Holds the hovered
17109
+ // chromosome, the in-progress select-drag, the committed drag-selections, and
17110
+ // the shared horizontal pan/zoom transform.
17111
+ const $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM = {
17112
+ scale: 1,
17113
+ leftFrac: 0
17114
+ };
17035
17115
  const $bfd29d54a0f0e853$var$EMPTY_UI = {
17036
17116
  hovered: null,
17037
17117
  inProgress: null,
17038
- selections: []
17118
+ selections: [],
17119
+ transform: $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM
17039
17120
  };
17040
17121
  const $bfd29d54a0f0e853$var$NOOP_SUB = ()=>()=>{};
17041
17122
  const $bfd29d54a0f0e853$var$NOOP_GET = ()=>$bfd29d54a0f0e853$var$EMPTY_UI;
@@ -17063,21 +17144,35 @@ function $bfd29d54a0f0e853$export$f06340802363875a() {
17063
17144
  };
17064
17145
  emit();
17065
17146
  },
17147
+ setTransform: (t)=>{
17148
+ state = {
17149
+ ...state,
17150
+ transform: t
17151
+ };
17152
+ emit();
17153
+ },
17154
+ resetTransform: ()=>{
17155
+ state = {
17156
+ ...state,
17157
+ transform: $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM
17158
+ };
17159
+ emit();
17160
+ },
17066
17161
  // Add a selection, merging it with any existing selection on the SAME
17067
- // genome whose pixel range overlaps — so overlapping drags become one
17162
+ // genome whose fraction range overlaps — so overlapping drags become one
17068
17163
  // region and shared bins aren't double-counted. Bins are unioned by their
17069
17164
  // global index.
17070
17165
  addSelection: (sel)=>{
17071
17166
  let merged = sel;
17072
17167
  const rest = [];
17073
- for (const s of state.selections)if (s.taxonId === merged.taxonId && s.x0 <= merged.x1 && s.x1 >= merged.x0) {
17168
+ for (const s of state.selections)if (s.taxonId === merged.taxonId && s.f0 <= merged.f1 && s.f1 >= merged.f0) {
17074
17169
  const byIdx = new Map();
17075
17170
  for (const b of s.bins)byIdx.set(b.idx, b.count);
17076
17171
  for (const b of merged.bins)byIdx.set(b.idx, b.count);
17077
17172
  merged = {
17078
17173
  taxonId: merged.taxonId,
17079
- x0: Math.min(s.x0, merged.x0),
17080
- x1: Math.max(s.x1, merged.x1),
17174
+ f0: Math.min(s.f0, merged.f0),
17175
+ f1: Math.max(s.f1, merged.f1),
17081
17176
  bins: [
17082
17177
  ...byIdx
17083
17178
  ].map(([idx, count])=>({
@@ -17095,7 +17190,17 @@ function $bfd29d54a0f0e853$export$f06340802363875a() {
17095
17190
  };
17096
17191
  emit();
17097
17192
  },
17193
+ // Clear hover/selections but keep the current zoom/pan transform — clearing
17194
+ // selections shouldn't yank the user back out of their zoom.
17098
17195
  clear: ()=>{
17196
+ state = {
17197
+ ...$bfd29d54a0f0e853$var$EMPTY_UI,
17198
+ transform: state.transform
17199
+ };
17200
+ emit();
17201
+ },
17202
+ // Full reset (new result set): drop everything including the transform.
17203
+ reset: ()=>{
17099
17204
  state = $bfd29d54a0f0e853$var$EMPTY_UI;
17100
17205
  emit();
17101
17206
  }
@@ -17115,55 +17220,56 @@ function $bfd29d54a0f0e853$export$861100744513cc3(selections) {
17115
17220
  ...set
17116
17221
  ];
17117
17222
  }
17118
- // Per-genome pixel layout across the [0,width) strip: region spans (for
17223
+ // Per-genome layout in genome-fraction space [0,1]: region spans (for
17119
17224
  // 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) {
17225
+ // drag-selected range). Width/zoom-independent the transform maps fractions
17226
+ // to screen pixels at render time.
17227
+ function $bfd29d54a0f0e853$var$buildGenomeLayout(genome) {
17122
17228
  const out = {
17123
17229
  regions: []
17124
17230
  };
17125
17231
  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;
17232
+ const full = genome && genome.fullGenomeSize;
17233
+ if (!regions || !full) return out;
17234
+ let base = 0;
17130
17235
  for (const region of regions){
17131
- const x0 = px;
17236
+ const f0 = base / full;
17132
17237
  const binsArr = [];
17133
17238
  const n = region.binCount();
17134
17239
  for(let i = 0; i < n; i++){
17135
17240
  const bin = region.bin(i);
17136
- const bw = (bin.end - bin.start + 1) / basesPerPx;
17241
+ const size = bin.end - bin.start + 1;
17242
+ const bf0 = base / full;
17243
+ base += size;
17137
17244
  binsArr.push({
17138
- x0: px,
17139
- x1: px + bw,
17245
+ f0: bf0,
17246
+ f1: base / full,
17140
17247
  count: bin.results && bin.results.count || 0,
17141
17248
  idx: bin.idx
17142
17249
  });
17143
- px += bw;
17144
17250
  }
17145
17251
  out.regions.push({
17146
17252
  name: region.name,
17147
- x0: x0,
17148
- x1: px,
17253
+ f0: f0,
17254
+ f1: base / full,
17149
17255
  bins: binsArr
17150
17256
  });
17151
17257
  }
17152
17258
  return out;
17153
17259
  }
17154
- function $bfd29d54a0f0e853$var$regionAtPx(layout, mx) {
17155
- for (const r of layout.regions)if (mx >= r.x0 && mx < r.x1) return r;
17260
+ function $bfd29d54a0f0e853$var$regionAtFrac(layout, f) {
17261
+ for (const r of layout.regions)if (f >= r.f0 && f < r.f1) return r;
17156
17262
  return null;
17157
17263
  }
17158
- // Non-empty bins overlapping a pixel range, as {idx, count}. Empty bins are
17264
+ // Non-empty bins overlapping a fraction range, as {idx, count}. Empty bins are
17159
17265
  // excluded by design (the selection / filter only covers bins with genes).
17160
- function $bfd29d54a0f0e853$var$selectedBinsInPxRange(layout, a, b) {
17266
+ function $bfd29d54a0f0e853$var$selectedBinsInFracRange(layout, a, b) {
17161
17267
  const lo = Math.min(a, b);
17162
17268
  const hi = Math.max(a, b);
17163
17269
  const bins = [];
17164
17270
  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({
17271
+ if (r.f1 < lo || r.f0 > hi) continue;
17272
+ for (const bin of r.bins)if (bin.f1 > lo && bin.f0 < hi && bin.count > 0) bins.push({
17167
17273
  idx: bin.idx,
17168
17274
  count: bin.count
17169
17275
  });
@@ -17177,11 +17283,46 @@ const $bfd29d54a0f0e853$var$HEADER_BTN = {
17177
17283
  padding: '1px 6px',
17178
17284
  cursor: 'pointer'
17179
17285
  };
17286
+ function $bfd29d54a0f0e853$var$ModeToggle({ mode: mode, setMode: setMode }) {
17287
+ const seg = (id, label)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
17288
+ type: "button",
17289
+ onClick: ()=>setMode(id),
17290
+ title: id === 'select' ? 'Drag to select a region' : 'Drag to pan, scroll to zoom',
17291
+ style: {
17292
+ fontSize: 11,
17293
+ lineHeight: 1,
17294
+ padding: '2px 7px',
17295
+ cursor: 'pointer',
17296
+ border: 'none',
17297
+ background: mode === id ? 'var(--tbrowse-accent, #2878dc)' : 'transparent',
17298
+ color: mode === id ? '#fff' : 'var(--tbrowse-text-muted)'
17299
+ },
17300
+ children: label
17301
+ });
17302
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
17303
+ style: {
17304
+ display: 'inline-flex',
17305
+ border: '1px solid var(--tbrowse-border-soft)',
17306
+ borderRadius: 4,
17307
+ overflow: 'hidden'
17308
+ },
17309
+ children: [
17310
+ seg('select', 'Select'),
17311
+ seg('panzoom', 'Pan/Zoom')
17312
+ ]
17313
+ });
17314
+ }
17180
17315
  const $bfd29d54a0f0e853$var$BinsHeader = ({ data: data, zoneState: zoneState, setZoneState: setZoneState })=>{
17181
17316
  const store = data.hostData && data.hostData.binsUI;
17182
17317
  const snap = (0, $gXNCa$react.useSyncExternalStore)(store ? store.subscribe : $bfd29d54a0f0e853$var$NOOP_SUB, store ? store.getState : $bfd29d54a0f0e853$var$NOOP_GET);
17183
17318
  const total = $bfd29d54a0f0e853$export$cf7300886d7d9e1e(snap.selections);
17184
17319
  const hovered = snap.hovered;
17320
+ const scale = snap.transform ? snap.transform.scale : 1;
17321
+ const mode = zoneState && zoneState.mode || 'select';
17322
+ const setMode = (m)=>setZoneState((s)=>({
17323
+ ...s ?? {},
17324
+ mode: m
17325
+ }));
17185
17326
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
17186
17327
  style: {
17187
17328
  height: '100%',
@@ -17199,7 +17340,8 @@ const $bfd29d54a0f0e853$var$BinsHeader = ({ data: data, zoneState: zoneState, se
17199
17340
  display: 'flex',
17200
17341
  alignItems: 'center',
17201
17342
  gap: 8,
17202
- minHeight: 0
17343
+ minHeight: 0,
17344
+ flexWrap: 'wrap'
17203
17345
  },
17204
17346
  children: [
17205
17347
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$tbrowse.EditableZoneName), {
@@ -17210,6 +17352,32 @@ const $bfd29d54a0f0e853$var$BinsHeader = ({ data: data, zoneState: zoneState, se
17210
17352
  name: next
17211
17353
  }))
17212
17354
  }),
17355
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($bfd29d54a0f0e853$var$ModeToggle, {
17356
+ mode: mode,
17357
+ setMode: setMode
17358
+ }),
17359
+ scale > 1.0001 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
17360
+ children: [
17361
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("span", {
17362
+ style: {
17363
+ color: 'var(--tbrowse-text-muted)',
17364
+ fontSize: 11,
17365
+ whiteSpace: 'nowrap'
17366
+ },
17367
+ children: [
17368
+ scale.toFixed(scale < 10 ? 1 : 0),
17369
+ "\xd7"
17370
+ ]
17371
+ }),
17372
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
17373
+ type: "button",
17374
+ style: $bfd29d54a0f0e853$var$HEADER_BTN,
17375
+ title: "Reset zoom",
17376
+ onClick: ()=>store && store.resetTransform(),
17377
+ children: "reset zoom"
17378
+ })
17379
+ ]
17380
+ }),
17213
17381
  snap.selections.length > 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
17214
17382
  children: [
17215
17383
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("span", {
@@ -17275,7 +17443,7 @@ function $bfd29d54a0f0e853$var$rowHighlight(isSelected, isExactHover, isInHovere
17275
17443
  return 'transparent';
17276
17444
  }
17277
17445
  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;
17446
+ const { visibleRows: visibleRows, rowRange: rowRange, width: width, data: data, zoneState: zoneState, hoveredNodeId: hoveredNodeId, hoveredSubtreeIds: hoveredSubtreeIds, selectedNodeId: selectedNodeId, onHoverNode: onHoverNode, onSelectNode: onSelectNode } = props;
17279
17447
  const canvasRef = (0, $gXNCa$react.useRef)(null);
17280
17448
  const containerRef = (0, $gXNCa$react.useRef)(null);
17281
17449
  const dragRef = (0, $gXNCa$react.useRef)(null);
@@ -17285,27 +17453,33 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17285
17453
  const suppressClickRef = (0, $gXNCa$react.useRef)(false);
17286
17454
  const store = data.hostData && data.hostData.binsUI;
17287
17455
  const snap = (0, $gXNCa$react.useSyncExternalStore)(store ? store.subscribe : $bfd29d54a0f0e853$var$NOOP_SUB, store ? store.getState : $bfd29d54a0f0e853$var$NOOP_GET);
17456
+ const transform = snap.transform || $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM;
17457
+ const mode = zoneState && zoneState.mode || 'select';
17288
17458
  const totalHeight = visibleRows.length ? visibleRows[visibleRows.length - 1].y + visibleRows[visibleRows.length - 1].height : 0;
17289
17459
  const bins = data.hostData && data.hostData.bins;
17290
17460
  const rows = visibleRows.slice(rowRange.startIndex, rowRange.endIndex);
17291
17461
  const w = Math.max(1, Math.floor(width));
17292
- // Pixel layout per visible genome row (regions + bins), for hit-testing.
17462
+ // Fraction-space layout per visible genome row (regions + bins), for
17463
+ // hit-testing. Independent of width/zoom, so it only rebuilds when the row
17464
+ // set changes.
17293
17465
  const layouts = (0, $gXNCa$react.useMemo)(()=>{
17294
17466
  const m = {};
17295
17467
  if (bins) for (const r of rows){
17296
17468
  if (r.kind !== 'leaf') continue;
17297
17469
  const g = bins.genomesByTaxon[r.nodeId];
17298
- if (g) m[r.nodeId] = $bfd29d54a0f0e853$var$buildGenomeLayout(g, w);
17470
+ if (g) m[r.nodeId] = $bfd29d54a0f0e853$var$buildGenomeLayout(g);
17299
17471
  }
17300
17472
  return m;
17301
17473
  // eslint-disable-next-line react-hooks/exhaustive-deps
17302
17474
  }, [
17303
17475
  bins,
17304
- w,
17305
17476
  rowRange.startIndex,
17306
17477
  rowRange.endIndex,
17307
17478
  visibleRows
17308
17479
  ]);
17480
+ // Fraction <-> screen-pixel helpers under the current transform.
17481
+ const fracToScreen = (f)=>(f - transform.leftFrac) * transform.scale * w;
17482
+ const screenToFrac = (px)=>transform.leftFrac + px / w / transform.scale;
17309
17483
  (0, $gXNCa$react.useEffect)(()=>{
17310
17484
  const canvas = canvasRef.current;
17311
17485
  if (!canvas || !bins) return;
@@ -17321,7 +17495,7 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17321
17495
  const genome = bins.genomesByTaxon[r.nodeId];
17322
17496
  if (!genome) continue;
17323
17497
  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);
17498
+ $bfd29d54a0f0e853$var$drawGenomeRow(ctx, genome, r.y + $bfd29d54a0f0e853$var$ROW_PAD_Y, w, innerH, bins.maxScore, transform.scale, transform.leftFrac);
17325
17499
  }
17326
17500
  }, [
17327
17501
  visibleRows,
@@ -17329,21 +17503,53 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17329
17503
  rowRange.endIndex,
17330
17504
  w,
17331
17505
  totalHeight,
17332
- bins
17506
+ bins,
17507
+ transform.scale,
17508
+ transform.leftFrac
17333
17509
  ]);
17334
17510
  const pxFromEvent = (e)=>{
17335
17511
  const el = containerRef.current;
17336
17512
  if (!el) return 0;
17337
17513
  const rect = el.getBoundingClientRect();
17338
- return Math.max(0, Math.min(w, e.clientX - rect.left));
17514
+ return $bfd29d54a0f0e853$var$clamp(e.clientX - rect.left, 0, w);
17339
17515
  };
17516
+ // Wheel-to-zoom (panzoom mode only), centred on the cursor. Native non-passive
17517
+ // listener so we can preventDefault the page scroll; re-attached when mode/
17518
+ // width change. Transform is read live from the store to avoid stale closures.
17519
+ (0, $gXNCa$react.useEffect)(()=>{
17520
+ const el = containerRef.current;
17521
+ if (!el || !store) return undefined;
17522
+ const onWheel = (e)=>{
17523
+ if (mode !== 'panzoom') return; // let the page scroll normally
17524
+ e.preventDefault();
17525
+ const t = store.getState().transform || $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM;
17526
+ const rect = el.getBoundingClientRect();
17527
+ const cursorPx = $bfd29d54a0f0e853$var$clamp(e.clientX - rect.left, 0, w);
17528
+ const fracAtCursor = t.leftFrac + cursorPx / w / t.scale;
17529
+ const factor = Math.exp(-e.deltaY * 0.0015);
17530
+ const newScale = $bfd29d54a0f0e853$var$clamp(t.scale * factor, 1, $bfd29d54a0f0e853$var$MAX_SCALE);
17531
+ const newLeft = $bfd29d54a0f0e853$var$clamp(fracAtCursor - cursorPx / w / newScale, 0, 1 - 1 / newScale);
17532
+ store.setTransform({
17533
+ scale: newScale,
17534
+ leftFrac: newLeft
17535
+ });
17536
+ };
17537
+ el.addEventListener('wheel', onWheel, {
17538
+ passive: false
17539
+ });
17540
+ return ()=>el.removeEventListener('wheel', onWheel);
17541
+ }, [
17542
+ store,
17543
+ w,
17544
+ mode
17545
+ ]);
17340
17546
  const genomeName = (taxonId)=>{
17341
17547
  const tax = data.taxonomy && data.taxonomy[taxonId];
17342
17548
  return tax && (tax.commonName || tax.scientificName) || String(taxonId);
17343
17549
  };
17344
17550
  const onRowMouseMove = (e, r)=>{
17345
17551
  if (!store || dragRef.current || !layouts[r.nodeId]) return;
17346
- const reg = $bfd29d54a0f0e853$var$regionAtPx(layouts[r.nodeId], pxFromEvent(e));
17552
+ const reg = $bfd29d54a0f0e853$var$regionAtFrac(layouts[r.nodeId], screenToFrac(pxFromEvent(e)));
17347
17553
  if (!reg) {
17348
17554
  store.setHovered(null);
17349
17555
  return;
@@ -17354,33 +17560,60 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17354
17560
  genomeName: genomeName(r.nodeId),
17355
17561
  regionName: reg.name,
17356
17562
  geneCount: geneCount,
17357
- x0: reg.x0,
17358
- x1: reg.x1
17563
+ f0: reg.f0,
17564
+ f1: reg.f1
17359
17565
  });
17360
17566
  };
17361
- const onRowPointerDown = (e, r)=>{
17362
- if (!store || !layouts[r.nodeId]) return;
17567
+ // Pan drag (panzoom mode): translate leftFrac by the cursor delta, scaled by
17568
+ // the current zoom. Shared transform → every genome row pans together.
17569
+ const startPan = (e)=>{
17363
17570
  e.preventDefault();
17364
17571
  const startPx = pxFromEvent(e);
17572
+ const t0 = store.getState().transform || $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM;
17573
+ let moved = false;
17574
+ const onMove = (ev)=>{
17575
+ const dx = pxFromEvent(ev) - startPx;
17576
+ if (Math.abs(dx) > 2) moved = true;
17577
+ const t = store.getState().transform || $bfd29d54a0f0e853$var$DEFAULT_TRANSFORM;
17578
+ const newLeft = $bfd29d54a0f0e853$var$clamp(t0.leftFrac - dx / w / t.scale, 0, 1 - 1 / t.scale);
17579
+ store.setTransform({
17580
+ scale: t.scale,
17581
+ leftFrac: newLeft
17582
+ });
17583
+ };
17584
+ const onUp = ()=>{
17585
+ document.removeEventListener('pointermove', onMove);
17586
+ document.removeEventListener('pointerup', onUp);
17587
+ if (moved) suppressClickRef.current = true;
17588
+ };
17589
+ document.addEventListener('pointermove', onMove);
17590
+ document.addEventListener('pointerup', onUp);
17591
+ };
17592
+ // Select drag (select mode): brush a fraction range and add the non-empty
17593
+ // bins under it as a committed selection.
17594
+ const startSelect = (e, r)=>{
17595
+ if (!layouts[r.nodeId]) return;
17596
+ e.preventDefault();
17597
+ const startFrac = screenToFrac(pxFromEvent(e));
17365
17598
  dragRef.current = {
17366
17599
  taxonId: r.nodeId,
17367
- startPx: startPx,
17600
+ startFrac: startFrac,
17368
17601
  moved: false
17369
17602
  };
17370
17603
  store.setInProgress({
17371
17604
  taxonId: r.nodeId,
17372
- x0: startPx,
17373
- x1: startPx
17605
+ f0: startFrac,
17606
+ f1: startFrac
17374
17607
  });
17375
17608
  const onMove = (ev)=>{
17376
17609
  const d = dragRef.current;
17377
17610
  if (!d) return;
17378
- const cur = pxFromEvent(ev);
17379
- if (Math.abs(cur - d.startPx) > 2) d.moved = true;
17611
+ const cur = screenToFrac(pxFromEvent(ev));
17612
+ if (Math.abs(cur - d.startFrac) * w * transform.scale > 2) d.moved = true;
17380
17613
  store.setInProgress({
17381
17614
  taxonId: d.taxonId,
17382
- x0: Math.min(d.startPx, cur),
17383
- x1: Math.max(d.startPx, cur)
17615
+ f0: Math.min(d.startFrac, cur),
17616
+ f1: Math.max(d.startFrac, cur)
17384
17617
  });
17385
17618
  };
17386
17619
  const onUp = (ev)=>{
@@ -17391,17 +17624,17 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17391
17624
  store.setInProgress(null);
17392
17625
  if (d && d.moved) {
17393
17626
  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);
17627
+ const cur = screenToFrac(pxFromEvent(ev));
17628
+ const f0 = Math.min(d.startFrac, cur);
17629
+ const f1 = Math.max(d.startFrac, cur);
17397
17630
  const lay = layouts[d.taxonId];
17398
17631
  if (lay) {
17399
- const bins = $bfd29d54a0f0e853$var$selectedBinsInPxRange(lay, x0, x1);
17400
- if (bins.length) store.addSelection({
17632
+ const selBins = $bfd29d54a0f0e853$var$selectedBinsInFracRange(lay, f0, f1);
17633
+ if (selBins.length) store.addSelection({
17401
17634
  taxonId: d.taxonId,
17402
- x0: x0,
17403
- x1: x1,
17404
- bins: bins
17635
+ f0: f0,
17636
+ f1: f1,
17637
+ bins: selBins
17405
17638
  });
17406
17639
  }
17407
17640
  }
@@ -17409,15 +17642,29 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17409
17642
  document.addEventListener('pointermove', onMove);
17410
17643
  document.addEventListener('pointerup', onUp);
17411
17644
  };
17645
+ const onRowPointerDown = (e, r)=>{
17646
+ if (!store) return;
17647
+ if (mode === 'panzoom') {
17648
+ startPan(e);
17649
+ return;
17650
+ }
17651
+ startSelect(e, r);
17652
+ };
17412
17653
  const rowByTaxon = new Map(rows.map((r)=>[
17413
17654
  r.nodeId,
17414
17655
  r
17415
17656
  ]));
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)=>{
17657
+ // Outline rectangles (drawn above the canvas; pointer-events:none). Stored in
17658
+ // fraction space and projected to screen px under the transform, clipped to
17659
+ // the visible strip. The y comes from the current row so they track scroll.
17660
+ const outline = (key, taxonId, f0, f1, color, dashed)=>{
17419
17661
  const r = rowByTaxon.get(taxonId);
17420
17662
  if (!r) return null;
17663
+ let x0 = fracToScreen(f0);
17664
+ let x1 = fracToScreen(f1);
17665
+ if (x1 <= 0 || x0 >= w) return null; // fully outside the visible window
17666
+ x0 = Math.max(0, x0);
17667
+ x1 = Math.min(w, x1);
17421
17668
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
17422
17669
  style: {
17423
17670
  position: 'absolute',
@@ -17431,6 +17678,7 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17431
17678
  }
17432
17679
  }, key);
17433
17680
  };
17681
+ const rowCursor = mode === 'panzoom' ? 'grab' : 'crosshair';
17434
17682
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
17435
17683
  ref: containerRef,
17436
17684
  style: {
@@ -17461,7 +17709,7 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17461
17709
  right: 0,
17462
17710
  height: r.height,
17463
17711
  background: $bfd29d54a0f0e853$var$rowHighlight(selectedNodeId === r.nodeId, hoveredNodeId === r.nodeId, !!(hoveredSubtreeIds && hoveredSubtreeIds.has(r.nodeId))),
17464
- cursor: 'crosshair',
17712
+ cursor: rowCursor,
17465
17713
  opacity: r.opacity ?? 1
17466
17714
  }
17467
17715
  }, r.nodeId)),
@@ -17476,9 +17724,9 @@ const $bfd29d54a0f0e853$var$BinsBody = (props)=>{
17476
17724
  pointerEvents: 'none'
17477
17725
  }
17478
17726
  }),
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)
17727
+ snap.hovered && !snap.inProgress && outline('hover', snap.hovered.taxonId, snap.hovered.f0, snap.hovered.f1, 'var(--tbrowse-accent, #2878dc)', false),
17728
+ snap.selections.map((s, i)=>outline(`sel-${i}`, s.taxonId, s.f0, s.f1, '#d62728', false)),
17729
+ snap.inProgress && outline('drag', snap.inProgress.taxonId, snap.inProgress.f0, snap.inProgress.f1, '#d62728', true)
17482
17730
  ]
17483
17731
  });
17484
17732
  };
@@ -17489,7 +17737,9 @@ const $bfd29d54a0f0e853$export$d85d069f2280460c = {
17489
17737
  Body: $bfd29d54a0f0e853$var$BinsBody,
17490
17738
  defaultWidth: 60,
17491
17739
  minWidth: 200,
17492
- defaultZoneState: {},
17740
+ defaultZoneState: {
17741
+ mode: 'select'
17742
+ },
17493
17743
  isAvailable: (data)=>Boolean(data.hostData && data.hostData.bins && data.hostData.bins.genomesByTaxon && Object.keys(data.hostData.bins.genomesByTaxon).length > 0),
17494
17744
  defaultVisible: true
17495
17745
  };
@@ -17724,7 +17974,7 @@ const $15504f5eba8e73bd$var$TaxDistTbrowse = (props)=>{
17724
17974
  const binsUiRef = (0, $gXNCa$react.useRef)(null);
17725
17975
  if (!binsUiRef.current) binsUiRef.current = (0, $bfd29d54a0f0e853$export$f06340802363875a)();
17726
17976
  (0, $gXNCa$react.useEffect)(()=>{
17727
- binsUiRef.current.clear();
17977
+ binsUiRef.current.reset();
17728
17978
  }, [
17729
17979
  grameneTaxDist
17730
17980
  ]);