@jbrowse/plugin-linear-genome-view 5.0.0-beta.1 → 5.0.0-beta.11

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.
Files changed (148) hide show
  1. package/esm/FeatureTrack/configSchema.d.ts +142 -50
  2. package/esm/LaunchLinearGenomeView/index.js +2 -13
  3. package/esm/LinearGenomeView/afterAttach.d.ts +5 -5
  4. package/esm/LinearGenomeView/afterAttach.js +75 -62
  5. package/esm/LinearGenomeView/components/CenterLine.js +5 -3
  6. package/esm/LinearGenomeView/components/CrisprGuidePanel.js +3 -5
  7. package/esm/LinearGenomeView/components/ExportSvgDialog.d.ts +3 -2
  8. package/esm/LinearGenomeView/components/ExportSvgDialog.js +6 -2
  9. package/esm/LinearGenomeView/components/GetSequenceDialog.js +2 -2
  10. package/esm/LinearGenomeView/components/Header.js +11 -1
  11. package/esm/LinearGenomeView/components/HeaderClearHighlightButton.js +2 -2
  12. package/esm/LinearGenomeView/components/HeaderPanControls.js +2 -1
  13. package/esm/LinearGenomeView/components/HeaderZoomControls.js +4 -34
  14. package/esm/LinearGenomeView/components/Highlight.js +7 -7
  15. package/esm/LinearGenomeView/components/HighlightBand.d.ts +9 -1
  16. package/esm/LinearGenomeView/components/HighlightBand.js +15 -3
  17. package/esm/LinearGenomeView/components/HighlightChip.d.ts +1 -2
  18. package/esm/LinearGenomeView/components/HighlightChip.js +5 -7
  19. package/esm/LinearGenomeView/components/HoverPositionHighlight.js +3 -3
  20. package/esm/LinearGenomeView/components/ImportForm.js +2 -2
  21. package/esm/LinearGenomeView/components/LinearGenomeView.js +3 -3
  22. package/esm/LinearGenomeView/components/LinearGenomeViewContainer.js +3 -4
  23. package/esm/LinearGenomeView/components/MiniControls.js +3 -2
  24. package/esm/LinearGenomeView/components/OverviewHighlight.js +3 -3
  25. package/esm/LinearGenomeView/components/OverviewHighlightBand.js +2 -3
  26. package/esm/LinearGenomeView/components/OverviewRubberband.js +20 -45
  27. package/esm/LinearGenomeView/components/OverviewScalebar.js +4 -5
  28. package/esm/LinearGenomeView/components/OverviewScalebarPolygon.js +5 -3
  29. package/esm/LinearGenomeView/components/PaddingBlocks.d.ts +1 -1
  30. package/esm/LinearGenomeView/components/PaddingBlocks.js +14 -9
  31. package/esm/LinearGenomeView/components/RangeSelectOverlay.js +5 -6
  32. package/esm/LinearGenomeView/components/ResizeAllTracksHandle.d.ts +5 -0
  33. package/esm/LinearGenomeView/components/ResizeAllTracksHandle.js +71 -0
  34. package/esm/LinearGenomeView/components/Rubberband.d.ts +1 -2
  35. package/esm/LinearGenomeView/components/Rubberband.js +3 -2
  36. package/esm/LinearGenomeView/components/SVGHighlightBand.js +2 -3
  37. package/esm/LinearGenomeView/components/Scalebar.d.ts +2 -6
  38. package/esm/LinearGenomeView/components/Scalebar.js +8 -5
  39. package/esm/LinearGenomeView/components/ScalebarRefNameLabels.js +49 -34
  40. package/esm/LinearGenomeView/components/SearchBox.js +8 -7
  41. package/esm/LinearGenomeView/components/SearchResultsDialog.d.ts +2 -1
  42. package/esm/LinearGenomeView/components/SearchResultsDialog.js +2 -2
  43. package/esm/LinearGenomeView/components/SearchResultsTable.d.ts +2 -1
  44. package/esm/LinearGenomeView/components/SearchResultsTable.js +12 -29
  45. package/esm/LinearGenomeView/components/SequenceSearchDialog.d.ts +2 -4
  46. package/esm/LinearGenomeView/components/TrackContainer.js +63 -9
  47. package/esm/LinearGenomeView/components/TrackLabel.js +10 -3
  48. package/esm/LinearGenomeView/components/TrackLabelButtons.js +2 -2
  49. package/esm/LinearGenomeView/components/TrackLabelDragHandle.js +2 -2
  50. package/esm/LinearGenomeView/components/TrackLabelMenu.js +3 -2
  51. package/esm/LinearGenomeView/components/TrackRenderingContainer.d.ts +4 -1
  52. package/esm/LinearGenomeView/components/TrackRenderingContainer.js +11 -23
  53. package/esm/LinearGenomeView/components/TracksContainer.js +10 -22
  54. package/esm/LinearGenomeView/components/VerticalGuide.d.ts +1 -2
  55. package/esm/LinearGenomeView/components/VerticalGuide.js +3 -16
  56. package/esm/LinearGenomeView/components/ZoomButton.js +6 -1
  57. package/esm/LinearGenomeView/components/ZoomTransform.js +4 -0
  58. package/esm/LinearGenomeView/components/fetchSequence.d.ts +1 -2
  59. package/esm/LinearGenomeView/components/recentLocationsMenu.js +10 -0
  60. package/esm/LinearGenomeView/components/searchModes.d.ts +1 -1
  61. package/esm/LinearGenomeView/components/searchModes.js +1 -1
  62. package/esm/LinearGenomeView/components/useRangeSelect.d.ts +0 -3
  63. package/esm/LinearGenomeView/components/useRangeSelect.js +20 -59
  64. package/esm/LinearGenomeView/components/useSideScroll.d.ts +16 -2
  65. package/esm/LinearGenomeView/components/useSideScroll.js +153 -103
  66. package/esm/LinearGenomeView/components/util.d.ts +4 -0
  67. package/esm/LinearGenomeView/components/util.js +4 -2
  68. package/esm/LinearGenomeView/consts.d.ts +2 -0
  69. package/esm/LinearGenomeView/consts.js +3 -1
  70. package/esm/LinearGenomeView/containingLgv.d.ts +21 -0
  71. package/esm/LinearGenomeView/containingLgv.js +23 -0
  72. package/esm/LinearGenomeView/headerFit.d.ts +4 -2
  73. package/esm/LinearGenomeView/headerFit.js +20 -7
  74. package/esm/LinearGenomeView/index.d.ts +1 -5
  75. package/esm/LinearGenomeView/index.js +10 -7
  76. package/esm/LinearGenomeView/keyboardHandler.d.ts +2 -0
  77. package/esm/LinearGenomeView/keyboardHandler.js +15 -1
  78. package/esm/LinearGenomeView/launchKeys.d.ts +2 -0
  79. package/esm/LinearGenomeView/launchKeys.js +27 -0
  80. package/esm/LinearGenomeView/lazyDialogs.d.ts +2 -4
  81. package/esm/LinearGenomeView/lazyDialogs.js +1 -0
  82. package/esm/LinearGenomeView/linkedViewSync.d.ts +3 -11
  83. package/esm/LinearGenomeView/linkedViewSync.js +27 -43
  84. package/esm/LinearGenomeView/menuItems.d.ts +16 -18
  85. package/esm/LinearGenomeView/menuItems.js +242 -151
  86. package/esm/LinearGenomeView/model.d.ts +2027 -200
  87. package/esm/LinearGenomeView/model.js +560 -395
  88. package/esm/LinearGenomeView/sharedScaleContainer.d.ts +1 -1
  89. package/esm/LinearGenomeView/showRegionsWithUndo.js +7 -1
  90. package/esm/LinearGenomeView/stickyChrome.d.ts +5 -9
  91. package/esm/LinearGenomeView/stickyChrome.js +5 -9
  92. package/esm/LinearGenomeView/svgcomponents/SVGGridlines.js +4 -4
  93. package/esm/LinearGenomeView/svgcomponents/SVGHeader.d.ts +4 -1
  94. package/esm/LinearGenomeView/svgcomponents/SVGHeader.js +8 -21
  95. package/esm/LinearGenomeView/svgcomponents/SVGHighlights.js +8 -8
  96. package/esm/LinearGenomeView/svgcomponents/SVGHighlightsOverlay.js +3 -3
  97. package/esm/LinearGenomeView/svgcomponents/SVGLinearGenomeView.js +7 -11
  98. package/esm/LinearGenomeView/svgcomponents/SVGRegionSeparators.js +2 -5
  99. package/esm/LinearGenomeView/svgcomponents/SVGRowHeader.js +5 -6
  100. package/esm/LinearGenomeView/svgcomponents/SVGRuler.js +9 -10
  101. package/esm/LinearGenomeView/svgcomponents/SVGScalebar.js +3 -3
  102. package/esm/LinearGenomeView/svgcomponents/SVGStackedRow.d.ts +27 -0
  103. package/esm/LinearGenomeView/svgcomponents/SVGStackedRow.js +12 -0
  104. package/esm/LinearGenomeView/svgcomponents/SVGTrackLabel.d.ts +2 -1
  105. package/esm/LinearGenomeView/svgcomponents/SVGTrackLabel.js +29 -16
  106. package/esm/LinearGenomeView/svgcomponents/SVGTracks.d.ts +4 -4
  107. package/esm/LinearGenomeView/svgcomponents/SVGTracks.js +6 -5
  108. package/esm/LinearGenomeView/svgcomponents/SVGView.d.ts +1 -1
  109. package/esm/LinearGenomeView/svgcomponents/SVGView.js +1 -1
  110. package/esm/LinearGenomeView/svgcomponents/figureContract.js +14 -14
  111. package/esm/LinearGenomeView/svgcomponents/renderViewTracks.d.ts +1 -0
  112. package/esm/LinearGenomeView/svgcomponents/renderViewTracks.js +1 -1
  113. package/esm/LinearGenomeView/svgcomponents/useViewSvgFigure.js +50 -68
  114. package/esm/LinearGenomeView/svgcomponents/util.d.ts +20 -8
  115. package/esm/LinearGenomeView/svgcomponents/util.js +50 -29
  116. package/esm/LinearGenomeView/types.d.ts +8 -11
  117. package/esm/LinearGenomeView/util.d.ts +60 -45
  118. package/esm/LinearGenomeView/util.js +131 -160
  119. package/esm/MultiLevelRubberband/MultiLevelRubberband.js +7 -4
  120. package/esm/MultiLevelRubberband/index.d.ts +2 -0
  121. package/esm/MultiLevelRubberband/index.js +2 -0
  122. package/esm/MultiLevelRubberband/menuItems.d.ts +34 -0
  123. package/esm/MultiLevelRubberband/menuItems.js +52 -0
  124. package/esm/MultiLevelRubberband/rowLabel.d.ts +22 -0
  125. package/esm/MultiLevelRubberband/rowLabel.js +27 -0
  126. package/esm/MultiLevelRubberband/types.d.ts +1 -0
  127. package/esm/MultiLevelRubberband/useRangeSelect.d.ts +2 -1
  128. package/esm/MultiLevelRubberband/useRangeSelect.js +31 -47
  129. package/esm/displayKitTests/perRegionTestEnv.d.ts +244 -86
  130. package/esm/displayKitTests/perRegionTestEnv.js +30 -29
  131. package/esm/index.d.ts +15 -85
  132. package/esm/index.js +13 -36
  133. package/esm/searchUtils.d.ts +18 -9
  134. package/esm/searchUtils.js +85 -69
  135. package/esm/shared/RubberbandSpan.js +2 -1
  136. package/esm/shared/coordLabels.d.ts +7 -0
  137. package/esm/shared/coordLabels.js +15 -1
  138. package/esm/shared/useWindowDrag.d.ts +18 -0
  139. package/esm/shared/useWindowDrag.js +45 -0
  140. package/package.json +10 -10
  141. package/esm/LinearGenomeView/FloatingSvgOverlay.d.ts +0 -5
  142. package/esm/LinearGenomeView/FloatingSvgOverlay.js +0 -25
  143. package/esm/LinearGenomeView/initKeys.d.ts +0 -13
  144. package/esm/LinearGenomeView/initKeys.js +0 -104
  145. package/esm/MultiLevelRubberband/VerticalGuide.d.ts +0 -6
  146. package/esm/MultiLevelRubberband/VerticalGuide.js +0 -21
  147. package/esm/lazyPluginExports.d.ts +0 -23
  148. package/esm/lazyPluginExports.js +0 -36
