@jbrowse/plugin-linear-genome-view 5.0.0-beta.2 → 5.0.0-beta.3

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.
@@ -14,7 +14,7 @@ import { assemblyErrorMessage, computeViewStatus, } from '@jbrowse/core/util/vie
14
14
  import { pendingLaunch, withLaunchInput, } from '@jbrowse/core/util/withLaunchInput';
15
15
  import { contentRightEdgePx } from '@jbrowse/display-kit/regionHost';
16
16
  import { cast, getParent, hasParent, isAlive, types, } from '@jbrowse/mobx-state-tree';
17
- import { when } from 'mobx';
17
+ import { observable, when } from 'mobx';
18
18
  import { handleSelectedRegion } from '../searchUtils.js';
19
19
  import { doAfterAttach } from './afterAttach.js';
20
20
  import { shouldSwapTracks } from './components/util.js';
@@ -335,12 +335,20 @@ export function stateModelFactory(pluginManager) {
335
335
  */
336
336
  launch: types.frozen(),
337
337
  }))
338
- .volatile(self => {
338
+ .volatile(() => {
339
339
  // typed locals so `unknown`/`Record` aren't narrowed to `undefined`/`{}`; inline
340
340
  // type assertions here get stripped by no-unnecessary-type-assertion
341
341
  const volatileError = undefined;
342
- const trackRefs = {};
343
342
  return {
343
+ /**
344
+ * #volatile
345
+ * Height of each track's in-flow label band, measured by
346
+ * TrackContainer as the rendering container's offset inside its Paper.
347
+ * Absent (0) while the label is hidden, overlapping, or not yet
348
+ * measured. Observable, so the offset and height getters re-derive
349
+ * when a band changes.
350
+ */
351
+ trackLabelBands: observable.map(),
344
352
  /**
345
353
  * #volatile
346
354
  */
@@ -361,10 +369,6 @@ export function stateModelFactory(pluginManager) {
361
369
  * #volatile
362
370
  */
363
371
  volatileError,
364
- /**
365
- * #volatile
366
- */
367
- trackRefs,
368
372
  /**
369
373
  * #volatile
370
374
  */
@@ -376,14 +380,10 @@ export function stateModelFactory(pluginManager) {
376
380
  /**
377
381
  * #volatile
378
382
  */
379
- // What `bpPerPx` used to hold at creation — the snapshot's value, or
380
- // the default of 1 — which cannot be read here now that it is derived
381
- // and declared after volatile. It has to be a real scale rather than
382
- // the not-yet-measured 0: the canvas displays lay out against
383
- // `coarseBpPerPx` rather than the live one, and a zero there is a
384
- // degenerate layout for the frame or two before the debounced autorun
385
- // first writes it.
386
- coarseBpPerPx: self.legacyBpPerPx || 1,
383
+ // A real scale rather than the not-yet-measured 0, for the canvas
384
+ // displays that lay out against it; `setWidth` seeds the true one at
385
+ // the first measure.
386
+ coarseBpPerPx: 1,
387
387
  /**
388
388
  * #volatile
389
389
  */
@@ -529,6 +529,14 @@ export function stateModelFactory(pluginManager) {
529
529
  },
530
530
  }))
531
531
  .views(self => ({
532
+ /**
533
+ * #getter
534
+ * The census entry for this view: the tracks it holds itself. Declared
535
+ * rather than derived — see `BaseViewModel.ownTracks`.
536
+ */
537
+ get ownTracks() {
538
+ return [...self.tracks];
539
+ },
532
540
  /**
533
541
  * #getter
534
542
  * Assembly-name prefix for the scalebar refName labels, or undefined for
@@ -758,14 +766,10 @@ export function stateModelFactory(pluginManager) {
758
766
  * #getter
759
767
  * A track's full cost beyond its display height.
760
768
  *
761
- * The track *label* is not counted, and cannot be: an offset label is an
762
- * in-flow box whose height is whatever the theme renders a Paper of icon
763
- * buttons at — 31.140625px on the stock theme, and not a number this file
764
- * can derive. So these getters are exact while labels are hidden or
765
- * overlapping, and short by one label box per labelled track otherwise.
766
- * Anything needing the offset to the pixel with labels showing measures
767
- * the DOM; `BreakpointSplitViewOverlay` does, and falls back to this
768
- * arithmetic only for a track with no mounted div.
769
+ * The track *label* is not counted here: an offset label is an in-flow
770
+ * box whose height is whatever the theme renders a Paper of icon buttons
771
+ * at, so it is measured into `trackLabelBands` per track and added by
772
+ * `trackLabelBand` rather than derived.
769
773
  */
770
774
  get trackChromeHeight() {
771
775
  return this.trackLeadingChrome + this.trackTrailingChrome;
@@ -816,6 +820,14 @@ export function stateModelFactory(pluginManager) {
816
820
  ? MINIMIZED_TRACK_HEIGHT
817
821
  : track.activeDisplay.height;
818
822
  },
823
+ /**
824
+ * #method
825
+ * the measured in-flow label band above a track's rendering container,
826
+ * 0 until TrackContainer has measured one or when no label is in flow
827
+ */
828
+ trackLabelBand(track) {
829
+ return self.trackLabelBands.get(track.configuration.trackId) ?? 0;
830
+ },
819
831
  /**
820
832
  * #getter
821
833
  */
@@ -826,7 +838,9 @@ export function stateModelFactory(pluginManager) {
826
838
  * #getter
827
839
  */
828
840
  get trackHeightsWithChrome() {
829
- return this.trackHeights + self.tracks.length * this.trackChromeHeight;
841
+ return (this.trackHeights +
842
+ self.tracks.length * this.trackChromeHeight +
843
+ sum(self.tracks.map(t => this.trackLabelBand(t))));
830
844
  },
831
845
  /**
832
846
  * #getter
@@ -844,14 +858,15 @@ export function stateModelFactory(pluginManager) {
844
858
  * first, then unpinned), from the same constants TrackContainer lays its
845
859
  * Paper out with. Returns `undefined` if the track is not present.
846
860
  *
847
- * Exact while track labels are hidden or overlapping. With an offset
848
- * label the answer is short by one label box per labelled track above
849
- * this one — see `trackChromeHeight` for why that box is not derivable
850
- * here.
861
+ * Includes each track's measured label band, this track's own included,
862
+ * since an in-flow label sits above the rendering container inside the
863
+ * same Paper. Exact once TrackContainer has measured; before that it is
864
+ * short by the unmeasured bands.
851
865
  */
852
866
  getTrackYOffset(trackId) {
853
867
  let y = this.headerHeight + this.scalebarHeight + this.trackLeadingChrome;
854
868
  for (const t of [...self.pinnedTracks, ...self.unpinnedTracks]) {
869
+ y += this.trackLabelBand(t);
855
870
  if (t.configuration.trackId === trackId) {
856
871
  return y;
857
872
  }
@@ -1062,7 +1077,16 @@ export function stateModelFactory(pluginManager) {
1062
1077
  // the gesture into one refetch, its in-flight guard caps concurrent
1063
1078
  // batches at one, and rpcDataMap is overwritten in place rather than
1064
1079
  // cleared, so nothing blanks (ADR-008, ADR-006).
1080
+ setTrackLabelBand(trackId, band) {
1081
+ if (band === 0) {
1082
+ self.trackLabelBands.delete(trackId);
1083
+ }
1084
+ else {
1085
+ self.trackLabelBands.set(trackId, band);
1086
+ }
1087
+ },
1065
1088
  setWidth(newWidth) {
1089
+ const unmeasured = self.bpPerPx <= 0;
1066
1090
  self.volatileWidth = newWidth;
1067
1091
  if (newWidth > 0 && self.windowWidthBp <= 0) {
1068
1092
  // First measure, and nothing has established a window yet: a migrated
@@ -1073,6 +1097,11 @@ export function stateModelFactory(pluginManager) {
1073
1097
  self.windowWidthBp = (self.legacyBpPerPx || 1) * newWidth;
1074
1098
  self.legacyBpPerPx = 0;
1075
1099
  }
1100
+ if (unmeasured && self.bpPerPx > 0) {
1101
+ // The debounced coarse autorun is 500ms out; a restored snapshot's
1102
+ // consumers would lay out and fetch at 1 bp/px until then.
1103
+ self.coarseBpPerPx = self.bpPerPx;
1104
+ }
1076
1105
  },
1077
1106
  /**
1078
1107
  * #action
@@ -1428,13 +1457,20 @@ export function stateModelFactory(pluginManager) {
1428
1457
  },
1429
1458
  /**
1430
1459
  * #method
1431
- * creates an svg export and save using FileSaver
1460
+ * renders the view to SVG markup, which it returns; saves it through
1461
+ * FileSaver unless `save: false`
1432
1462
  */
1463
+ // Promise<string> is stated, against the house preference for inferred
1464
+ // return types: renderToSvg is typed against this very model, so an
1465
+ // inferred return makes the model type reference itself (TS2456).
1433
1466
  async exportSvg(opts = {}) {
1434
1467
  const { renderToSvg } = await import('./svgcomponents/SVGLinearGenomeView.js');
1435
1468
  const html = await renderToSvg(self, opts);
1436
- const { saveSvgAsImage } = await import('@jbrowse/core/svg/saveSvgAsImage');
1437
- await saveSvgAsImage(html, opts);
1469
+ if (opts.save !== false) {
1470
+ const { saveSvgAsImage } = await import('@jbrowse/core/svg/saveSvgAsImage');
1471
+ await saveSvgAsImage(html, opts);
1472
+ }
1473
+ return html;
1438
1474
  },
1439
1475
  }))
1440
1476
  .actions(self => {
@@ -2737,7 +2773,7 @@ export function stateModelFactory(pluginManager) {
2737
2773
  * `centerAt`'s animated twin: the same destination, reached along the
2738
2774
  * arc instead of jumped to, at the zoom the view is already on.
2739
2775
  */
2740
- flyToCenter(coord, refName) {
2776
+ flyToCenter(coord, refName, displayedRegionIndex) {
2741
2777
  // `bpToLinearBp` rather than `bpToPx`, which rounds to a whole pixel
2742
2778
  // at the current zoom: mid-flight that zoom is a point on somebody
2743
2779
  // else's arc, and a destination quantized against it moves with it.
@@ -2745,6 +2781,7 @@ export function stateModelFactory(pluginManager) {
2745
2781
  refName,
2746
2782
  coord,
2747
2783
  displayedRegions: self.displayedRegions,
2784
+ displayedRegionIndex,
2748
2785
  });
2749
2786
  if (centerBp !== undefined) {
2750
2787
  flyTo(centerBp, heading ? heading.windowWidthBp : self.windowWidthBp);
@@ -2760,7 +2797,10 @@ export function stateModelFactory(pluginManager) {
2760
2797
  doAfterAttach(self);
2761
2798
  },
2762
2799
  }));
2763
- return withLaunchInput(model, lgvLaunchKeys)
2800
+ return withLaunchInput(model, lgvLaunchKeys, {
2801
+ registry: pluginManager,
2802
+ materialized: snap => !!snap.displayedRegions?.length,
2803
+ })
2764
2804
  .preProcessSnapshot((snap) => {
2765
2805
  if (!snap) {
2766
2806
  return snap;
@@ -2802,24 +2842,16 @@ export function stateModelFactory(pluginManager) {
2802
2842
  };
2803
2843
  })
2804
2844
  .postProcessSnapshot(snap => {
2805
- // launch is transient, never persisted. showCenterLine is
2806
- // purely a localStorage-backed preference (see setupLocalStorageAutorun
2807
- // in afterAttach.ts) and is never persisted into session snapshots. The
2808
- // remaining fields are also localStorage-backed, but still persist when
2809
- // non-default: their strip baseline is the universal default (hardcoded
2810
- // here), not the localStorage-derived creation default, so a
2811
- // localStorage-set value stays portable across browsers.
2812
- const { launch, showCenterLine, showCytobands, trackLabels, colorByCDS, showAminoAcids, showTrackOutlines, ...rest } = snap;
2845
+ // showCenterLine is purely a localStorage-backed preference (see
2846
+ // setupLocalStorageAutorun in afterAttach.ts) and is never persisted
2847
+ // into session snapshots. The remaining fields are also
2848
+ // localStorage-backed, but still persist when non-default: their strip
2849
+ // baseline is the universal default (hardcoded here), not the
2850
+ // localStorage-derived creation default, so a localStorage-set value
2851
+ // stays portable across browsers.
2852
+ const { showCenterLine, showCytobands, trackLabels, colorByCDS, showAminoAcids, showTrackOutlines, ...rest } = snap;
2813
2853
  return {
2814
2854
  ...rest,
2815
- // keep the launch state until displayedRegions exist, so a snapshot
2816
- // taken before the launch autorun navigates (e.g. autosave firing
2817
- // mid-load) can still rebuild the view instead of dropping to the
2818
- // import form. displayedRegions is stripDefault, so it's absent (not
2819
- // []) when empty — the optional chain is runtime-necessary despite the
2820
- // non-nullish type.
2821
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
2822
- ...(launch && !snap.displayedRegions?.length ? { launch } : {}),
2823
2855
  ...(!showCytobands ? { showCytobands } : {}),
2824
2856
  ...(trackLabels ? { trackLabels } : {}),
2825
2857
  ...(colorByCDS ? { colorByCDS } : {}),
@@ -1,6 +1,14 @@
1
1
  import type { AssemblyManager, ParsedLocString } from '@jbrowse/core/util';
2
+ import type { PxToBpResult } from '@jbrowse/core/util/Base1DUtils';
2
3
  import type { BaseBlock, ContentBlock } from '@jbrowse/core/util/blockTypes';
3
4
  import type { Region } from '@jbrowse/core/util/types';
5
+ interface CenterKeepingView {
6
+ width: number;
7
+ displayedRegions: Region[];
8
+ pxToBp: (px: number) => PxToBpResult;
9
+ setDisplayedRegions: (regions: Region[]) => void;
10
+ centerAt: (coord: number, refName: string, index?: number) => void;
11
+ }
4
12
  /**
5
13
  * The stored viewport of a view showing `totalBp` of displayed regions fitted to
6
14
  * `width` — the regions filling it edge to edge, and centered in it only where
@@ -197,7 +205,29 @@ export declare function regionMoveActions(idx: number, numRegions: number): {
197
205
  * the new region set).
198
206
  */
199
207
  export declare function withRegionMoved(regions: Region[], from: number, to: number): Region[];
208
+ /**
209
+ * Replace the region list and keep looking at whatever was under the middle of
210
+ * the viewport, when that region is in the new list.
211
+ *
212
+ * `setDisplayedRegions` carries `offsetPx` across and clamps it, which is a
213
+ * linear position in a coordinate space the write just redefined: dropping the
214
+ * regions to the left of the one on screen strands the view at the end of what
215
+ * remains, on blank space. Where the centred region is gone the clamp is still
216
+ * the only answer, so this falls back to it.
217
+ */
218
+ export declare function setDisplayedRegionsKeepingCenter(model: CenterKeepingView, regions: Region[]): void;
200
219
  export declare function withRegionRemoved(regions: Region[], index: number): Region[];
220
+ /**
221
+ * The full run of same-orientation regions on one refName that a scalebar
222
+ * label's `firstRegionIndex`..`lastRegionIndex` sits inside. Those come off the
223
+ * static blocks, which are clipped to the viewport and broken by an elided
224
+ * block, so a zoomed-in collapsed-introns view brackets only the exons on
225
+ * screen — which is the wrong answer for anything acting on the whole run.
226
+ */
227
+ export declare function regionRunBounds(regions: Region[], firstIndex: number, lastIndex: number): {
228
+ first: number;
229
+ last: number;
230
+ };
201
231
  export declare function withRegionReversed(regions: Region[], index: number): Region[];
202
232
  /**
203
233
  * Whether a label occupying [leftPx, leftPx + labelWidth] fits within a block
@@ -348,3 +378,4 @@ export declare function generateLocations({ regions, assemblyManager, assemblyNa
348
378
  */
349
379
  export declare function parseLocStrings(input: string, assemblyName: string, isValidRefName: (str: string, assemblyName: string) => boolean): ParsedLocString[];
350
380
  export declare function calculateVisibleLocStrings(contentBlocks: ContentBlock[]): string;
381
+ export {};
@@ -415,9 +415,57 @@ export function withRegionMoved(regions, from, to) {
415
415
  out.splice(to, 0, moved);
416
416
  return out;
417
417
  }
418
+ /**
419
+ * Replace the region list and keep looking at whatever was under the middle of
420
+ * the viewport, when that region is in the new list.
421
+ *
422
+ * `setDisplayedRegions` carries `offsetPx` across and clamps it, which is a
423
+ * linear position in a coordinate space the write just redefined: dropping the
424
+ * regions to the left of the one on screen strands the view at the end of what
425
+ * remains, on blank space. Where the centred region is gone the clamp is still
426
+ * the only answer, so this falls back to it.
427
+ */
428
+ export function setDisplayedRegionsKeepingCenter(model, regions) {
429
+ const center = model.displayedRegions.length
430
+ ? model.pxToBp(model.width / 2)
431
+ : undefined;
432
+ model.setDisplayedRegions(regions);
433
+ const index = center
434
+ ? regions.findIndex(r => r.refName === center.refName &&
435
+ r.start === center.start &&
436
+ r.end === center.end)
437
+ : -1;
438
+ if (center && index !== -1) {
439
+ model.centerAt(center.coord0, center.refName, index);
440
+ }
441
+ }
418
442
  export function withRegionRemoved(regions, index) {
419
443
  return regions.filter((_, i) => i !== index);
420
444
  }
445
+ /**
446
+ * The full run of same-orientation regions on one refName that a scalebar
447
+ * label's `firstRegionIndex`..`lastRegionIndex` sits inside. Those come off the
448
+ * static blocks, which are clipped to the viewport and broken by an elided
449
+ * block, so a zoomed-in collapsed-introns view brackets only the exons on
450
+ * screen — which is the wrong answer for anything acting on the whole run.
451
+ */
452
+ export function regionRunBounds(regions, firstIndex, lastIndex) {
453
+ const anchor = regions[firstIndex];
454
+ const inRun = (r) => r !== undefined &&
455
+ anchor !== undefined &&
456
+ r.refName === anchor.refName &&
457
+ r.assemblyName === anchor.assemblyName &&
458
+ !!r.reversed === !!anchor.reversed;
459
+ let first = firstIndex;
460
+ let last = lastIndex;
461
+ while (inRun(regions[first - 1])) {
462
+ first--;
463
+ }
464
+ while (inRun(regions[last + 1])) {
465
+ last++;
466
+ }
467
+ return { first, last };
468
+ }
421
469
  export function withRegionReversed(regions, index) {
422
470
  return regions.map((region, i) => i === index ? { ...region, reversed: !region.reversed } : region);
423
471
  }