@@ -1,29 +1,39 @@
1
1
  import { lazy } from 'react';
2
2
  import { getConf } from '@jbrowse/core/configuration';
3
- import { BaseViewModel, HighlightsMixin, } from '@jbrowse/core/pluggableElementTypes/models';
4
- import { VIEW_HEADER_HEIGHT } from '@jbrowse/core/ui';
3
+ import { BaseViewModel } from '@jbrowse/core/pluggableElementTypes/models';
4
+ import { exportViewSvg } from '@jbrowse/core/svg/exportViewSvg';
5
5
  import { assembleLocString, clamp, getBpDisplayStr, getDialogHost, getSession, getTickDisplayStr, isSessionModelWithWidgets, localStorageGetBoolean, localStorageGetItem, springAnimate, sum, } from '@jbrowse/core/util';
6
6
  import { bpToLinearBp, bpToPx, computeMoveToLayout, createOverviewLayout, getLayoutHighlightCoords, getOverviewRegionPxSpan, moveTo, pxToBp, } from '@jbrowse/core/util/Base1DUtils';
7
+ import { scaleBandHeights } from '@jbrowse/core/util/bandHeight';
7
8
  import { wholeBaseRegions } from '@jbrowse/core/util/blockTypes';
8
9
  import calculateDynamicBlocks from '@jbrowse/core/util/calculateDynamicBlocks';
9
10
  import calculateStaticBlocks from '@jbrowse/core/util/calculateStaticBlocks';
10
11
  import { tickLabelsWorthDrawing } from '@jbrowse/core/util/tickLabels';
11
- import { hideTrackGeneric, showTrackGeneric, toggleTrackGeneric, } from '@jbrowse/core/util/tracks';
12
+ import { hideTrackGeneric, launchToggleTrackGeneric, launchTrackGeneric, showTrackGeneric, toggleTrackGeneric, } from '@jbrowse/core/util/tracks';
12
13
  import { ElementId } from '@jbrowse/core/util/types/mst';
13
- import { computeViewStatus } from '@jbrowse/core/util/viewStatus';
14
+ import { highlightsOnAssemblies } from '@jbrowse/core/util/viewHighlights';
15
+ import { assemblyErrorMessage, computeViewStatus, viewLoading, } from '@jbrowse/core/util/viewStatus';
16
+ import { pendingLaunch, withLaunchInput, } from '@jbrowse/core/util/withLaunchInput';
17
+ import { MIN_DISPLAY_HEIGHT } from '@jbrowse/display-kit/const';
18
+ import { contentRightEdgePx } from '@jbrowse/display-kit/regionHost';
14
19
  import { cast, getParent, hasParent, isAlive, types, } from '@jbrowse/mobx-state-tree';
15
- import { when } from 'mobx';
16
- import { handleSelectedRegion } from '../searchUtils.js';
20
+ import { observable, when } from 'mobx';
21
+ import { handleSelectedRegion, navToOption } from '../searchUtils.js';
17
22
  import { doAfterAttach } from './afterAttach.js';
18
- import { shouldSwapTracks } from './components/util.js';
19
- import { HEADER_BAR_HEIGHT, HEADER_OVERVIEW_HEIGHT, MIN_BP_PER_PX, MINIMIZED_TRACK_HEIGHT, RESIZE_HANDLE_HEIGHT, SCALE_BAR_HEIGHT, SHOW_ALL_REGIONS_FILL, TRACK_OUTLINE_BORDER, TRACK_TOP_GAP, } from './consts.js';
23
+ import { getCytobands, shouldSwapTracks } from './components/util.js';
24
+ import { HEADER_BAR_HEIGHT, HEADER_OVERVIEW_HEIGHT, MIN_BP_PER_PX, MINIMIZED_TRACK_HEIGHT, MINIMUM_BLOCK_WIDTH, RESIZE_ALL_HANDLE_HEIGHT, RESIZE_HANDLE_HEIGHT, SCALE_BAR_HEIGHT, SHOW_ALL_REGIONS_FILL, TRACK_OUTLINE_BORDER, TRACK_TOP_GAP, } from './consts.js';
20
25
  import { planFlight } from './flyTo.js';
21
26
  import { setupKeyboardHandler } from './keyboardHandler.js';
22
- import { buildMenuItems, buildRubberBandMenuItems, buildRubberbandClickMenuItems, } from './menuItems.js';
27
+ import { lgvLaunchKeys } from './launchKeys.js';
28
+ import { buildHighlightsSubMenuItems, buildMenuItems, buildRubberBandMenuItems, buildRubberbandClickMenuItems, } from './menuItems.js';
23
29
  import { sharedScaleContainerOf } from './sharedScaleContainer.js';
24
- import { calculateVisibleLocStrings, cytobandLabelGutterWidth, expandRegion, fitAllRegionsWindow, generateLocations, getScalebarRefNameLabels, regionsOrientation, groupContiguousBlocks, labelFitsInBlock, makeBlockTicks, runRefNameLabelPx, tickLabelWidth, } from './util.js';
30
+ import { calculateVisibleLocStrings, cytobandLabelGutterWidth, expandRegion, fitAllRegionsWindow, generateLocations, getScalebarRefNameLabels, regionsOrientation, groupContiguousBlocks, labelFitsInBlock, makeBlockTicks, runRefNameLabelPx, setDisplayedRegionsKeepingCenter, tickLabelWidth, } from './util.js';
25
31
  // lazies
26
32
  const SearchResultsDialog = lazy(() => import('./components/SearchResultsDialog.js'));
33
+ // The one array `paddingSpans` returns when a view has nothing to mask, so that
34
+ // computed's value repeats by identity. Frozen because every consumer maps over
35
+ // it and a caller that sorted in place would do so for the whole session.
36
+ const NO_PADDING_SPANS = Object.freeze([]);
27
37
  /**
28
38
  * Calculate the offsetPx needed to center content within a viewport.
29
39
  * Returns a negative offset when content is smaller than viewport (padding on left).
@@ -32,16 +42,20 @@ function getCenteredOffsetPx(contentPx, viewportPx) {
32
42
  return Math.round(contentPx / 2 - viewportPx / 2);
33
43
  }
34
44
  /**
35
- * Resolve a region's refName to the assembly's canonical name, falling back to
36
- * the raw refName when the assemblyName is missing or unknown (so highlights
37
- * authored without an assembly still render in single-assembly views).
45
+ * A refName in the named assembly's canonical spelling, or unchanged when that
46
+ * assembly is missing or unknown, so a highlight authored without one still
47
+ * lands in a single-assembly view.
38
48
  */
39
- function resolveCanonicalRefName(self, region) {
49
+ function canonicalRefName(assemblyManager, assemblyName, refName) {
50
+ const asm = assemblyName ? assemblyManager.get(assemblyName) : undefined;
51
+ return asm?.getCanonicalRefName2(refName) ?? refName;
52
+ }
53
+ function canonicalHighlight(self, region) {
40
54
  const { assemblyManager } = getSession(self);
41
- const asm = region.assemblyName
42
- ? assemblyManager.get(region.assemblyName)
43
- : undefined;
44
- return asm?.getCanonicalRefName2(region.refName) ?? region.refName;
55
+ return {
56
+ ...region,
57
+ refName: canonicalRefName(assemblyManager, region.assemblyName, region.refName),
58
+ };
45
59
  }
46
60
  // bpPerPx deltas smaller than this are treated as no zoom change, avoiding
47
61
  // pointless offset re-anchoring on micro-steps
@@ -58,16 +72,6 @@ function sameBlockKeys(a, b) {
58
72
  return (a.blocks.length === b.blocks.length &&
59
73
  a.blocks.every((block, i) => block.key === b.blocks[i].key));
60
74
  }
61
- /**
62
- * Resolve a NavLocation's refName to the assembly's canonical name, falling
63
- * back to the raw refName (and the view's default assembly) when the assembly
64
- * is missing or unknown.
65
- */
66
- function navLocationRefName(assemblyManager, defaultAssemblyName, location) {
67
- return (assemblyManager
68
- .get(location.assemblyName || defaultAssemblyName)
69
- ?.getCanonicalRefName2(location.refName) || location.refName);
70
- }
71
75
  /**
72
76
  * Resolve one end of a navigation range to the displayedRegion index that
73
77
  * contains it plus the bp offset into that region. `side` selects which edge of
@@ -77,7 +81,7 @@ function navLocationRefName(assemblyManager, defaultAssemblyName, location) {
77
81
  * resolved against those raw coords, and only the region it picks bounds `grow`.
78
82
  */
79
83
  function resolveNavEndpoint({ location, assemblyManager, defaultAssemblyName, side, displayedRegions, grow, }) {
80
- const refName = navLocationRefName(assemblyManager, defaultAssemblyName, location);
84
+ const refName = canonicalRefName(assemblyManager, location.assemblyName || defaultAssemblyName, location.refName);
81
85
  const first = displayedRegions.find(r => r.refName === refName);
82
86
  if (!first) {
83
87
  throw new Error(`could not find a region with refName "${refName}"`);
@@ -141,30 +145,27 @@ function isTrackSelectorVisible(self) {
141
145
  * #category view
142
146
  *
143
147
  * #example
144
- * A `LinearGenomeView` is what you hand-author under `defaultSession.views`. The
145
- * `init` shorthand fills in `displayedRegions`/`bpPerPx`/`offsetPx` for you:
148
+ * A `LinearGenomeView` is what you hand-author under `defaultSession.views`, and
149
+ * every setting goes directly on the view object. `assembly`/`loc` fill in
150
+ * `displayedRegions`/`bpPerPx`/`offsetPx` for you:
146
151
  * ```js
147
152
  * defaultSession: {
148
153
  * name: 'My session',
149
154
  * views: [
150
155
  * {
151
156
  * type: 'LinearGenomeView',
152
- * // plain persisted props sit alongside init, not inside it
153
157
  * colorByCDS: true,
154
- * init: {
155
- * assembly: 'hg38',
156
- * loc: 'chr1:1,000,000-1,100,000',
157
- * tracks: ['genes', 'alignments'],
158
- * },
158
+ * assembly: 'hg38',
159
+ * loc: 'chr1:1,000,000-1,100,000',
160
+ * tracks: ['genes', 'alignments'],
159
161
  * },
160
162
  * ],
161
163
  * }
162
164
  * ```
163
- * `init` holds only keys that need on-attach resolution — also `tracklist`,
164
- * `nav`, `highlight` (see the `init` property below). Plain view props like
165
- * `colorByCDS`, `showAminoAcids`, `showCenterLine`, `trackLabels`,
166
- * `showHighlightChips` are set
167
- * directly on the view (MST restores them natively).
165
+ * `assembly`, `loc`, `tracks`, `tracklist`, `nav` and `highlight` need
166
+ * on-attach resolution and are captured into the `launch` property below;
167
+ * `colorByCDS`, `showAminoAcids`, `showCenterLine` and `trackLabels` are plain
168
+ * view props MST restores natively. Both are written the same way.
168
169
  * At runtime the same model is driven imperatively — every property and action
169
170
  * below is reachable on `viewState.session.views[0]`:
170
171
  * ```js
@@ -175,8 +176,8 @@ function isTrackSelectorVisible(self) {
175
176
  * ```
176
177
  */
177
178
  export function stateModelFactory(pluginManager) {
178
- return types
179
- .compose('LinearGenomeView', BaseViewModel, HighlightsMixin(), types.model({
179
+ const model = types
180
+ .compose('LinearGenomeView', BaseViewModel, types.model({
180
181
  /**
181
182
  * #property
182
183
  */
@@ -214,8 +215,8 @@ export function stateModelFactory(pluginManager) {
214
215
  windowWidthBp: types.stripDefault(types.number, 0),
215
216
  /**
216
217
  * #property
217
- * MIGRATION ONLY, and safe to delete once pre-window sessions are no
218
- * longer in circulation.
218
+ * MIGRATION ONLY, and permanent: share links written before the window
219
+ * was stored carry `bpPerPx`/`offsetPx`, and there are many of them.
219
220
  *
220
221
  * A snapshot written before the window was stored carries `offsetPx` and
221
222
  * `bpPerPx` but not the width they were measured at, so the window they
@@ -260,8 +261,8 @@ export function stateModelFactory(pluginManager) {
260
261
  /**
261
262
  * #property
262
263
  * vestigial: the hierarchical selector is the only one that exists, so
263
- * this value is ignored. Retained because saved sessions and configs
264
- * persist it.
264
+ * this value is ignored. Retained because saved sessions and share
265
+ * links persist it.
265
266
  */
266
267
  trackSelectorType: types.stripDefault(types.enumeration(['hierarchical']), 'hierarchical'),
267
268
  /**
@@ -282,7 +283,7 @@ export function stateModelFactory(pluginManager) {
282
283
  * "hidden", or empty string "" (which results in the
283
284
  * LinearGenomeViewPlugin config default being used). the resolved value
284
285
  * is the `effectiveTrackLabels` getter. see LinearGenomeViewPlugin
285
- * https://jbrowse.org/jb2/docs/config/lineargenomeviewplugin/ docs for
286
+ * https://jbrowse.org/jb2/docs/config/lineargenomeviewconfigschema/ docs for
286
287
  * how conf is used
287
288
  */
288
289
  trackLabels: types.optional(types.string, () => localStorageGetItem('lgv-trackLabels') ?? ''),
@@ -291,11 +292,6 @@ export function stateModelFactory(pluginManager) {
291
292
  * show the "gridlines" in the track area
292
293
  */
293
294
  showGridlines: types.stripDefault(types.boolean, true),
294
- /**
295
- * #property
296
- * controls whether highlight/bookmark chip labels are shown inline
297
- */
298
- labelsVisible: types.stripDefault(types.boolean, true),
299
295
  /**
300
296
  * #property
301
297
  * color CDS segments by reading frame
@@ -321,51 +317,52 @@ export function stateModelFactory(pluginManager) {
321
317
  scalebarOnly: types.stripDefault(types.boolean, false),
322
318
  /**
323
319
  * #property
324
- * transient declarative launch spec: assembly + optional location,
325
- * tracks, and highlights to apply once the view attaches. It is applied
326
- * by the afterAttach autorun and then cleared (setInit(undefined)), so a
327
- * saved session never retains it. Shared by all three launch surfaces —
328
- * URL params, createViewState(), and session/config JSON. example:
329
- * ```json
330
- * {
331
- * "assembly": "hg19",
332
- * "loc": "chr1:1,000,000-2,000,000",
333
- * "tracks": ["genes", "variants"]
334
- * }
335
- * ```
336
- */
337
- init: types.frozen(),
320
+ * transient launch state: the settings written on the view object that
321
+ * need resolving before they can be view state — an assembly, a
322
+ * location, track recipes, highlights. `preProcessSnapshot` moves them
323
+ * here off the snapshot, the afterAttach autorun applies them and
324
+ * clears this, so a saved session never retains it. Not written by
325
+ * hand: author every setting directly on the view.
326
+ */
327
+ launch: types.frozen(),
338
328
  }))
339
- .volatile(self => {
329
+ .volatile(() => {
340
330
  // typed locals so `unknown`/`Record` aren't narrowed to `undefined`/`{}`; inline
341
331
  // type assertions here get stripped by no-unnecessary-type-assertion
342
332
  const volatileError = undefined;
343
- const trackRefs = {};
344
333
  return {
345
334
  /**
346
335
  * #volatile
336
+ * Height of each track's in-flow label band, measured by
337
+ * TrackContainer as the rendering container's offset inside its Paper.
338
+ * Absent (0) while the label is hidden, overlapping, or not yet
339
+ * measured. Observable, so the offset and height getters re-derive
340
+ * when a band changes.
347
341
  */
348
- volatileWidth: undefined,
342
+ trackLabelBands: observable.map(),
349
343
  /**
350
344
  * #volatile
345
+ * How much taller the view's box is than the scroll port it sits in,
346
+ * in px, negative when it is shorter. Measured by the view component
347
+ * for a top-level view; undefined until then, and for a nested one.
351
348
  */
352
- minimumBlockWidth: 3,
349
+ scrollPortExcess: undefined,
353
350
  /**
354
351
  * #volatile
355
352
  */
356
- draggingTrackId: undefined,
353
+ volatileWidth: undefined,
357
354
  /**
358
355
  * #volatile
359
356
  */
360
- lastTrackDragY: undefined,
357
+ draggingTrackId: undefined,
361
358
  /**
362
359
  * #volatile
363
360
  */
364
- volatileError,
361
+ lastTrackDragY: undefined,
365
362
  /**
366
363
  * #volatile
367
364
  */
368
- trackRefs,
365
+ volatileError,
369
366
  /**
370
367
  * #volatile
371
368
  */
@@ -377,14 +374,10 @@ export function stateModelFactory(pluginManager) {
377
374
  /**
378
375
  * #volatile
379
376
  */
380
- // What `bpPerPx` used to hold at creation — the snapshot's value, or
381
- // the default of 1 — which cannot be read here now that it is derived
382
- // and declared after volatile. It has to be a real scale rather than
383
- // the not-yet-measured 0: the canvas displays lay out against
384
- // `coarseBpPerPx` rather than the live one, and a zero there is a
385
- // degenerate layout for the frame or two before the debounced autorun
386
- // first writes it.
387
- coarseBpPerPx: self.legacyBpPerPx || 1,
377
+ // A real scale rather than the not-yet-measured 0, for the canvas
378
+ // displays that lay out against it; `setWidth` seeds the true one at
379
+ // the first measure.
380
+ coarseBpPerPx: 1,
388
381
  /**
389
382
  * #volatile
390
383
  */
@@ -397,10 +390,6 @@ export function stateModelFactory(pluginManager) {
397
390
  * #volatile
398
391
  */
399
392
  isScalebarRefNameMenuOpen: false,
400
- /**
401
- * #volatile
402
- */
403
- scalebarRefNameClickPending: false,
404
393
  /**
405
394
  * #volatile
406
395
  * temporary vertical guides that can be set by displays (e.g., LD display hover)
@@ -413,6 +402,14 @@ export function stateModelFactory(pluginManager) {
413
402
  // ahead of every other getter so that the ~300 reads of `self.bpPerPx` /
414
403
  // `self.offsetPx` across the codebase go on resolving by name. Only the
415
404
  // writes had to move, and they are six actions below.
405
+ /**
406
+ * #getter
407
+ * the launch state that still has something to apply — the gate every
408
+ * loading and error path below reads.
409
+ */
410
+ get pendingLaunch() {
411
+ return pendingLaunch(self.launch);
412
+ },
416
413
  /**
417
414
  * #getter
418
415
  * corresponds roughly to the zoom level, base-pairs per pixel.
@@ -437,6 +434,14 @@ export function stateModelFactory(pluginManager) {
437
434
  const { bpPerPx } = this;
438
435
  return bpPerPx <= 0 ? 0 : self.windowStartBp / bpPerPx;
439
436
  },
437
+ /**
438
+ * #getter
439
+ * a displayed region narrower than this many pixels draws as an elided
440
+ * block
441
+ */
442
+ get minimumBlockWidth() {
443
+ return MINIMUM_BLOCK_WIDTH;
444
+ },
440
445
  /**
441
446
  * #getter
442
447
  * scroll-to-zoom is a global, personal preference resolved from the
@@ -498,6 +503,14 @@ export function stateModelFactory(pluginManager) {
498
503
  const { assemblyManager } = getSession(self);
499
504
  return this.assemblyNames.map(a => assemblyManager.getDisplayName(a));
500
505
  },
506
+ /**
507
+ * #getter
508
+ * the session's highlights on this view's assemblies
509
+ */
510
+ get highlights() {
511
+ const { highlights, assemblyManager } = getSession(self);
512
+ return highlightsOnAssemblies(highlights, this.assemblyNames, assemblyManager);
513
+ },
501
514
  /**
502
515
  * #getter
503
516
  * checking if lgv is a 'top-level' view is used for toggling pin track
@@ -524,8 +537,23 @@ export function stateModelFactory(pluginManager) {
524
537
  .views(self => ({
525
538
  /**
526
539
  * #getter
527
- * Assembly-name prefix for the scalebar refName labels, or undefined for
528
- * none. A container view (e.g. LinearSyntenyView) opts its sub-views in by
540
+ * The census entry for this view: the tracks it holds itself. Declared
541
+ * rather than derived — see `BaseViewModel.ownTracks`.
542
+ */
543
+ get ownTracks() {
544
+ return [...self.tracks];
545
+ },
546
+ /**
547
+ * #getter
548
+ * See `BaseViewModel.rendersDisplays`.
549
+ */
550
+ get rendersDisplays() {
551
+ return !self.scalebarOnly;
552
+ },
553
+ /**
554
+ * #getter
555
+ * Assembly name captioning the scalebar ahead of its refName labels, or
556
+ * undefined for none. A container view (e.g. LinearSyntenyView) opts its sub-views in by
529
557
  * exposing showAssemblyNameInSubviewScalebar; duck-typed rather than
530
558
  * matching a concrete view type so no upward plugin dependency is needed
531
559
  * and any container can opt in. A wrong nesting depth simply yields no
@@ -555,10 +583,7 @@ export function stateModelFactory(pluginManager) {
555
583
  */
556
584
  get assemblyErrors() {
557
585
  const { assemblyManager } = getSession(self);
558
- return self.assemblyNames
559
- .map(name => assemblyManager.get(name)?.error)
560
- .filter(e => !!e)
561
- .join(', ');
586
+ return assemblyErrorMessage(assemblyManager, self.assemblyNames);
562
587
  },
563
588
  /**
564
589
  * #getter
@@ -575,10 +600,23 @@ export function stateModelFactory(pluginManager) {
575
600
  * and error checks resolve it directly through here.
576
601
  */
577
602
  get initAssembly() {
578
- return self.init
579
- ? getSession(self).assemblyManager.get(self.init.assembly)
603
+ const name = this.launchAssemblyName;
604
+ return self.pendingLaunch && name !== undefined
605
+ ? getSession(self).assemblyManager.get(name)
580
606
  : undefined;
581
607
  },
608
+ /**
609
+ * #getter
610
+ * The assembly a pending launch is for: the one it names, else the one
611
+ * the view already shows. A re-launch of a materialized view (a
612
+ * `jb.setSession` document writing `loc` beside the built state) need
613
+ * not restate it; without the default the readiness gate waited on
614
+ * `assemblyManager.get(undefined)` for good. Undefined on a fresh view
615
+ * whose blob names none, which `error` reports.
616
+ */
617
+ get launchAssemblyName() {
618
+ return self.pendingLaunch?.assembly ?? self.assemblyNames[0];
619
+ },
582
620
  /**
583
621
  * #getter
584
622
  */
@@ -587,7 +625,7 @@ export function stateModelFactory(pluginManager) {
587
625
  return false;
588
626
  }
589
627
  // if init is set, wait for that assembly to have regions loaded
590
- if (self.init) {
628
+ if (self.pendingLaunch) {
591
629
  const asm = this.initAssembly;
592
630
  return !!(asm?.initialized && asm.regions);
593
631
  }
@@ -606,47 +644,23 @@ export function stateModelFactory(pluginManager) {
606
644
  * displayed assemblies that hasn't finished loading.
607
645
  */
608
646
  get loadingAssembly() {
609
- return self.init
647
+ return self.pendingLaunch
610
648
  ? this.initAssembly
611
649
  : getSession(self).assemblyManager.loadingAssembly(self.assemblyNames);
612
650
  },
613
651
  /**
614
652
  * #getter
615
- * What the spinner says. The assembly reports which of its files it is
616
- * downloading, so this is "Downloading chromosome aliases" rather than a bare
617
- * "Loading" for the part of startup that actually takes time. Falls back
618
- * once the assembly is loaded and the remaining wait is the init autorun's
619
- * own navigation, which is local.
620
- */
621
- get loadingMessage() {
622
- return this.showLoading
623
- ? this.loadingAssembly?.statusMessage || 'Loading'
624
- : undefined;
625
- },
626
- /**
627
- * #getter
628
- * Determinate fraction for the spinner's bar, when the assembly load
629
- * reports one (a whole-file download with a Content-Length)
630
- */
631
- get loadingProgress() {
632
- return this.showLoading
633
- ? this.loadingAssembly?.statusProgress
634
- : undefined;
635
- },
636
- /**
637
- * #getter
638
- * The URL the assembly load is currently fetching, when the phase named
639
- * one. Only the stalled-load notice reads it — see
640
- * {@link ViewLoadingScreen}.
653
+ * What the loading screen says while `showLoading`, read off the
654
+ * assembly whose load is the wait; undefined otherwise.
641
655
  */
642
- get loadingSource() {
643
- return this.showLoading ? this.loadingAssembly?.statusSource : undefined;
656
+ get loading() {
657
+ return viewLoading(this.showLoading, () => this.loadingAssembly);
644
658
  },
645
659
  /**
646
660
  * #getter
647
661
  */
648
662
  get hasSomethingToShow() {
649
- return this.hasDisplayedRegions || !!self.init;
663
+ return this.hasDisplayedRegions || !!self.pendingLaunch;
650
664
  },
651
665
  /**
652
666
  * #getter
@@ -656,7 +670,7 @@ export function stateModelFactory(pluginManager) {
656
670
  * would mount over empty regions and pxToBp/hover would throw.
657
671
  *
658
672
  * **Not dotplot's or synteny's `initPending`**, which is the bare
659
- * `!!self.init` — "an init blob has not been applied" — and which those
673
+ * `!!self.pendingLaunch` — "an init blob has not been applied" — and which those
660
674
  * views read from their `settled` gate rather than from `showLoading`.
661
675
  * This one is narrower on purpose: it closes only the window where there
662
676
  * is nothing on screen. Once navigation has produced regions the view is
@@ -666,7 +680,7 @@ export function stateModelFactory(pluginManager) {
666
680
  * theirs.
667
681
  */
668
682
  get awaitingInitNavigation() {
669
- return !!self.init && !this.hasDisplayedRegions;
683
+ return !!self.pendingLaunch && !this.hasDisplayedRegions;
670
684
  },
671
685
  /**
672
686
  * #getter
@@ -719,12 +733,7 @@ export function stateModelFactory(pluginManager) {
719
733
  return computeViewStatus({
720
734
  error: this.error,
721
735
  hasSomethingToShow: this.hasSomethingToShow,
722
- loading: () => this.showLoading
723
- ? {
724
- message: this.loadingMessage ?? 'Loading',
725
- progress: this.loadingProgress,
726
- }
727
- : undefined,
736
+ loading: () => this.loading,
728
737
  });
729
738
  },
730
739
  /**
@@ -754,14 +763,10 @@ export function stateModelFactory(pluginManager) {
754
763
  * #getter
755
764
  * A track's full cost beyond its display height.
756
765
  *
757
- * The track *label* is not counted, and cannot be: an offset label is an
758
- * in-flow box whose height is whatever the theme renders a Paper of icon
759
- * buttons at — 31.140625px on the stock theme, and not a number this file
760
- * can derive. So these getters are exact while labels are hidden or
761
- * overlapping, and short by one label box per labelled track otherwise.
762
- * Anything needing the offset to the pixel with labels showing measures
763
- * the DOM; `BreakpointSplitViewOverlay` does, and falls back to this
764
- * arithmetic only for a track with no mounted div.
766
+ * The track *label* is not counted here: an offset label is an in-flow
767
+ * box whose height is whatever the theme renders a Paper of icon buttons
768
+ * at, so it is measured into `trackLabelBands` per track and added by
769
+ * `trackLabelBand` rather than derived.
765
770
  */
766
771
  get trackChromeHeight() {
767
772
  return this.trackLeadingChrome + this.trackTrailingChrome;
@@ -780,25 +785,6 @@ export function stateModelFactory(pluginManager) {
780
785
  return HEADER_BAR_HEIGHT + HEADER_OVERVIEW_HEIGHT;
781
786
  }
782
787
  },
783
- /**
784
- * #getter
785
- * Where the scalebar pins when the view's chrome is sticky: everything
786
- * stacked above it, which is the view's own title bar plus this view's
787
- * header. Expressed from `headerHeight` rather than re-summing its
788
- * constants — the two are the same box, and a second spelling of the sum
789
- * is free to drift from the first while both typecheck.
790
- */
791
- get rubberbandTop() {
792
- return self.stickyViewHeaders
793
- ? VIEW_HEADER_HEIGHT + this.headerHeight
794
- : 0;
795
- },
796
- /**
797
- * #getter
798
- */
799
- get pinnedTracksTop() {
800
- return this.rubberbandTop + this.scalebarHeight;
801
- },
802
788
  /**
803
789
  * #method
804
790
  * rendered height of a single track, collapsing to a fixed height when
@@ -812,6 +798,14 @@ export function stateModelFactory(pluginManager) {
812
798
  ? MINIMIZED_TRACK_HEIGHT
813
799
  : track.activeDisplay.height;
814
800
  },
801
+ /**
802
+ * #method
803
+ * the measured in-flow label band above a track's rendering container,
804
+ * 0 until TrackContainer has measured one or when no label is in flow
805
+ */
806
+ trackLabelBand(track) {
807
+ return self.trackLabelBands.get(track.configuration.trackId) ?? 0;
808
+ },
815
809
  /**
816
810
  * #getter
817
811
  */
@@ -822,7 +816,9 @@ export function stateModelFactory(pluginManager) {
822
816
  * #getter
823
817
  */
824
818
  get trackHeightsWithChrome() {
825
- return this.trackHeights + self.tracks.length * this.trackChromeHeight;
819
+ return (this.trackHeights +
820
+ self.tracks.length * this.trackChromeHeight +
821
+ sum(self.tracks.map(t => this.trackLabelBand(t))));
826
822
  },
827
823
  /**
828
824
  * #getter
@@ -831,7 +827,22 @@ export function stateModelFactory(pluginManager) {
831
827
  if (self.scalebarOnly) {
832
828
  return this.headerHeight + this.scalebarHeight;
833
829
  }
834
- return (this.trackHeightsWithChrome + this.headerHeight + this.scalebarHeight);
830
+ return (this.trackHeightsWithChrome +
831
+ this.headerHeight +
832
+ this.scalebarHeight +
833
+ (self.tracks.length && self.isTopLevelView
834
+ ? RESIZE_ALL_HANDLE_HEIGHT
835
+ : 0));
836
+ },
837
+ /**
838
+ * #getter
839
+ * The display height of each track a whole-stack resize moves, by track
840
+ * instance id: every shown track that is not minimized.
841
+ */
842
+ get resizableTrackHeights() {
843
+ return new Map(self.tracks
844
+ .filter(t => !t.minimized)
845
+ .map(t => [t.id, t.activeDisplay.height]));
835
846
  },
836
847
  /**
837
848
  * #method
@@ -840,14 +851,15 @@ export function stateModelFactory(pluginManager) {
840
851
  * first, then unpinned), from the same constants TrackContainer lays its
841
852
  * Paper out with. Returns `undefined` if the track is not present.
842
853
  *
843
- * Exact while track labels are hidden or overlapping. With an offset
844
- * label the answer is short by one label box per labelled track above
845
- * this one — see `trackChromeHeight` for why that box is not derivable
846
- * here.
854
+ * Includes each track's measured label band, this track's own included,
855
+ * since an in-flow label sits above the rendering container inside the
856
+ * same Paper. Exact once TrackContainer has measured; before that it is
857
+ * short by the unmeasured bands.
847
858
  */
848
859
  getTrackYOffset(trackId) {
849
860
  let y = this.headerHeight + this.scalebarHeight + this.trackLeadingChrome;
850
861
  for (const t of [...self.pinnedTracks, ...self.unpinnedTracks]) {
862
+ y += this.trackLabelBand(t);
851
863
  if (t.configuration.trackId === trackId) {
852
864
  return y;
853
865
  }
@@ -879,24 +891,22 @@ export function stateModelFactory(pluginManager) {
879
891
  if (this.totalBp === 0 || self.width === 0) {
880
892
  return 1;
881
893
  }
882
- // Floor at minBpPerPx so tiny displayed regions (totalBp small enough
883
- // that the fill-scaled ratio drops below MIN_BP_PER_PX) can't produce
884
- // maxBpPerPx < minBpPerPx, which would invert the zoom-slider bounds
885
- // and the clamp() range in zoomTo.
886
- return Math.max(MIN_BP_PER_PX, this.totalBp / (self.width * SHOW_ALL_REGIONS_FILL));
894
+ // floored so a tiny region set cannot put maxBpPerPx under
895
+ // minBpPerPx, which would invert the zoom slider and zoomTo's clamp
896
+ return Math.max(this.minBpPerPx, this.totalBp / (self.width * SHOW_ALL_REGIONS_FILL));
887
897
  },
888
898
  /**
889
899
  * #getter
890
- * The zoom-out limit. This view's own fit, except while a container holds
891
- * it on a shared scale coarser than that — a small genome next to a large
892
- * one, drawn short so the two compare by length.
900
+ * The zoom-out limit. Equal to the fit for this view's regions, except
901
+ * while a container holds it on a coarser shared scale, such as a small
902
+ * genome next to a large one, drawn short so the two compare by length.
893
903
  *
894
- * Raising the LIMIT is what makes such a scale survive: written past the
895
- * limit instead, it is undone by the first thing that clamps — a wheel
896
- * tick, a rubberband, a `setDisplayedRegions` — which is how the dotplot's
897
- * locked aspect ratio once turned "zoom out" into a zoom in
898
- * (`axisMaxBpPerPx`). Here every route to a zoom clamps against the same
899
- * ceiling, so full zoom-out LANDS on the shared scale.
904
+ * The shared scale raises the limit. A bpPerPx written past the limit
905
+ * would be undone by the first operation that clamps (a wheel tick, a
906
+ * rubberband, a `setDisplayedRegions`), as with the dotplot's locked
907
+ * aspect ratio turning "zoom out" into a zoom in (`axisMaxBpPerPx`).
908
+ * Every zoom clamps against the same ceiling, so full zoom-out stops at
909
+ * the shared scale.
900
910
  */
901
911
  get maxBpPerPx() {
902
912
  return Math.max(this.fitBpPerPx, this.sharedFitBpPerPx);
@@ -935,17 +945,17 @@ export function stateModelFactory(pluginManager) {
935
945
  return this.assembliesNotFound;
936
946
  }
937
947
  // Check init assembly for errors (displayedRegions may be empty during init)
938
- if (self.init) {
948
+ if (self.pendingLaunch) {
939
949
  // `assembly` is required by InitState, but init is a frozen blob and
940
950
  // hand-authored JSON is what fills it, so the type is not a guarantee.
941
951
  // Naming the authoring mistake beats the downstream symptom, which is
942
952
  // the literal string "Assembly undefined not found".
943
- if (!self.init.assembly) {
953
+ if (!this.launchAssemblyName) {
944
954
  return 'LinearGenomeView init needs an "assembly"';
945
955
  }
946
956
  const asm = this.initAssembly;
947
957
  if (!asm) {
948
- return `Assembly ${self.init.assembly} not found`;
958
+ return `Assembly ${this.launchAssemblyName} not found`;
949
959
  }
950
960
  if (asm.error) {
951
961
  return asm.error;
@@ -1045,20 +1055,48 @@ export function stateModelFactory(pluginManager) {
1045
1055
  /**
1046
1056
  * #action
1047
1057
  */
1048
- // A resize needs no arithmetic: the window is what is stored, so a new
1049
- // width simply divides into it and `bpPerPx` follows. The view keeps the
1050
- // sequence it was framing rather than letting the right edge eat into it.
1051
- //
1052
- // Keeping the scale instead — which is what storing pixels amounted to —
1053
- // was a block-cache optimization: block boundaries are
1054
- // `blockNum * ceil(800 * bpPerPx)` and block keys embed start/end, so
1055
- // holding bpPerPx across a resize kept every block key, and so every
1056
- // fetched region, identical. Rescaling makes a resize a zoom, and a zoom
1057
- // is already a solved case: FetchVisibleRegions' 300ms debounce coalesces
1058
- // the gesture into one refetch, its in-flight guard caps concurrent
1059
- // batches at one, and rpcDataMap is overwritten in place rather than
1060
- // cleared, so nothing blanks (ADR-008, ADR-006).
1058
+ setTrackLabelBand(trackId, band) {
1059
+ if (band === 0) {
1060
+ self.trackLabelBands.delete(trackId);
1061
+ }
1062
+ else {
1063
+ self.trackLabelBands.set(trackId, band);
1064
+ }
1065
+ },
1066
+ /**
1067
+ * #action
1068
+ */
1069
+ setScrollPortExcess(px) {
1070
+ self.scrollPortExcess = px;
1071
+ },
1072
+ /**
1073
+ * #action
1074
+ * Grow or shrink every unminimized track by `distance` px in all, each
1075
+ * by its share of the height it has above the display minimum. `from` is
1076
+ * the heights to scale, by track instance id; a drag passes the heights
1077
+ * it started with, so whole-pixel rounding does not compound frame by
1078
+ * frame. Returns how far the stack moved.
1079
+ */
1080
+ resizeTracks(distance, from = self.resizableTrackHeights) {
1081
+ const tracks = self.tracks.filter(t => !t.minimized && from.has(t.id));
1082
+ const start = tracks.map(t => from.get(t.id));
1083
+ const next = scaleBandHeights(start, distance, MIN_DISPLAY_HEIGHT);
1084
+ for (const [i, track] of tracks.entries()) {
1085
+ const display = track.activeDisplay;
1086
+ const delta = next[i] - display.height;
1087
+ if (delta !== 0) {
1088
+ display.resizeHeight(delta);
1089
+ }
1090
+ }
1091
+ return sum(next) - sum(start);
1092
+ },
1093
+ /**
1094
+ * #action
1095
+ * A resize keeps the window in bp, so `bpPerPx` follows the new width
1096
+ * and the view keeps framing the same sequence (ADR-070).
1097
+ */
1061
1098
  setWidth(newWidth) {
1099
+ const unmeasured = self.bpPerPx <= 0;
1062
1100
  self.volatileWidth = newWidth;
1063
1101
  if (newWidth > 0 && self.windowWidthBp <= 0) {
1064
1102
  // First measure, and nothing has established a window yet: a migrated
@@ -1069,6 +1107,11 @@ export function stateModelFactory(pluginManager) {
1069
1107
  self.windowWidthBp = (self.legacyBpPerPx || 1) * newWidth;
1070
1108
  self.legacyBpPerPx = 0;
1071
1109
  }
1110
+ if (unmeasured && self.bpPerPx > 0) {
1111
+ // The debounced coarse autorun is 500ms out; a restored snapshot's
1112
+ // consumers would lay out and fetch at 1 bp/px until then.
1113
+ self.coarseBpPerPx = self.bpPerPx;
1114
+ }
1072
1115
  },
1073
1116
  /**
1074
1117
  * #action
@@ -1082,12 +1125,6 @@ export function stateModelFactory(pluginManager) {
1082
1125
  setIsScalebarRefNameMenuOpen(isOpen) {
1083
1126
  self.isScalebarRefNameMenuOpen = isOpen;
1084
1127
  },
1085
- /**
1086
- * #action
1087
- */
1088
- setScalebarRefNameClickPending(pending) {
1089
- self.scalebarRefNameClickPending = pending;
1090
- },
1091
1128
  /**
1092
1129
  * #action
1093
1130
  */
@@ -1118,12 +1155,6 @@ export function stateModelFactory(pluginManager) {
1118
1155
  setShowGridlines(b) {
1119
1156
  self.showGridlines = b;
1120
1157
  },
1121
- /**
1122
- * #action
1123
- */
1124
- setLabelsVisible(arg) {
1125
- self.labelsVisible = arg;
1126
- },
1127
1158
  /**
1128
1159
  * #action
1129
1160
  * set temporary vertical guides (e.g., for LD display hover)
@@ -1196,8 +1227,15 @@ export function stateModelFactory(pluginManager) {
1196
1227
  },
1197
1228
  /**
1198
1229
  * #action
1199
- */
1200
- setSearchResults(searchResults, searchQuery, assemblyName) {
1230
+ * Raise the picker over search results that name more than one place.
1231
+ * @param onPick - where a picked result lands; by default it navigates
1232
+ * there and shows the track its index came from
1233
+ */
1234
+ setSearchResults(searchResults, searchQuery, assemblyName, onPick = result => navToOption({
1235
+ option: result,
1236
+ model: self,
1237
+ assemblyName,
1238
+ })) {
1201
1239
  getDialogHost(self).queueDialog(handleClose => [
1202
1240
  SearchResultsDialog,
1203
1241
  {
@@ -1206,6 +1244,7 @@ export function stateModelFactory(pluginManager) {
1206
1244
  searchQuery,
1207
1245
  handleClose,
1208
1246
  assemblyName,
1247
+ onPick,
1209
1248
  },
1210
1249
  ]);
1211
1250
  },
@@ -1225,70 +1264,109 @@ export function stateModelFactory(pluginManager) {
1225
1264
  .actions(self => ({
1226
1265
  /**
1227
1266
  * #action
1267
+ * Resize every unminimized track so the view is exactly as tall as the
1268
+ * scroll port it sits in. Does nothing until the view has measured one.
1228
1269
  */
1229
- moveTrackDown(id) {
1230
- const section = self.trackSection(id);
1231
- const idx = section.findIndex(t => t.id === id);
1232
- if (idx !== -1 && idx < section.length - 1) {
1233
- this.moveTrack(id, section[idx + 1].id);
1270
+ fitTracksToWindow() {
1271
+ if (self.scrollPortExcess) {
1272
+ self.resizeTracks(-self.scrollPortExcess);
1234
1273
  }
1235
1274
  },
1236
1275
  /**
1237
1276
  * #action
1277
+ * showTrack for a track whose display state model may be lazily
1278
+ * loaded: loads it, then shows
1238
1279
  */
1239
- moveTrackUp(id) {
1240
- const section = self.trackSection(id);
1241
- const idx = section.findIndex(t => t.id === id);
1242
- if (idx > 0) {
1243
- this.moveTrack(id, section[idx - 1].id);
1244
- }
1280
+ async launchTrack(trackId, initialSnapshot = {}, displayInitialSnapshot = {}, inlineConf) {
1281
+ return launchTrackGeneric(self, trackId, initialSnapshot, displayInitialSnapshot, inlineConf);
1245
1282
  },
1283
+ }))
1284
+ .views(self => ({
1246
1285
  /**
1247
- * #action
1286
+ * #method
1287
+ * The shown track a move action means: the menu passes a track model's
1288
+ * own `id`, an agent passes the `trackId` it knows the track by, and
1289
+ * both name one shown track. Answers the model id, or the argument
1290
+ * unchanged when nothing shown matches, so the move reports it.
1248
1291
  */
1249
- moveTrackToTop(id) {
1250
- const section = self.trackSection(id);
1251
- if (section.length && section[0].id !== id) {
1252
- this.moveTrack(id, section[0].id);
1253
- }
1292
+ trackModelId(idOrTrackId) {
1293
+ return ((self.tracks.find(t => t.id === idOrTrackId) ??
1294
+ self.tracks.find(t => t.configuration.trackId === idOrTrackId))?.id ?? idOrTrackId);
1254
1295
  },
1255
- /**
1256
- * #action
1257
- */
1258
- moveTrackToBottom(id) {
1259
- const section = self.trackSection(id);
1260
- const last = section[section.length - 1];
1261
- if (last && last.id !== id) {
1262
- this.moveTrack(id, last.id);
1296
+ }))
1297
+ .actions(self => {
1298
+ function shownIndex(idOrTrackId) {
1299
+ const id = self.trackModelId(idOrTrackId);
1300
+ const index = self.tracks.findIndex(track => track.id === id);
1301
+ if (index === -1) {
1302
+ throw new Error(`No shown track has id or trackId "${idOrTrackId}" — the view's tracks are ${self.tracks.map(t => t.configuration.trackId).join(', ') || 'none'}`);
1263
1303
  }
1264
- },
1304
+ return index;
1305
+ }
1265
1306
  /**
1266
1307
  * #action
1267
- */
1268
- moveTrack(movingId, targetId) {
1269
- const oldIndex = self.tracks.findIndex(track => track.id === movingId);
1270
- if (oldIndex === -1) {
1271
- throw new Error(`Track ID ${movingId} not found`);
1272
- }
1273
- const newIndex = self.tracks.findIndex(track => track.id === targetId);
1274
- if (newIndex === -1) {
1275
- throw new Error(`Track ID ${targetId} not found`);
1276
- }
1277
- // direction-aware placement: filtering out oldIndex shifts the target
1278
- // left by one when dragging down (oldIndex < newIndex), so splicing at
1279
- // newIndex lands the track *after* the target; dragging up leaves the
1280
- // target's index intact, landing *before* it. This matches the side
1281
- // the dragged track approaches from.
1308
+ * Move one shown track to another's position; each is a track model id
1309
+ * or a trackId.
1310
+ */
1311
+ function moveTrack(moving, target) {
1312
+ const oldIndex = shownIndex(moving);
1313
+ const newIndex = shownIndex(target);
1314
+ // splicing at newIndex after removing oldIndex lands a track dragged
1315
+ // down after its target and one dragged up before it
1282
1316
  const tracks = self.tracks.filter((_, idx) => idx !== oldIndex);
1283
1317
  tracks.splice(newIndex, 0, self.tracks[oldIndex]);
1284
1318
  self.tracks = cast(tracks);
1285
- },
1319
+ }
1320
+ function moveWithinSection(idOrTrackId, pick) {
1321
+ const moving = self.tracks[shownIndex(idOrTrackId)];
1322
+ const section = self.trackSection(moving.id);
1323
+ const target = pick(section, section.indexOf(moving));
1324
+ if (target && target !== moving) {
1325
+ moveTrack(moving.id, target.id);
1326
+ }
1327
+ }
1328
+ return {
1329
+ moveTrack,
1330
+ /**
1331
+ * #action
1332
+ */
1333
+ moveTrackDown(idOrTrackId) {
1334
+ moveWithinSection(idOrTrackId, (section, i) => section[i + 1]);
1335
+ },
1336
+ /**
1337
+ * #action
1338
+ */
1339
+ moveTrackUp(idOrTrackId) {
1340
+ moveWithinSection(idOrTrackId, (section, i) => section[i - 1]);
1341
+ },
1342
+ /**
1343
+ * #action
1344
+ */
1345
+ moveTrackToTop(idOrTrackId) {
1346
+ moveWithinSection(idOrTrackId, section => section[0]);
1347
+ },
1348
+ /**
1349
+ * #action
1350
+ */
1351
+ moveTrackToBottom(idOrTrackId) {
1352
+ moveWithinSection(idOrTrackId, section => section.at(-1));
1353
+ },
1354
+ };
1355
+ })
1356
+ .actions(self => ({
1286
1357
  /**
1287
1358
  * #action
1288
1359
  */
1289
1360
  toggleTrack(trackId) {
1290
1361
  return toggleTrackGeneric(self, trackId);
1291
1362
  },
1363
+ /**
1364
+ * #action
1365
+ * toggleTrack with launchTrack's loading behavior
1366
+ */
1367
+ async launchToggleTrack(trackId) {
1368
+ return launchToggleTrackGeneric(self, trackId);
1369
+ },
1292
1370
  /**
1293
1371
  * #action
1294
1372
  */
@@ -1373,12 +1451,6 @@ export function stateModelFactory(pluginManager) {
1373
1451
  self.lastTrackDragY = undefined;
1374
1452
  }
1375
1453
  },
1376
- /**
1377
- * #action
1378
- */
1379
- setLastTrackDragY(y) {
1380
- self.lastTrackDragY = y;
1381
- },
1382
1454
  /**
1383
1455
  * #action
1384
1456
  * called while dragging a track over the track at `targetId`; reorders
@@ -1394,7 +1466,7 @@ export function stateModelFactory(pluginManager) {
1394
1466
  const movingDown = targetIdx > draggingIdx;
1395
1467
  if (shouldSwapTracks(self.lastTrackDragY, currentY, movingDown)) {
1396
1468
  self.lastTrackDragY = currentY;
1397
- this.moveTrack(draggingTrackId, targetId);
1469
+ self.moveTrack(draggingTrackId, targetId);
1398
1470
  }
1399
1471
  }
1400
1472
  }
@@ -1402,18 +1474,18 @@ export function stateModelFactory(pluginManager) {
1402
1474
  /**
1403
1475
  * #action
1404
1476
  */
1405
- setInit(arg) {
1406
- self.init = arg;
1477
+ setLaunch(launch) {
1478
+ self.launch = launch;
1407
1479
  },
1408
1480
  /**
1409
1481
  * #method
1410
- * creates an svg export and save using FileSaver
1482
+ * renders the view to SVG markup, which it returns; saves it through
1483
+ * FileSaver unless `save: false`
1411
1484
  */
1485
+ // Promise<string> is stated: renderToSvg is typed against this very
1486
+ // model, so an inferred return makes the type reference itself (TS2456)
1412
1487
  async exportSvg(opts = {}) {
1413
- const { renderToSvg } = await import('./svgcomponents/SVGLinearGenomeView.js');
1414
- const html = await renderToSvg(self, opts);
1415
- const { saveSvgAsImage } = await import('@jbrowse/core/svg/saveSvgAsImage');
1416
- await saveSvgAsImage(html, opts);
1488
+ return exportViewSvg(self, opts, () => import('./svgcomponents/SVGLinearGenomeView.js'));
1417
1489
  },
1418
1490
  }))
1419
1491
  .actions(self => {
@@ -1499,25 +1571,26 @@ export function stateModelFactory(pluginManager) {
1499
1571
  * #getter
1500
1572
  * an ideogram only reads correctly against an entire chromosome: on a
1501
1573
  * sub-region it is a meaningless slice of bands, and the centromere shows
1502
- * up as a lone half-triangle
1574
+ * up as a lone half-triangle. The chromosome needs bands of its own too:
1575
+ * a cytoband file often lists only the main chromosomes, not chrM or the
1576
+ * unplaced scaffolds
1503
1577
  */
1504
1578
  get canShowCytobands() {
1505
- return this.showsWholeChromosome && this.anyCytobandsExist;
1579
+ const region = self.displayedRegions[0];
1580
+ return (this.showsWholeChromosome &&
1581
+ !!region &&
1582
+ getCytobands(getSession(self).assemblyManager.get(region.assemblyName), region.refName).length > 0);
1506
1583
  },
1507
1584
  /**
1508
1585
  * #getter
1509
1586
  * the `showCytobands` setting gated by whether cytobands can be shown at
1510
- * all (whole chromosome + data present) — i.e. actually on screen
1587
+ * all (whole chromosome with bands of its own) and by the header
1588
+ * overview they draw in being on, which the SVG export follows too
1511
1589
  */
1512
1590
  get effectiveShowCytobands() {
1513
- return this.canShowCytobands && self.showCytobands;
1514
- },
1515
- /**
1516
- * #getter
1517
- */
1518
- get anyCytobandsExist() {
1519
- const { assemblyManager } = getSession(self);
1520
- return self.assemblyNames.some(a => assemblyManager.get(a)?.cytobands?.length);
1591
+ return (this.canShowCytobands &&
1592
+ self.showCytobands &&
1593
+ !self.hideHeaderOverview);
1521
1594
  },
1522
1595
  /**
1523
1596
  * #getter
@@ -1544,6 +1617,13 @@ export function stateModelFactory(pluginManager) {
1544
1617
  menuItems() {
1545
1618
  return buildMenuItems(self);
1546
1619
  },
1620
+ /**
1621
+ * #method
1622
+ * the view menu's Highlights submenu; extend this to add a row there
1623
+ */
1624
+ highlightsSubMenuItems() {
1625
+ return buildHighlightsSubMenuItems(self);
1626
+ },
1547
1627
  /**
1548
1628
  * #method
1549
1629
  * what a plugin can start from the selected region — a synteny view, a
@@ -1574,7 +1654,6 @@ export function stateModelFactory(pluginManager) {
1574
1654
  let coverageRightPx = 0;
1575
1655
  let prevBpPerPx;
1576
1656
  let prevWidth;
1577
- let prevMinimumBlockWidth;
1578
1657
  let prevDisplayedRegions;
1579
1658
  return {
1580
1659
  /**
@@ -1586,16 +1665,13 @@ export function stateModelFactory(pluginManager) {
1586
1665
  * blocks to render their data for the region represented by the block
1587
1666
  */
1588
1667
  get staticBlocks() {
1589
- const { offsetPx, bpPerPx, width, minimumBlockWidth, displayedRegions, } = self;
1668
+ const { offsetPx, bpPerPx, width, displayedRegions } = self;
1590
1669
  // Fast path: if only offsetPx changed and viewport is still within
1591
1670
  // the coverage range of existing blocks, skip the expensive
1592
- // calculateStaticBlocks call entirely. minimumBlockWidth is read here
1593
- // (not just in calculateStaticBlocks) so MobX still invalidates this
1594
- // computed if it changes while the viewport stays within coverage.
1671
+ // calculateStaticBlocks call entirely.
1595
1672
  if (currentlyCalculatedStaticBlocks !== undefined &&
1596
1673
  bpPerPx === prevBpPerPx &&
1597
1674
  width === prevWidth &&
1598
- minimumBlockWidth === prevMinimumBlockWidth &&
1599
1675
  displayedRegions === prevDisplayedRegions &&
1600
1676
  offsetPx >= coverageLeftPx &&
1601
1677
  offsetPx + width <= coverageRightPx) {
@@ -1604,16 +1680,13 @@ export function stateModelFactory(pluginManager) {
1604
1680
  const newBlocks = calculateStaticBlocks(self);
1605
1681
  // Hand back the previous BlockSet when the recompute produced the same
1606
1682
  // blocks, so a sideways scroll past the coverage edge doesn't re-render
1607
- // every track. minimumBlockWidth is part of the guard because block
1608
- // keys don't encode ContentBlock-vs-ElidedBlock, so a region can flip
1609
- // type with an unchanged key. Geometry is compared first: on a zoom it
1610
- // always differs, which skips the key walk entirely — the whole-genome
1611
- // view has one block per refName, so joining those keys into a string
1612
- // (as this used to) allocated an array and a string per zoom frame.
1683
+ // every track. Geometry is compared first: on a zoom it always
1684
+ // differs, which skips the key walk entirely — the whole-genome view
1685
+ // has one block per refName, so joining those keys into a string (as
1686
+ // this used to) allocated an array and a string per zoom frame.
1613
1687
  if (currentlyCalculatedStaticBlocks === undefined ||
1614
1688
  bpPerPx !== prevBpPerPx ||
1615
1689
  width !== prevWidth ||
1616
- minimumBlockWidth !== prevMinimumBlockWidth ||
1617
1690
  !sameBlockKeys(currentlyCalculatedStaticBlocks, newBlocks)) {
1618
1691
  currentlyCalculatedStaticBlocks = newBlocks;
1619
1692
  }
@@ -1629,7 +1702,6 @@ export function stateModelFactory(pluginManager) {
1629
1702
  }
1630
1703
  prevBpPerPx = bpPerPx;
1631
1704
  prevWidth = width;
1632
- prevMinimumBlockWidth = minimumBlockWidth;
1633
1705
  prevDisplayedRegions = displayedRegions;
1634
1706
  return currentlyCalculatedStaticBlocks;
1635
1707
  },
@@ -1758,7 +1830,7 @@ export function stateModelFactory(pluginManager) {
1758
1830
  for (const [i, run] of runs.entries()) {
1759
1831
  const runLeft = run.offsetPx - firstBlockOffset;
1760
1832
  const runLabels = [];
1761
- for (const { base, x } of makeBlockTicks(run, bpPerPx, true, false)) {
1833
+ for (const { base, x } of makeBlockTicks(run, bpPerPx, false)) {
1762
1834
  const label = getTickDisplayStr(base + 1, bpPerPx);
1763
1835
  const w = tickLabelWidth(label);
1764
1836
  // the bold refName label pinned at the run's left edge takes
@@ -1817,8 +1889,9 @@ export function stateModelFactory(pluginManager) {
1817
1889
  * into that through `scalebarDisplayPrefix`) and whether it is flipped.
1818
1890
  * A ` [rev]` there is a fact about the ROW; the same marker on a
1819
1891
  * chromosome name means that one region, which only happens under mixed
1820
- * orientation. `captionSpanPx` is the width it takes, which the
1821
- * coordinate numbers stay out from under. The SVG export deliberately
1892
+ * orientation. `captionSpanPx` is the width it takes: the sticky label
1893
+ * pins to its right edge rather than the viewport's, and the coordinate
1894
+ * numbers stay out from under it. The SVG export deliberately
1822
1895
  * calls `getScalebarRefNameLabels` itself with no prefix rather than
1823
1896
  * reading this, since it draws its own assembly name above the ruler.
1824
1897
  */
@@ -1835,37 +1908,42 @@ export function stateModelFactory(pluginManager) {
1835
1908
  * Every span along the row that is not track data, as plain geometry in
1836
1909
  * the staticBlocks frame — the same frame as gridlineTicks and
1837
1910
  * scalebarLabels, so one `translateX(staticBlocksTranslateX)` places all
1838
- * three. Three kinds, and a host drawing its own chrome needs all of
1839
- * them:
1911
+ * three. A host drawing its own chrome needs all three kinds:
1840
1912
  *
1841
1913
  * - `seam`: the 3px bar at a region's right edge. Displayed regions are
1842
- * laid out **contiguously** — calculateStaticBlocks emits boundary
1843
- * padding only before the first region and after the last — so this
1914
+ * laid out **contiguously** (calculateStaticBlocks emits boundary
1915
+ * padding only before the first region and after the last), so this
1844
1916
  * bar is the only thing separating two of them. Without it a
1845
- * two-region view reads as a one-region view scrolled somewhere
1846
- * strange. `isRightEndOfDisplayedRegion` is what marks it, and the
1847
- * right edge of the blocks currently *loaded* — which is what block
1848
- * geometry hands you — is not the same filter.
1917
+ * two-region view looks like a single region.
1918
+ * `isRightEndOfDisplayedRegion` marks it; the right edge of the
1919
+ * currently *loaded* blocks, which block geometry gives you, is a
1920
+ * different set.
1849
1921
  * - `elided`: a region too narrow to draw at this zoom. Whole-genome on
1850
- * a real assembly is mostly these — hg38 has 455 sequences and all
1851
- * but the 24 chromosomes land sub-pixel — so a host that skips them
1852
- * renders that tail as nothing at all.
1922
+ * a real assembly is mostly these (hg38 has 455 sequences, and all
1923
+ * but the 24 chromosomes are under a pixel wide), so a host that
1924
+ * skips them leaves that part of the row empty.
1853
1925
  * - `boundary`: past the start of the first region or the end of the
1854
- * last. Greying it is what makes the last region's seam read as the
1855
- * edge of a filled area rather than a rule floating in the track.
1926
+ * last. Greying it shows the last region's seam as the edge of a
1927
+ * filled area, where it would otherwise look like a line in the
1928
+ * middle of the track.
1856
1929
  *
1857
1930
  * Elided blocks get no seam even though they carry the flag: at the
1858
- * zoom where regions elide, one bar per region is a solid grey wall.
1931
+ * zoom where regions elide, one bar per region would fill the row solid
1932
+ * grey.
1859
1933
  *
1860
- * PaddingBlocks is the in-tree consumer. The SVG export is deliberately
1861
- * NOT one: `SVGRegionSeparators` walks dynamicBlocks itself and draws
1862
- * only the seam, because `elided` and `boundary` are chrome for an
1863
- * interactive row rather than information a figure carries — striped
1864
- * grey saying "regions here are too narrow to draw" is noise in a
1865
- * static image, and at whole-genome zoom it would be most of the row.
1866
- * The seam is the one that must survive: regions lay out contiguously,
1867
- * so it is all that separates two of them.
1934
+ * PaddingBlocks is the in-tree consumer. The SVG export does not use
1935
+ * it: `SVGRegionSeparators` walks dynamicBlocks itself and draws only
1936
+ * the seam, because `elided` and `boundary` mark state for an
1937
+ * interactive row. In a static figure the striped grey would cover most
1938
+ * of the row at whole-genome zoom. The export keeps the seam because
1939
+ * regions lay out contiguously and the seam is all that separates two
1940
+ * of them.
1868
1941
  */
1942
+ // Annotated with the shape spelled out rather than as `PaddingSpan`,
1943
+ // because the API doc's type column is where a host drawing its own
1944
+ // chrome reads the field names, and a named type prints as its name.
1945
+ // `spans` and NO_PADDING_SPANS are both `PaddingSpan[]`, so the two
1946
+ // cannot drift without failing here.
1869
1947
  get paddingSpans() {
1870
1948
  const { blocks, offsetPx: firstBlockOffset } = this.staticBlocks;
1871
1949
  const spans = [];
@@ -1892,7 +1970,14 @@ export function stateModelFactory(pluginManager) {
1892
1970
  });
1893
1971
  }
1894
1972
  }
1895
- return spans;
1973
+ // One shared array when there is nothing to draw, so the computed's
1974
+ // value repeats and MobX's `===` stops the chain here. This is the
1975
+ // COMMON case, not an edge one: a view sitting inside one contig has
1976
+ // no region seam, no elision and no boundary, and that is where a
1977
+ // reader spends nearly all of a session. A fresh `[]` each frame
1978
+ // instead re-rendered a PaddingBlocks per track plus one for the
1979
+ // container, every frame of every gesture, to draw nothing.
1980
+ return spans.length > 0 ? spans : NO_PADDING_SPANS;
1896
1981
  },
1897
1982
  /**
1898
1983
  * #getter
@@ -1924,8 +2009,8 @@ export function stateModelFactory(pluginManager) {
1924
2009
  * viewport. False both when the view holds no regions at all and when
1925
2010
  * it holds some but is scrolled entirely off them; either way there is
1926
2011
  * no visible span for the scalebar, ruler and refName labels to
1927
- * describe, which is what the SVG export's header checks before drawing
1928
- * one. Distinct from `hasDisplayedRegions`, which only asks whether the
2012
+ * describe. The SVG export checks it before drawing its header.
2013
+ * Distinct from `hasDisplayedRegions`, which only asks whether the
1929
2014
  * view has been given regions, not whether any are on screen.
1930
2015
  */
1931
2016
  get hasVisibleContent() {
@@ -1962,6 +2047,32 @@ export function stateModelFactory(pluginManager) {
1962
2047
  };
1963
2048
  });
1964
2049
  },
2050
+ /**
2051
+ * #getter
2052
+ * Right edge (px, viewport-relative) of the on-screen content, clamped
2053
+ * to the track — where a right-pinned overlay such as a wiggle's colour
2054
+ * or score legend belongs, rather than out in the empty gutter the
2055
+ * regions can leave at whole-genome zoom. `@jbrowse/display-kit`'s
2056
+ * `contentRightEdgePx` states the rule; an SVG export applies the same
2057
+ * one against the export's canvas width instead of this track's.
2058
+ *
2059
+ * Published as a SCALAR on purpose. `visibleRegions` rebuilds a fresh
2060
+ * array of fresh objects on every pan and zoom frame, so a component
2061
+ * that derived this number itself re-rendered on every frame of every
2062
+ * gesture — to produce, whenever content fills the track, the unchanged
2063
+ * `trackWidthPx`. Reading the scalar lets MobX's `===` stop the chain at
2064
+ * this computed instead. Measured over 20 zoom frames: the wiggle
2065
+ * family's three body instances fell from 66 renders to 7.
2066
+ */
2067
+ get contentRightEdgePx() {
2068
+ // The `trackWidthPx` rule sends a DISPLAY to `model.canvasWidthPx`
2069
+ // so it cannot pick the wrong one of four view getters. This is the
2070
+ // view, publishing its own geometry, and `canvasWidthPx` is defined
2071
+ // as exactly this host's `trackWidthPx` (MultiRegionDisplayMixin) —
2072
+ // the same source of truth, read from the side that owns it.
2073
+ // eslint-disable-next-line no-restricted-syntax
2074
+ return contentRightEdgePx(this.visibleRegions, self.trackWidthPx);
2075
+ },
1965
2076
  /**
1966
2077
  * #getter
1967
2078
  * visibleRegions expanded by a half-screen buffer on each side,
@@ -2001,23 +2112,22 @@ export function stateModelFactory(pluginManager) {
2001
2112
  },
2002
2113
  /**
2003
2114
  * #getter
2004
- * **What a debounced consumer clips to**: the coarse blocks once the
2005
- * view has settled at least once, the live ones before that.
2115
+ * **The blocks a debounced consumer clips to**: the coarse blocks once
2116
+ * the view has settled at least once, the live ones before that.
2006
2117
  *
2007
- * The coarse blocks exist so a per-bp scan does not recompute on every
2008
- * animation frame during a pan or zoom — wiggle's autoscale domain, the
2009
- * alignments coverage scale and MAF's coverage band are the three — and
2010
- * while they are stale
2011
- * the answer is merely a frame or two old, which is what the debounce
2012
- * means. **Empty is different in kind.** A scan over no blocks yields no
2013
- * entries, and no entries is not a stale domain, it is the fallback one:
2014
- * `[0,1]`, which draws a line plot blank and a density plot saturated.
2015
- * That window is the 500ms between a view initializing and the coarse
2016
- * autorun's first run, and data can now land inside it — the per-region
2017
- * fetch used to be trailing-edge at 600ms, so it never did.
2118
+ * The coarse blocks stop a per-bp scan from recomputing on every
2119
+ * animation frame during a pan or zoom. Wiggle's autoscale domain, the
2120
+ * alignments coverage scale and MAF's coverage band use them. While the
2121
+ * coarse blocks are stale the result is a frame or two old, as the
2122
+ * debounce intends. **Empty coarse blocks are a different case.** A scan
2123
+ * over no blocks yields no entries, and the consumer falls back to the
2124
+ * domain `[0,1]`, which draws a line plot blank and a density plot
2125
+ * saturated. The coarse blocks are empty for the 500ms between a view
2126
+ * initializing and the coarse autorun's first run, and data can load
2127
+ * within that window.
2018
2128
  *
2019
- * One recompute at that transition, against N per frame, which is the
2020
- * trade the guard was making anyway.
2129
+ * Falling back to the live blocks costs one extra recompute at that
2130
+ * transition, compared with N per frame without the debounce.
2021
2131
  */
2022
2132
  get settledDynamicBlocks() {
2023
2133
  return self.coarseDynamicBlocks.length
@@ -2041,7 +2151,7 @@ export function stateModelFactory(pluginManager) {
2041
2151
  * #getter
2042
2152
  */
2043
2153
  get effectiveTotalBp() {
2044
- return self.bpPerPx * self.width;
2154
+ return self.windowWidthBp;
2045
2155
  },
2046
2156
  /**
2047
2157
  * #getter
@@ -2128,7 +2238,7 @@ export function stateModelFactory(pluginManager) {
2128
2238
  * as. Prefer `setWindow`: pixels mean nothing without the width they were
2129
2239
  * measured at, so a round trip through here is only exact while the width
2130
2240
  * holds still. Kept for callers that genuinely have pixels — a wheel
2131
- * gesture, a rubberband — and for reading old snapshots.
2241
+ * gesture, a rubberband.
2132
2242
  */
2133
2243
  setNewView(bpPerPx, offsetPx) {
2134
2244
  self.zoomTo(bpPerPx);
@@ -2280,14 +2390,6 @@ export function stateModelFactory(pluginManager) {
2280
2390
  clearView() {
2281
2391
  self.setDisplayedRegions([]);
2282
2392
  self.tracks.clear();
2283
- // it is necessary to run these after setting displayed regions empty
2284
- // or else model.offsetPx gets set to Infinity and breaks
2285
- // @jbrowse/mobx-state-tree snapshot
2286
- self.scrollTo(0);
2287
- self.zoomTo(10);
2288
- // after them, not through setDisplayedRegions, whose own settle ran at
2289
- // the intermediate viewport these two lines exist to leave
2290
- self.settleCoarseBlocks();
2291
2393
  },
2292
2394
  /**
2293
2395
  * #action
@@ -2386,13 +2488,15 @@ export function stateModelFactory(pluginManager) {
2386
2488
  * navigating to the locstring
2387
2489
  * @param grow - optional multiplier to expand the region by (e.g., 0.2
2388
2490
  * adds 20% padding on each side)
2491
+ * @param opts - `showHitTrack: false` keeps a text-search hit from also
2492
+ * showing the track its index was built from
2389
2493
  * @returns whether the view moved. A multi-hit search raises the picker
2390
2494
  * instead of navigating, and both resolve — see `handleSelectedRegion`.
2391
2495
  */
2392
2496
  // annotated, not inferred: the same type cycle `showTrack` names. Left to
2393
2497
  // infer, the `boolean` reaches this model's type through `self` and 180
2394
2498
  // unrelated files lose their parameter types to it.
2395
- async navToLocString(input, optAssemblyName, grow) {
2499
+ async navToLocString(input, optAssemblyName, grow, opts) {
2396
2500
  const { assemblyNames } = self;
2397
2501
  const session = getSession(self);
2398
2502
  const { assemblyManager } = session;
@@ -2407,6 +2511,7 @@ export function stateModelFactory(pluginManager) {
2407
2511
  input,
2408
2512
  assemblyName,
2409
2513
  grow,
2514
+ showHitTrack: opts?.showHitTrack,
2410
2515
  model: self,
2411
2516
  });
2412
2517
  },
@@ -2429,7 +2534,18 @@ export function stateModelFactory(pluginManager) {
2429
2534
  return;
2430
2535
  }
2431
2536
  const { assemblyManager } = getSession(self);
2432
- await when(() => self.volatileWidth !== undefined);
2537
+ // A one-sided `volatileWidth !== undefined` parks forever on a view
2538
+ // that never mounts, and `navToMultiLevelBreak` awaits a Promise.all of
2539
+ // these one line before the `whenViewSettled` that was written to cure
2540
+ // exactly that hang. `isAlive` is the second terminal state and the
2541
+ // guard below is what it means. NOT `self.error`, which is a transient
2542
+ // truth (`assembliesNotFound` while a session assembly is registering)
2543
+ // independent of mounting, so it would return without navigating where
2544
+ // waiting would have navigated correctly.
2545
+ await when(() => self.volatileWidth !== undefined || !isAlive(self));
2546
+ if (!isAlive(self) || self.volatileWidth === undefined) {
2547
+ return;
2548
+ }
2433
2549
  // Generate locations from the parsed regions
2434
2550
  const locations = await generateLocations({
2435
2551
  regions,
@@ -2492,6 +2608,31 @@ export function stateModelFactory(pluginManager) {
2492
2608
  async navToLocation(parsedLocString, assemblyName, grow) {
2493
2609
  return self.navToLocations([parsedLocString], assemblyName, grow);
2494
2610
  },
2611
+ /**
2612
+ * #action
2613
+ * adds the region in view to the session's highlights
2614
+ */
2615
+ highlightCurrentRegion() {
2616
+ const [region] = self.visibleWholeBaseRegions;
2617
+ if (region) {
2618
+ getSession(self).addHighlight(region);
2619
+ }
2620
+ },
2621
+ /**
2622
+ * #action
2623
+ * navigates to the most recently added highlight on this view's
2624
+ * assemblies, zoomed out a little for context
2625
+ */
2626
+ async navigateNewestHighlight() {
2627
+ const newest = self.highlights.at(-1);
2628
+ if (newest) {
2629
+ const { assemblyName, ...region } = newest;
2630
+ await self.navToLocString(assembleLocString(region), assemblyName, 0.2);
2631
+ }
2632
+ else {
2633
+ getSession(self).notify('There are no highlights to navigate to.', 'info');
2634
+ }
2635
+ },
2495
2636
  }))
2496
2637
  .views(self => ({
2497
2638
  /**
@@ -2508,14 +2649,28 @@ export function stateModelFactory(pluginManager) {
2508
2649
  },
2509
2650
  /**
2510
2651
  * #method
2511
- * Map a highlight or bookmark region to its pixel position+width inside
2652
+ * Map a highlight region to its pixel position+width inside
2512
2653
  * the tracks container. Falls back to the raw refName if the region's
2513
2654
  * assemblyName is missing or unknown so highlights authored without an
2514
2655
  * assembly still render in single-assembly views.
2515
2656
  */
2516
2657
  getHighlightCoords(region) {
2517
- const refName = resolveCanonicalRefName(self, region);
2518
- return getLayoutHighlightCoords(self, { ...region, refName });
2658
+ return getLayoutHighlightCoords(self, canonicalHighlight(self, region));
2659
+ },
2660
+ /**
2661
+ * #method
2662
+ * y ranges, from the top of the highlight bands, of tracks whose display
2663
+ * sets `drawsGenomicCoordinates` to false; the bands skip them
2664
+ */
2665
+ nonGenomicTrackBands() {
2666
+ const y0 = self.headerHeight + self.scalebarHeight;
2667
+ return [...self.pinnedTracks, ...self.unpinnedTracks].flatMap(track => {
2668
+ const display = track.activeDisplay;
2669
+ const top = self.getTrackYOffset(track.configuration.trackId);
2670
+ return display.drawsGenomicCoordinates === false && top !== undefined
2671
+ ? [{ top: top - y0, height: self.trackHeight(track) }]
2672
+ : [];
2673
+ });
2519
2674
  },
2520
2675
  /**
2521
2676
  * #method
@@ -2523,11 +2678,7 @@ export function stateModelFactory(pluginManager) {
2523
2678
  * shifted by the cytoband offset
2524
2679
  */
2525
2680
  getOverviewHighlightCoords(region) {
2526
- const refName = resolveCanonicalRefName(self, region);
2527
- const coords = getLayoutHighlightCoords(self.overviewLayout, {
2528
- ...region,
2529
- refName,
2530
- });
2681
+ const coords = getLayoutHighlightCoords(self.overviewLayout, canonicalHighlight(self, region));
2531
2682
  return coords
2532
2683
  ? { ...coords, left: coords.left + self.cytobandOffset }
2533
2684
  : undefined;
@@ -2566,6 +2717,17 @@ export function stateModelFactory(pluginManager) {
2566
2717
  ? this.pxToBp(self.width / 2)
2567
2718
  : undefined;
2568
2719
  },
2720
+ }))
2721
+ .actions(self => ({
2722
+ /**
2723
+ * #action
2724
+ * a region edit from the ruler's menu as one action, which a synteny
2725
+ * stack's follow reads as the reader's gesture on this row; see
2726
+ * `setDisplayedRegionsKeepingCenter`
2727
+ */
2728
+ editDisplayedRegions(edit) {
2729
+ setDisplayedRegionsKeepingCenter(self, edit);
2730
+ },
2569
2731
  }))
2570
2732
  .views(self => ({
2571
2733
  /**
@@ -2594,21 +2756,21 @@ export function stateModelFactory(pluginManager) {
2594
2756
  let heading;
2595
2757
  /**
2596
2758
  * #action
2597
- * Travel to a window rather than appear in it: the Van Wijk arc from
2598
- * where the view is to where it is going, played over its own duration.
2759
+ * Animate to a window along the Van Wijk arc from the current window to
2760
+ * the target, over a duration derived from the arc.
2599
2761
  *
2600
- * WHAT LANDS IS WHAT `setWindow` WOULD HAVE LANDED — only the path in
2601
- * between is new. That is what lets a caller offer an Undo, a snackbar or
2602
- * a follow anchor around this exactly as it did around the instant move.
2762
+ * The final state is the one `setWindow` would produce; only the path in
2763
+ * between differs. A caller can therefore offer an Undo, a snackbar or a
2764
+ * follow anchor around this exactly as around the instant move.
2603
2765
  *
2604
- * It YIELDS to anything else that moves the view, by reading back what it
2605
- * wrote and stopping the moment the view holds something else: a wheel
2606
- * zoom, a drag, a locstring nav, or the Undo on the very snackbar the
2607
- * flight was launched with. Compared against what was WRITTEN rather than
2608
- * what was asked for, because the write clamps — an arc that pulls back
2609
- * past `maxBpPerPx` reads its own clamped result back, and treating that
2610
- * as interference would end the flight one frame in. `springAnimate`
2611
- * defends the same value the same way.
2766
+ * The flight stops when anything else moves the view: it reads back what
2767
+ * it wrote and stops as soon as the view holds something else, such as a
2768
+ * wheel zoom, a drag, a locstring nav, or the Undo on the snackbar the
2769
+ * flight was launched with. It compares against the written value, not
2770
+ * the requested one, because the write clamps: an arc that pulls back
2771
+ * past `maxBpPerPx` reads back its clamped result, and treating that as
2772
+ * interference would end the flight after one frame. `springAnimate`
2773
+ * handles the same value the same way.
2612
2774
  */
2613
2775
  function flyTo(centerBp, windowWidthBp) {
2614
2776
  cancelLastFlight();
@@ -2675,7 +2837,7 @@ export function stateModelFactory(pluginManager) {
2675
2837
  * `centerAt`'s animated twin: the same destination, reached along the
2676
2838
  * arc instead of jumped to, at the zoom the view is already on.
2677
2839
  */
2678
- flyToCenter(coord, refName) {
2840
+ flyToCenter(coord, refName, displayedRegionIndex) {
2679
2841
  // `bpToLinearBp` rather than `bpToPx`, which rounds to a whole pixel
2680
2842
  // at the current zoom: mid-flight that zoom is a point on somebody
2681
2843
  // else's arc, and a destination quantized against it moves with it.
@@ -2683,11 +2845,22 @@ export function stateModelFactory(pluginManager) {
2683
2845
  refName,
2684
2846
  coord,
2685
2847
  displayedRegions: self.displayedRegions,
2848
+ displayedRegionIndex,
2686
2849
  });
2687
2850
  if (centerBp !== undefined) {
2688
2851
  flyTo(centerBp, heading ? heading.windowWidthBp : self.windowWidthBp);
2689
2852
  }
2690
2853
  },
2854
+ /**
2855
+ * #action
2856
+ * A flight to a linear bp centre at the zoom the view is on, or is
2857
+ * already flying to, widened where that is narrower than `fitWidthBp`.
2858
+ * The current width mid-flight is a point on the arc, so a second
2859
+ * flight read from it would land pulled back.
2860
+ */
2861
+ flyToFit(centerBp, fitWidthBp) {
2862
+ flyTo(centerBp, Math.max(heading ? heading.windowWidthBp : self.windowWidthBp, fitWidthBp));
2863
+ },
2691
2864
  };
2692
2865
  })
2693
2866
  .actions(self => ({
@@ -2697,22 +2870,21 @@ export function stateModelFactory(pluginManager) {
2697
2870
  afterAttach() {
2698
2871
  doAfterAttach(self);
2699
2872
  },
2700
- }))
2873
+ }));
2874
+ return withLaunchInput(model, lgvLaunchKeys, {
2875
+ registry: pluginManager,
2876
+ materialized: snap => !!snap.displayedRegions?.length,
2877
+ })
2701
2878
  .preProcessSnapshot((snap) => {
2702
2879
  if (!snap) {
2703
2880
  return snap;
2704
2881
  }
2705
- // The cytobands setting has been `showCytobandsSetting` and (briefly)
2706
- // `cytobandsVisible`; both now persist as the bare `showCytobands` prop
2882
+ // v4's `showCytobandsSetting` persists as the bare `showCytobands` prop
2707
2883
  // (the capability-gated getter is `effectiveShowCytobands`).
2708
- const { highlight, showCytobandsSetting, cytobandsVisible, offsetPx, bpPerPx, ...rest } = snap;
2709
- const legacyShowCytobands = showCytobandsSetting ?? cytobandsVisible;
2884
+ const { showCytobandsSetting, offsetPx, bpPerPx, ...rest } = snap;
2710
2885
  return {
2711
- highlight: Array.isArray(highlight) || highlight === undefined
2712
- ? highlight
2713
- : [highlight],
2714
- ...(legacyShowCytobands !== undefined
2715
- ? { showCytobands: legacyShowCytobands }
2886
+ ...(showCytobandsSetting !== undefined
2887
+ ? { showCytobands: showCytobandsSetting }
2716
2888
  : {}),
2717
2889
  // The viewport used to persist as pixels. Half of that converts
2718
2890
  // exactly and needs no width — the left edge in bp is offsetPx *
@@ -2739,23 +2911,16 @@ export function stateModelFactory(pluginManager) {
2739
2911
  };
2740
2912
  })
2741
2913
  .postProcessSnapshot(snap => {
2742
- // init is transient launch state, never persisted. showCenterLine is
2743
- // purely a localStorage-backed preference (see setupLocalStorageAutorun
2744
- // in afterAttach.ts) and is never persisted into session snapshots. The
2745
- // remaining fields are also localStorage-backed, but still persist when
2746
- // non-default: their strip baseline is the universal default (hardcoded
2747
- // here), not the localStorage-derived creation default, so a
2748
- // localStorage-set value stays portable across browsers.
2749
- const { init, showCenterLine, showCytobands, trackLabels, colorByCDS, showAminoAcids, showTrackOutlines, ...rest } = snap;
2914
+ // showCenterLine is purely a localStorage-backed preference (see
2915
+ // setupLocalStorageAutorun in afterAttach.ts) and is never persisted
2916
+ // into session snapshots. The remaining fields are also
2917
+ // localStorage-backed, but still persist when non-default: their strip
2918
+ // baseline is the universal default (hardcoded here), not the
2919
+ // localStorage-derived creation default, so a localStorage-set value
2920
+ // stays portable across browsers.
2921
+ const { showCenterLine, showCytobands, trackLabels, colorByCDS, showAminoAcids, showTrackOutlines, ...rest } = snap;
2750
2922
  return {
2751
2923
  ...rest,
2752
- // keep init until displayedRegions exist, so a snapshot taken before the
2753
- // launch autorun navigates (e.g. autosave firing mid-load) can still
2754
- // rebuild the view instead of dropping to the import form.
2755
- // displayedRegions is stripDefault, so it's absent (not []) when empty —
2756
- // the optional chain is runtime-necessary despite the non-nullish type.
2757
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
2758
- ...(init && !snap.displayedRegions?.length ? { init } : {}),
2759
2924
  ...(!showCytobands ? { showCytobands } : {}),
2760
2925
  ...(trackLabels ? { trackLabels } : {}),
2761
2926
  ...(colorByCDS ? { colorByCDS } : {}),