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

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 (177) hide show
  1. package/esm/LinearGenomeView/afterAttach.d.ts +22 -5
  2. package/esm/LinearGenomeView/afterAttach.js +129 -106
  3. package/esm/LinearGenomeView/axisConfigSchema.d.ts +18 -0
  4. package/esm/LinearGenomeView/axisConfigSchema.js +22 -0
  5. package/esm/LinearGenomeView/components/CenterLine.js +10 -5
  6. package/esm/LinearGenomeView/components/ConnectedHoverHighlight.js +2 -3
  7. package/esm/LinearGenomeView/components/CrisprGuidePanel.js +3 -5
  8. package/esm/LinearGenomeView/components/ExportSvgDialog.d.ts +9 -4
  9. package/esm/LinearGenomeView/components/ExportSvgDialog.js +45 -8
  10. package/esm/LinearGenomeView/components/GetSequenceDialog.js +2 -2
  11. package/esm/LinearGenomeView/components/Header.js +11 -1
  12. package/esm/LinearGenomeView/components/HeaderClearHighlightButton.js +2 -2
  13. package/esm/LinearGenomeView/components/HeaderPanControls.d.ts +3 -3
  14. package/esm/LinearGenomeView/components/HeaderPanControls.js +6 -3
  15. package/esm/LinearGenomeView/components/HeaderSearchBoxes.js +4 -1
  16. package/esm/LinearGenomeView/components/HeaderZoomControls.js +6 -35
  17. package/esm/LinearGenomeView/components/Highlight.js +27 -11
  18. package/esm/LinearGenomeView/components/HighlightBand.d.ts +11 -2
  19. package/esm/LinearGenomeView/components/HighlightBand.js +32 -13
  20. package/esm/LinearGenomeView/components/HighlightChip.d.ts +3 -3
  21. package/esm/LinearGenomeView/components/HighlightChip.js +13 -8
  22. package/esm/LinearGenomeView/components/HighlightLabelDialog.d.ts +6 -0
  23. package/esm/LinearGenomeView/components/HighlightLabelDialog.js +18 -0
  24. package/esm/LinearGenomeView/components/HoverPositionHighlight.js +3 -3
  25. package/esm/LinearGenomeView/components/ImportForm.js +7 -9
  26. package/esm/LinearGenomeView/components/LinearGenomeView.js +5 -12
  27. package/esm/LinearGenomeView/components/LinearGenomeViewContainer.js +13 -14
  28. package/esm/LinearGenomeView/components/MiniControls.js +11 -5
  29. package/esm/LinearGenomeView/components/OverviewHighlight.js +3 -3
  30. package/esm/LinearGenomeView/components/OverviewHighlightBand.d.ts +3 -2
  31. package/esm/LinearGenomeView/components/OverviewHighlightBand.js +6 -5
  32. package/esm/LinearGenomeView/components/OverviewRubberband.js +25 -48
  33. package/esm/LinearGenomeView/components/OverviewScalebar.js +4 -5
  34. package/esm/LinearGenomeView/components/OverviewScalebarPolygon.js +5 -3
  35. package/esm/LinearGenomeView/components/PaddingBlocks.js +3 -8
  36. package/esm/LinearGenomeView/components/RangeSelectOverlay.js +5 -6
  37. package/esm/LinearGenomeView/components/RegionWidthEditorDialog.d.ts +3 -2
  38. package/esm/LinearGenomeView/components/RegionWidthEditorDialog.js +4 -4
  39. package/esm/LinearGenomeView/components/ResizeAllTracksHandle.d.ts +5 -0
  40. package/esm/LinearGenomeView/components/ResizeAllTracksHandle.js +74 -0
  41. package/esm/LinearGenomeView/components/Rubberband.d.ts +1 -2
  42. package/esm/LinearGenomeView/components/Rubberband.js +3 -2
  43. package/esm/LinearGenomeView/components/SVGHighlightBand.d.ts +10 -1
  44. package/esm/LinearGenomeView/components/SVGHighlightBand.js +16 -5
  45. package/esm/LinearGenomeView/components/Scalebar.d.ts +2 -6
  46. package/esm/LinearGenomeView/components/Scalebar.js +8 -5
  47. package/esm/LinearGenomeView/components/ScalebarRefNameLabels.js +51 -36
  48. package/esm/LinearGenomeView/components/SearchBox.js +10 -16
  49. package/esm/LinearGenomeView/components/SearchPanelForm.d.ts +3 -2
  50. package/esm/LinearGenomeView/components/SearchPanelForm.js +4 -2
  51. package/esm/LinearGenomeView/components/SearchResultsDialog.d.ts +4 -8
  52. package/esm/LinearGenomeView/components/SearchResultsDialog.js +7 -4
  53. package/esm/LinearGenomeView/components/SearchResultsTable.d.ts +4 -7
  54. package/esm/LinearGenomeView/components/SearchResultsTable.js +25 -53
  55. package/esm/LinearGenomeView/components/SequenceFeatureHoverHighlight.js +2 -3
  56. package/esm/LinearGenomeView/components/SequenceSearchDialog.d.ts +2 -4
  57. package/esm/LinearGenomeView/components/StrandCheckboxes.d.ts +3 -2
  58. package/esm/LinearGenomeView/components/StrandCheckboxes.js +4 -2
  59. package/esm/LinearGenomeView/components/TrackContainer.d.ts +2 -3
  60. package/esm/LinearGenomeView/components/TrackContainer.js +63 -10
  61. package/esm/LinearGenomeView/components/TrackLabel.js +14 -4
  62. package/esm/LinearGenomeView/components/TrackLabelButtons.js +2 -2
  63. package/esm/LinearGenomeView/components/TrackLabelDragHandle.js +4 -5
  64. package/esm/LinearGenomeView/components/TrackLabelMenu.js +11 -14
  65. package/esm/LinearGenomeView/components/TrackLabelRefNameWarning.d.ts +3 -4
  66. package/esm/LinearGenomeView/components/TrackLabelRefNameWarning.js +23 -10
  67. package/esm/LinearGenomeView/components/TrackRenderingContainer.d.ts +6 -4
  68. package/esm/LinearGenomeView/components/TrackRenderingContainer.js +12 -24
  69. package/esm/LinearGenomeView/components/TracksContainer.js +11 -23
  70. package/esm/LinearGenomeView/components/VerticalGuide.d.ts +1 -2
  71. package/esm/LinearGenomeView/components/VerticalGuide.js +3 -16
  72. package/esm/LinearGenomeView/components/fetchSequence.d.ts +1 -2
  73. package/esm/LinearGenomeView/components/fetchSequence.js +15 -7
  74. package/esm/LinearGenomeView/components/recentLocationsMenu.js +10 -0
  75. package/esm/LinearGenomeView/components/useRangeSelect.d.ts +0 -3
  76. package/esm/LinearGenomeView/components/useRangeSelect.js +26 -61
  77. package/esm/LinearGenomeView/components/useSearchBoxPrefs.d.ts +3 -1
  78. package/esm/LinearGenomeView/components/useSearchBoxPrefs.js +4 -2
  79. package/esm/LinearGenomeView/components/useSideScroll.d.ts +17 -2
  80. package/esm/LinearGenomeView/components/useSideScroll.js +156 -102
  81. package/esm/LinearGenomeView/components/util.d.ts +4 -0
  82. package/esm/LinearGenomeView/components/util.js +4 -2
  83. package/esm/LinearGenomeView/consts.d.ts +2 -0
  84. package/esm/LinearGenomeView/consts.js +3 -1
  85. package/esm/LinearGenomeView/containingLgv.d.ts +21 -0
  86. package/esm/LinearGenomeView/containingLgv.js +23 -0
  87. package/esm/LinearGenomeView/headerFit.d.ts +4 -2
  88. package/esm/LinearGenomeView/headerFit.js +20 -7
  89. package/esm/LinearGenomeView/index.d.ts +2 -7
  90. package/esm/LinearGenomeView/index.js +5 -7
  91. package/esm/LinearGenomeView/keyboardHandler.d.ts +8 -7
  92. package/esm/LinearGenomeView/keyboardHandler.js +61 -23
  93. package/esm/LinearGenomeView/launchKeys.d.ts +1 -1
  94. package/esm/LinearGenomeView/launchKeys.js +11 -6
  95. package/esm/LinearGenomeView/lazyDialogs.d.ts +16 -3
  96. package/esm/LinearGenomeView/lazyDialogs.js +1 -0
  97. package/esm/LinearGenomeView/linkedViewSync.d.ts +3 -11
  98. package/esm/LinearGenomeView/linkedViewSync.js +27 -49
  99. package/esm/LinearGenomeView/menuItems.d.ts +16 -18
  100. package/esm/LinearGenomeView/menuItems.js +264 -164
  101. package/esm/LinearGenomeView/model.d.ts +1192 -560
  102. package/esm/LinearGenomeView/model.js +805 -590
  103. package/esm/LinearGenomeView/rExport/exportLinearView.d.ts +17 -0
  104. package/esm/LinearGenomeView/rExport/exportLinearView.js +117 -0
  105. package/esm/LinearGenomeView/rExport/rScript.d.ts +110 -0
  106. package/esm/LinearGenomeView/rExport/rScript.js +524 -0
  107. package/esm/LinearGenomeView/sharedScaleContainer.d.ts +1 -1
  108. package/esm/LinearGenomeView/sharedScaleContainer.js +2 -12
  109. package/esm/LinearGenomeView/showFiles.d.ts +2 -0
  110. package/esm/LinearGenomeView/showFiles.js +19 -0
  111. package/esm/LinearGenomeView/showRegionsWithUndo.d.ts +1 -1
  112. package/esm/LinearGenomeView/showRegionsWithUndo.js +13 -5
  113. package/esm/LinearGenomeView/stickyChrome.d.ts +5 -9
  114. package/esm/LinearGenomeView/stickyChrome.js +5 -9
  115. package/esm/LinearGenomeView/svgcomponents/SVGGridlines.js +4 -4
  116. package/esm/LinearGenomeView/svgcomponents/SVGHeader.d.ts +4 -1
  117. package/esm/LinearGenomeView/svgcomponents/SVGHeader.js +8 -21
  118. package/esm/LinearGenomeView/svgcomponents/SVGHighlights.d.ts +3 -1
  119. package/esm/LinearGenomeView/svgcomponents/SVGHighlights.js +9 -9
  120. package/esm/LinearGenomeView/svgcomponents/SVGHighlightsOverlay.d.ts +3 -1
  121. package/esm/LinearGenomeView/svgcomponents/SVGHighlightsOverlay.js +5 -5
  122. package/esm/LinearGenomeView/svgcomponents/SVGLinearGenomeView.js +13 -18
  123. package/esm/LinearGenomeView/svgcomponents/SVGRegionSeparators.js +2 -5
  124. package/esm/LinearGenomeView/svgcomponents/SVGRowHeader.js +5 -6
  125. package/esm/LinearGenomeView/svgcomponents/SVGRuler.js +9 -10
  126. package/esm/LinearGenomeView/svgcomponents/SVGScalebar.js +3 -3
  127. package/esm/LinearGenomeView/svgcomponents/SVGStackedRow.d.ts +26 -0
  128. package/esm/LinearGenomeView/svgcomponents/SVGStackedRow.js +12 -0
  129. package/esm/LinearGenomeView/svgcomponents/SVGTrackLabel.js +21 -13
  130. package/esm/LinearGenomeView/svgcomponents/SVGTracks.d.ts +4 -5
  131. package/esm/LinearGenomeView/svgcomponents/SVGTracks.js +7 -6
  132. package/esm/LinearGenomeView/svgcomponents/SVGView.d.ts +2 -3
  133. package/esm/LinearGenomeView/svgcomponents/SVGView.js +17 -3
  134. package/esm/LinearGenomeView/svgcomponents/renderViewTracks.d.ts +24 -20
  135. package/esm/LinearGenomeView/svgcomponents/renderViewTracks.js +42 -29
  136. package/esm/LinearGenomeView/svgcomponents/useViewSvgFigure.js +74 -77
  137. package/esm/LinearGenomeView/svgcomponents/util.d.ts +28 -12
  138. package/esm/LinearGenomeView/svgcomponents/util.js +41 -29
  139. package/esm/LinearGenomeView/types.d.ts +13 -10
  140. package/esm/LinearGenomeView/types.js +0 -1
  141. package/esm/LinearGenomeView/util.d.ts +103 -46
  142. package/esm/LinearGenomeView/util.js +222 -170
  143. package/esm/MultiLevelRubberband/MultiLevelRubberband.js +9 -6
  144. package/esm/MultiLevelRubberband/index.d.ts +2 -0
  145. package/esm/MultiLevelRubberband/index.js +2 -0
  146. package/esm/MultiLevelRubberband/menuItems.d.ts +34 -0
  147. package/esm/MultiLevelRubberband/menuItems.js +52 -0
  148. package/esm/MultiLevelRubberband/rowLabel.d.ts +22 -0
  149. package/esm/MultiLevelRubberband/rowLabel.js +27 -0
  150. package/esm/MultiLevelRubberband/types.d.ts +1 -0
  151. package/esm/MultiLevelRubberband/useRangeSelect.d.ts +2 -1
  152. package/esm/MultiLevelRubberband/useRangeSelect.js +45 -59
  153. package/esm/TrackModifiers/index.d.ts +83 -0
  154. package/esm/TrackModifiers/index.js +231 -0
  155. package/esm/displayKitTests/perRegionTestEnv.d.ts +244 -68
  156. package/esm/displayKitTests/perRegionTestEnv.js +30 -26
  157. package/esm/index.d.ts +21 -99
  158. package/esm/index.js +17 -38
  159. package/esm/searchUtils.d.ts +26 -10
  160. package/esm/searchUtils.js +137 -137
  161. package/esm/shared/RubberbandSpan.d.ts +3 -2
  162. package/esm/shared/RubberbandSpan.js +6 -3
  163. package/esm/shared/coordLabels.d.ts +11 -4
  164. package/esm/shared/coordLabels.js +20 -5
  165. package/esm/shared/useWindowDrag.d.ts +18 -0
  166. package/esm/shared/useWindowDrag.js +47 -0
  167. package/package.json +12 -11
  168. package/esm/FeatureTrack/configSchema.d.ts +0 -124
  169. package/esm/FeatureTrack/configSchema.js +0 -54
  170. package/esm/FeatureTrack/index.d.ts +0 -2
  171. package/esm/FeatureTrack/index.js +0 -13
  172. package/esm/LinearGenomeView/initKeys.d.ts +0 -12
  173. package/esm/LinearGenomeView/initKeys.js +0 -68
  174. package/esm/MultiLevelRubberband/VerticalGuide.d.ts +0 -6
  175. package/esm/MultiLevelRubberband/VerticalGuide.js +0 -21
  176. package/esm/lazyPluginExports.d.ts +0 -23
  177. package/esm/lazyPluginExports.js +0 -36
@@ -1,36 +1,72 @@
1
- import { lazy } from 'react';
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';
5
- import { assembleLocString, clamp, getBpDisplayStr, getDialogHost, getSession, getTickDisplayStr, isSessionModelWithWidgets, localStorageGetBoolean, localStorageGetItem, springAnimate, sum, } from '@jbrowse/core/util';
1
+ import { canonicalLocString } from '@jbrowse/core/TextSearch/places';
2
+ import { getConf, readConfObject } from '@jbrowse/core/configuration';
3
+ import { BaseViewModel } from '@jbrowse/core/pluggableElementTypes/models';
4
+ import { exportViewSvg } from '@jbrowse/core/svg/exportViewSvg';
5
+ import { highlightListMenuItems } from '@jbrowse/core/ui/menuItems';
6
+ import { assembleLocString, clamp, getBpDisplayStr, getEnv, getNotificationSink, getSession, isSessionModelWithWidgets, localStorageGetBoolean, localStorageGetItem, localStorageSetBoolean, localStorageSetItem, springAnimate, sum, } from '@jbrowse/core/util';
6
7
  import { bpToLinearBp, bpToPx, computeMoveToLayout, createOverviewLayout, getLayoutHighlightCoords, getOverviewRegionPxSpan, moveTo, pxToBp, } from '@jbrowse/core/util/Base1DUtils';
8
+ import { scaleBandHeights } from '@jbrowse/core/util/bandHeight';
7
9
  import { wholeBaseRegions } from '@jbrowse/core/util/blockTypes';
8
10
  import calculateDynamicBlocks from '@jbrowse/core/util/calculateDynamicBlocks';
9
11
  import calculateStaticBlocks from '@jbrowse/core/util/calculateStaticBlocks';
10
- import { tickLabelsWorthDrawing } from '@jbrowse/core/util/tickLabels';
12
+ import { coerceHighlight } from '@jbrowse/core/util/highlights';
11
13
  import { hideTrackGeneric, launchToggleTrackGeneric, launchTrackGeneric, showTrackGeneric, toggleTrackGeneric, } from '@jbrowse/core/util/tracks';
12
14
  import { ElementId } from '@jbrowse/core/util/types/mst';
13
- import { assemblyErrorMessage, computeViewStatus, } from '@jbrowse/core/util/viewStatus';
15
+ import { highlightsOnAssemblies } from '@jbrowse/core/util/viewHighlights';
16
+ import { assemblyErrorMessage, computeViewStatus, viewLoading, } from '@jbrowse/core/util/viewStatus';
14
17
  import { pendingLaunch, withLaunchInput, } from '@jbrowse/core/util/withLaunchInput';
18
+ import { MIN_DISPLAY_HEIGHT } from '@jbrowse/display-kit/const';
15
19
  import { contentRightEdgePx } from '@jbrowse/display-kit/regionHost';
16
- import { cast, getParent, hasParent, isAlive, types, } from '@jbrowse/mobx-state-tree';
17
- import { when } from 'mobx';
18
- import { handleSelectedRegion } from '../searchUtils.js';
20
+ import { VIEW_TRACK_LABEL_MODES } from '@jbrowse/display-kit/types';
21
+ import { cast, getParent, getSnapshot, hasParent, isAlive, types, } from '@jbrowse/mobx-state-tree';
22
+ import { observable, when } from 'mobx';
23
+ import { fetchResults, handleSelectedRegion, isOpenInView, navToOption, } from '../searchUtils.js';
19
24
  import { doAfterAttach } from './afterAttach.js';
20
- import { shouldSwapTracks } from './components/util.js';
21
- 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';
25
+ import { linearGenomeViewAxisConfigSchema } from './axisConfigSchema.js';
26
+ import { getCytobands, shouldSwapTracks } from './components/util.js';
27
+ 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';
22
28
  import { planFlight } from './flyTo.js';
23
29
  import { setupKeyboardHandler } from './keyboardHandler.js';
24
30
  import { lgvLaunchKeys } from './launchKeys.js';
25
- import { buildMenuItems, buildRubberBandMenuItems, buildRubberbandClickMenuItems, } from './menuItems.js';
31
+ import { buildHighlightsSubMenuItems, buildMenuItems, buildRubberBandMenuItems, buildRubberbandClickMenuItems, } from './menuItems.js';
26
32
  import { sharedScaleContainerOf } from './sharedScaleContainer.js';
27
- import { calculateVisibleLocStrings, cytobandLabelGutterWidth, expandRegion, fitAllRegionsWindow, generateLocations, getScalebarRefNameLabels, regionsOrientation, groupContiguousBlocks, labelFitsInBlock, makeBlockTicks, runRefNameLabelPx, tickLabelWidth, } from './util.js';
28
- // lazies
29
- const SearchResultsDialog = lazy(() => import('./components/SearchResultsDialog.js'));
30
- // The one array `paddingSpans` returns when a view has nothing to mask, so that
31
- // computed's value repeats by identity. Frozen because every consumer maps over
32
- // it and a caller that sorted in place would do so for the whole session.
33
- const NO_PADDING_SPANS = Object.freeze([]);
33
+ import { showFiles } from './showFiles.js';
34
+ import { calculateVisibleLocStrings, cytobandLabelGutterWidth, expandRegion, fitAllRegionsWindow, generateLocations, getGridlineTicks, getPaddingSpans, getScalebarLabels, getScalebarRefNameLabels, regionsOrientation, setDisplayedRegionsKeepingCenter, } from './util.js';
35
+ function searchHitsOf(model, results, query, assemblyName, land) {
36
+ const session = getSession(model);
37
+ const assembly = session.assemblyManager.get(assemblyName);
38
+ const ordered = [...results].sort((a, b) => Number(isOpenInView(b, model)) - Number(isOpenInView(a, model)));
39
+ return {
40
+ query,
41
+ assemblyName,
42
+ rows: ordered.map(result => {
43
+ const location = result.getLocation() ?? '';
44
+ const trackId = result.getTrackId();
45
+ return {
46
+ id: result.getId(),
47
+ label: result.getLabel(),
48
+ location: assembly && location
49
+ ? canonicalLocString(location, assembly)
50
+ : location,
51
+ trackName: trackId ? (session.getTrackById(trackId)?.name ?? '') : '',
52
+ };
53
+ }),
54
+ pick: async (id) => {
55
+ const picked = ordered.find(result => result.getId() === id);
56
+ try {
57
+ if (picked) {
58
+ await land(picked);
59
+ }
60
+ }
61
+ catch (e) {
62
+ console.error(e);
63
+ if (isAlive(model)) {
64
+ getNotificationSink(model).notifyError(`${e}`, e);
65
+ }
66
+ }
67
+ },
68
+ };
69
+ }
34
70
  /**
35
71
  * Calculate the offsetPx needed to center content within a viewport.
36
72
  * Returns a negative offset when content is smaller than viewport (padding on left).
@@ -39,42 +75,46 @@ function getCenteredOffsetPx(contentPx, viewportPx) {
39
75
  return Math.round(contentPx / 2 - viewportPx / 2);
40
76
  }
41
77
  /**
42
- * Resolve a region's refName to the assembly's canonical name, falling back to
43
- * the raw refName when the assemblyName is missing or unknown (so highlights
44
- * authored without an assembly still render in single-assembly views).
78
+ * A refName in the named assembly's canonical spelling, or unchanged when that
79
+ * assembly is missing or unknown, so a highlight authored without one still
80
+ * lands in a single-assembly view.
45
81
  */
46
- function resolveCanonicalRefName(self, region) {
82
+ function canonicalRefName(assemblyManager, assemblyName, refName) {
83
+ const asm = assemblyName ? assemblyManager.get(assemblyName) : undefined;
84
+ return asm?.getCanonicalRefName2(refName) ?? refName;
85
+ }
86
+ function canonicalHighlight(self, region) {
47
87
  const { assemblyManager } = getSession(self);
48
- const asm = region.assemblyName
49
- ? assemblyManager.get(region.assemblyName)
50
- : undefined;
51
- return asm?.getCanonicalRefName2(region.refName) ?? region.refName;
88
+ return {
89
+ ...region,
90
+ refName: canonicalRefName(assemblyManager, region.assemblyName, region.refName),
91
+ };
52
92
  }
53
93
  // bpPerPx deltas smaller than this are treated as no zoom change, avoiding
54
94
  // pointless offset re-anchoring on micro-steps
95
+ // a stored mode this build does not know reads as unset, the plugin default
96
+ function storedTrackLabels() {
97
+ const stored = localStorageGetItem('lgv-trackLabels');
98
+ return VIEW_TRACK_LABEL_MODES.find(mode => mode === stored) ?? '';
99
+ }
55
100
  const BP_PER_PX_EPSILON = 0.000001;
56
- // px of the rightmost content kept on-screen at max scroll-right, so the genome
57
- // can't be scrolled entirely off the left edge. Shared by maxOffset and
58
- // getSelectedRegions' clamp so the two bounds can't drift apart.
59
- const MAX_OFFSET_PADDING_PX = 10;
60
- // px of the leftmost content kept on-screen at max scroll-left, mirroring
61
- // MAX_OFFSET_PADDING_PX at the other end
62
- const MIN_OFFSET_PADDING_PX = 30;
101
+ // px of blank space a view may scroll past either end of its regions, enough to
102
+ // read as an edge and too little to lose the content
103
+ const OVERSCROLL_PX = 100;
104
+ // The offsets a view can scroll to: the content edge to edge plus a little
105
+ // overscroll. Content narrower than the view collapses the range to the
106
+ // centered offset, which is what `showAllRegions` and `fitAllRegions` ask for.
107
+ function offsetBounds(contentPx, viewportPx) {
108
+ const centered = getCenteredOffsetPx(contentPx, viewportPx);
109
+ return contentPx > viewportPx
110
+ ? { min: -OVERSCROLL_PX, max: contentPx - viewportPx + OVERSCROLL_PX }
111
+ : { min: centered, max: centered };
112
+ }
63
113
  // whether two BlockSets cover the same blocks, by key and in order
64
114
  function sameBlockKeys(a, b) {
65
115
  return (a.blocks.length === b.blocks.length &&
66
116
  a.blocks.every((block, i) => block.key === b.blocks[i].key));
67
117
  }
68
- /**
69
- * Resolve a NavLocation's refName to the assembly's canonical name, falling
70
- * back to the raw refName (and the view's default assembly) when the assembly
71
- * is missing or unknown.
72
- */
73
- function navLocationRefName(assemblyManager, defaultAssemblyName, location) {
74
- return (assemblyManager
75
- .get(location.assemblyName || defaultAssemblyName)
76
- ?.getCanonicalRefName2(location.refName) || location.refName);
77
- }
78
118
  /**
79
119
  * Resolve one end of a navigation range to the displayedRegion index that
80
120
  * contains it plus the bp offset into that region. `side` selects which edge of
@@ -84,7 +124,7 @@ function navLocationRefName(assemblyManager, defaultAssemblyName, location) {
84
124
  * resolved against those raw coords, and only the region it picks bounds `grow`.
85
125
  */
86
126
  function resolveNavEndpoint({ location, assemblyManager, defaultAssemblyName, side, displayedRegions, grow, }) {
87
- const refName = navLocationRefName(assemblyManager, defaultAssemblyName, location);
127
+ const refName = canonicalRefName(assemblyManager, location.assemblyName || defaultAssemblyName, location.refName);
88
128
  const first = displayedRegions.find(r => r.refName === refName);
89
129
  if (!first) {
90
130
  throw new Error(`could not find a region with refName "${refName}"`);
@@ -131,21 +171,24 @@ function openTrackSelectorWidget(self) {
131
171
  throw new Error('session does not support widgets');
132
172
  }
133
173
  /**
134
- * Whether the track selector is the drawer widget on screen right now. One
174
+ * Whether the drawer is showing this view's track selector right now. One
135
175
  * spelling for the `isTrackSelectorOpen` getter (which lights up the header
136
- * button) and for `toggleTrackSelector` (which decides hide-vs-show) — the
137
- * `!minimized` term is exactly the kind of thing that goes into one of two
138
- * hand-written copies and not the other.
176
+ * button) and for `toggleTrackSelector` (which decides hide-vs-show). The
177
+ * widget id is shared by every view, so the selector's `view` is what says
178
+ * whose it is.
139
179
  */
140
180
  function isTrackSelectorVisible(self) {
141
181
  const session = getSession(self);
142
182
  return (isSessionModelWithWidgets(session) &&
143
183
  session.visibleWidget?.id === TRACK_SELECTOR_WIDGET_ID &&
184
+ session.visibleWidget.view?.id === self.id &&
144
185
  !session.minimized);
145
186
  }
146
187
  /**
147
188
  * #stateModel LinearGenomeView
148
189
  * #category view
190
+ * Stacks tracks under a horizontal axis of one or more genomic regions laid
191
+ * end to end, which the user scrolls, zooms and searches.
149
192
  *
150
193
  * #example
151
194
  * A `LinearGenomeView` is what you hand-author under `defaultSession.views`, and
@@ -157,7 +200,7 @@ function isTrackSelectorVisible(self) {
157
200
  * views: [
158
201
  * {
159
202
  * type: 'LinearGenomeView',
160
- * colorByCDS: true,
203
+ * colorByFrame: true,
161
204
  * assembly: 'hg38',
162
205
  * loc: 'chr1:1,000,000-1,100,000',
163
206
  * tracks: ['genes', 'alignments'],
@@ -167,9 +210,8 @@ function isTrackSelectorVisible(self) {
167
210
  * ```
168
211
  * `assembly`, `loc`, `tracks`, `tracklist`, `nav` and `highlight` need
169
212
  * on-attach resolution and are captured into the `launch` property below;
170
- * `colorByCDS`, `showAminoAcids`, `showCenterLine`, `trackLabels` and
171
- * `showHighlightChips` are plain view props MST restores natively. Both are
172
- * written the same way.
213
+ * `colorByFrame`, `showAminoAcids`, `showCenterLine` and `trackLabels` are plain
214
+ * view props MST restores natively. Both are written the same way.
173
215
  * At runtime the same model is driven imperatively — every property and action
174
216
  * below is reachable on `viewState.session.views[0]`:
175
217
  * ```js
@@ -181,7 +223,7 @@ function isTrackSelectorVisible(self) {
181
223
  */
182
224
  export function stateModelFactory(pluginManager) {
183
225
  const model = types
184
- .compose('LinearGenomeView', BaseViewModel, HighlightsMixin(), types.model({
226
+ .compose('LinearGenomeView', BaseViewModel, types.model({
185
227
  /**
186
228
  * #property
187
229
  */
@@ -196,17 +238,16 @@ export function stateModelFactory(pluginManager) {
196
238
  /**
197
239
  * #property
198
240
  * Left edge of the viewport, in linearized bp — the concatenated
199
- * `displayedRegions` space that `offsetPx` indexes, which carries no
241
+ * `displayedRegions` space that `offsetPx` indexes, which has no
200
242
  * inter-region padding, so the two differ only by `bpPerPx`. May be
201
- * negative, which is the view scrolled past the left end.
243
+ * negative when the view is scrolled past the left end.
202
244
  *
203
- * The viewport is stored as the genomic WINDOW it frames rather than as
204
- * the pixels that framed it, because pixels mean nothing without the
205
- * width they were measured at and a snapshot does not carry one. Storing
206
- * them anyway is why a session authored in a 1000px window used to open
207
- * at 500px showing half the region its author was looking at, while the
208
- * same location as a `&loc=` opened correctly — the two ways to share a
209
- * view disagreed, and only the one that stores intent was right.
245
+ * The view stores the genomic window it frames rather than the pixels
246
+ * that framed it, because pixels mean nothing without the width they
247
+ * were measured at and a snapshot does not include one. A session
248
+ * authored in a 1000px window therefore shows the same region when
249
+ * opened at 500px, as the same location given as a `&loc=` does: both
250
+ * ways to share a view store intent.
210
251
  */
211
252
  windowStartBp: types.stripDefault(types.number, 0),
212
253
  /**
@@ -219,14 +260,15 @@ export function stateModelFactory(pluginManager) {
219
260
  windowWidthBp: types.stripDefault(types.number, 0),
220
261
  /**
221
262
  * #property
222
- * MIGRATION ONLY, and safe to delete once pre-window sessions are no
223
- * longer in circulation.
263
+ * Used for migration only, and permanent: share links written before the
264
+ * window was stored contain `bpPerPx`/`offsetPx`, and there are many of
265
+ * them.
224
266
  *
225
- * A snapshot written before the window was stored carries `offsetPx` and
267
+ * A snapshot written before the window was stored has `offsetPx` and
226
268
  * `bpPerPx` but not the width they were measured at, so the window they
227
269
  * framed cannot be recovered. `windowStartBp` can (it is
228
270
  * `offsetPx * bpPerPx`, no width needed); the width in bp cannot. This
229
- * carries the old `bpPerPx` to the first measure, which adopts it at
271
+ * property holds the old `bpPerPx` until the first measure, which adopts it at
230
272
  * whatever width arrives — exactly what the old code did — and clears
231
273
  * this. So an old link keeps its old behavior rather than being
232
274
  * reinterpreted, and everything authored since restores its window.
@@ -235,9 +277,8 @@ export function stateModelFactory(pluginManager) {
235
277
  /**
236
278
  * #property
237
279
  * currently displayed regions, can be a single chromosome, arbitrary
238
- * subsections, or the entire set of chromosomes in the genome, but it not
239
- * advised to use the entire set of chromosomes if your assembly is very
240
- * fragmented
280
+ * subsections, or the entire set of chromosomes in the genome. The entire
281
+ * set is not advised if your assembly is very fragmented
241
282
  */
242
283
  displayedRegions: types.stripDefault(types.frozen(), []),
243
284
  /**
@@ -264,14 +305,17 @@ export function stateModelFactory(pluginManager) {
264
305
  hideNoTracksActive: types.stripDefault(types.boolean, false),
265
306
  /**
266
307
  * #property
267
- * vestigial: the hierarchical selector is the only one that exists, so
268
- * this value is ignored. Retained because saved sessions and configs
269
- * persist it.
308
+ * ignored: the hierarchical selector is the only one. Declared because
309
+ * saved sessions and share links carry it.
310
+ * @deprecated
270
311
  */
271
- trackSelectorType: types.stripDefault(types.enumeration(['hierarchical']), 'hierarchical'),
312
+ trackSelectorType: types.stripDefault(types.string, 'hierarchical'),
272
313
  /**
273
314
  * #property
274
- * show the "center line"
315
+ * show the "center line". A personal preference: never in a session
316
+ * snapshot. It, showCytobands, trackLabels, colorByFrame, showAminoAcids
317
+ * and showTrackOutlines start from this browser's last choice, which
318
+ * their setters record.
275
319
  */
276
320
  showCenterLine: types.optional(types.boolean, () => localStorageGetBoolean('lgv-showCenterLine', false)),
277
321
  /**
@@ -284,34 +328,30 @@ export function stateModelFactory(pluginManager) {
284
328
  /**
285
329
  * #property
286
330
  * how to display the track labels, can be "overlapping", "offset", or
287
- * "hidden", or empty string "" (which results in the
288
- * LinearGenomeViewPlugin config default being used). the resolved value
289
- * is the `effectiveTrackLabels` getter. see LinearGenomeViewPlugin
290
- * https://jbrowse.org/jb2/docs/config/lineargenomeviewplugin/ docs for
291
- * how conf is used
292
- */
293
- trackLabels: types.optional(types.string, () => localStorageGetItem('lgv-trackLabels') ?? ''),
294
- /**
295
- * #property
296
- * show the "gridlines" in the track area
331
+ * "hidden", or empty string "" (which uses the LinearGenomeViewPlugin
332
+ * config default). The resolved value is the `effectiveTrackLabels`
333
+ * getter. See the LinearGenomeViewPlugin docs at
334
+ * https://jbrowse.org/jb2/docs/config/lineargenomeviewconfigschema/ for
335
+ * how the config is used
297
336
  */
298
- showGridlines: types.stripDefault(types.boolean, true),
337
+ trackLabels: types.optional(types.enumeration('ViewTrackLabels', ['', ...VIEW_TRACK_LABEL_MODES]), () => storedTrackLabels()),
299
338
  /**
300
339
  * #property
301
- * controls whether highlight/bookmark chip labels are shown inline
340
+ * The ruler's guide, a [](/docs/config/lineargenomeviewaxis) object:
341
+ * `{ grid: false }` stops its ticks running down through the tracks.
302
342
  */
303
- labelsVisible: types.stripDefault(types.boolean, true),
343
+ axis: types.stripDefault(linearGenomeViewAxisConfigSchema, {}),
304
344
  /**
305
345
  * #property
306
346
  * color CDS segments by reading frame
307
347
  */
308
- colorByCDS: types.optional(types.boolean, () => localStorageGetBoolean('lgv-colorByCDS', false)),
348
+ colorByFrame: types.optional(types.boolean, () => localStorageGetBoolean('lgv-colorByFrame', false)),
309
349
  /**
310
350
  * #property
311
351
  * draw translated codons on coding features once zoomed in far enough:
312
352
  * an alternating per-codon shading, and the amino acid letters on top of
313
- * it at base-level zoom. Independent of `colorByCDS`, which only recolors
314
- * the segments by frame.
353
+ * it at base-level zoom. Independent of `colorByFrame`, which only
354
+ * recolors the segments by frame.
315
355
  */
316
356
  showAminoAcids: types.optional(types.boolean, () => localStorageGetBoolean('lgv-showAminoAcids', true)),
317
357
  /**
@@ -335,36 +375,49 @@ export function stateModelFactory(pluginManager) {
335
375
  */
336
376
  launch: types.frozen(),
337
377
  }))
338
- .volatile(self => {
378
+ .volatile(() => {
339
379
  // typed locals so `unknown`/`Record` aren't narrowed to `undefined`/`{}`; inline
340
380
  // type assertions here get stripped by no-unnecessary-type-assertion
341
381
  const volatileError = undefined;
342
- const trackRefs = {};
343
382
  return {
344
383
  /**
345
384
  * #volatile
385
+ * Height of each track's in-flow label band, measured by
386
+ * TrackContainer as the rendering container's offset inside its Paper.
387
+ * Absent (0) while the label is hidden, overlapping, or not yet
388
+ * measured. Observable, so the offset and height getters re-derive
389
+ * when a band changes.
346
390
  */
347
- volatileWidth: undefined,
391
+ trackLabelBands: observable.map(),
348
392
  /**
349
393
  * #volatile
394
+ * How much taller the view's box is than the scroll port it sits in,
395
+ * in px, negative when it is shorter. Measured by the view component
396
+ * for a top-level view; undefined until then, and for a nested one.
350
397
  */
351
- minimumBlockWidth: 3,
398
+ scrollPortExcess: undefined,
399
+ /**
400
+ * #volatile
401
+ */
402
+ volatileWidth: undefined,
352
403
  /**
353
404
  * #volatile
354
405
  */
355
406
  draggingTrackId: undefined,
356
407
  /**
357
408
  * #volatile
409
+ * Set by a search whose hits name more than one place, until the user
410
+ * picks one or closes it.
358
411
  */
359
- lastTrackDragY: undefined,
412
+ searchPicker: undefined,
360
413
  /**
361
414
  * #volatile
362
415
  */
363
- volatileError,
416
+ lastTrackDragY: undefined,
364
417
  /**
365
418
  * #volatile
366
419
  */
367
- trackRefs,
420
+ volatileError,
368
421
  /**
369
422
  * #volatile
370
423
  */
@@ -376,30 +429,29 @@ export function stateModelFactory(pluginManager) {
376
429
  /**
377
430
  * #volatile
378
431
  */
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,
432
+ // A real scale rather than the not-yet-measured 0, for the canvas
433
+ // displays that lay out against it; `setWidth` seeds the true one at
434
+ // the first measure.
435
+ coarseBpPerPx: 1,
387
436
  /**
388
437
  * #volatile
438
+ * the scroll offset the coarse blocks were laid out at, in px at
439
+ * `coarseBpPerPx`: the settled viewport's left edge, which the first
440
+ * coarse block is not while the view is scrolled before the first region
389
441
  */
390
- leftOffset: undefined,
442
+ coarseOffsetPx: 0,
391
443
  /**
392
444
  * #volatile
393
445
  */
394
- rightOffset: undefined,
446
+ leftOffset: undefined,
395
447
  /**
396
448
  * #volatile
397
449
  */
398
- isScalebarRefNameMenuOpen: false,
450
+ rightOffset: undefined,
399
451
  /**
400
452
  * #volatile
401
453
  */
402
- scalebarRefNameClickPending: false,
454
+ isScalebarRefNameMenuOpen: false,
403
455
  /**
404
456
  * #volatile
405
457
  * temporary vertical guides that can be set by displays (e.g., LD display hover)
@@ -408,6 +460,13 @@ export function stateModelFactory(pluginManager) {
408
460
  };
409
461
  })
410
462
  .views(self => ({
463
+ /**
464
+ * #getter
465
+ * `axis.grid`: whether the ruler's ticks run down through the tracks.
466
+ */
467
+ get axisGrid() {
468
+ return readConfObject(self.axis, 'grid');
469
+ },
411
470
  // The viewport in pixels, derived from the window it frames — declared
412
471
  // ahead of every other getter so that the ~300 reads of `self.bpPerPx` /
413
472
  // `self.offsetPx` across the codebase go on resolving by name. Only the
@@ -444,6 +503,14 @@ export function stateModelFactory(pluginManager) {
444
503
  const { bpPerPx } = this;
445
504
  return bpPerPx <= 0 ? 0 : self.windowStartBp / bpPerPx;
446
505
  },
506
+ /**
507
+ * #getter
508
+ * a displayed region narrower than this many pixels draws as an elided
509
+ * block
510
+ */
511
+ get minimumBlockWidth() {
512
+ return MINIMUM_BLOCK_WIDTH;
513
+ },
447
514
  /**
448
515
  * #getter
449
516
  * scroll-to-zoom is a global, personal preference resolved from the
@@ -505,6 +572,14 @@ export function stateModelFactory(pluginManager) {
505
572
  const { assemblyManager } = getSession(self);
506
573
  return this.assemblyNames.map(a => assemblyManager.getDisplayName(a));
507
574
  },
575
+ /**
576
+ * #getter
577
+ * the session's highlights on this view's assemblies
578
+ */
579
+ get highlights() {
580
+ const { highlights, assemblyManager } = getSession(self);
581
+ return highlightsOnAssemblies(highlights, this.assemblyNames, assemblyManager);
582
+ },
508
583
  /**
509
584
  * #getter
510
585
  * checking if lgv is a 'top-level' view is used for toggling pin track
@@ -531,8 +606,23 @@ export function stateModelFactory(pluginManager) {
531
606
  .views(self => ({
532
607
  /**
533
608
  * #getter
534
- * Assembly-name prefix for the scalebar refName labels, or undefined for
535
- * none. A container view (e.g. LinearSyntenyView) opts its sub-views in by
609
+ * The census entry for this view: the tracks it holds itself. Declared
610
+ * rather than derived — see `BaseViewModel.ownTracks`.
611
+ */
612
+ get ownTracks() {
613
+ return [...self.tracks];
614
+ },
615
+ /**
616
+ * #getter
617
+ * See `BaseViewModel.rendersDisplays`.
618
+ */
619
+ get rendersDisplays() {
620
+ return !self.scalebarOnly;
621
+ },
622
+ /**
623
+ * #getter
624
+ * Assembly name captioning the scalebar ahead of its refName labels, or
625
+ * undefined for none. A container view (e.g. LinearSyntenyView) opts its sub-views in by
536
626
  * exposing showAssemblyNameInSubviewScalebar; duck-typed rather than
537
627
  * matching a concrete view type so no upward plugin dependency is needed
538
628
  * and any container can opt in. A wrong nesting depth simply yields no
@@ -579,10 +669,23 @@ export function stateModelFactory(pluginManager) {
579
669
  * and error checks resolve it directly through here.
580
670
  */
581
671
  get initAssembly() {
582
- return self.pendingLaunch
583
- ? getSession(self).assemblyManager.get(self.pendingLaunch.assembly)
672
+ const name = this.launchAssemblyName;
673
+ return self.pendingLaunch && name !== undefined
674
+ ? getSession(self).assemblyManager.get(name)
584
675
  : undefined;
585
676
  },
677
+ /**
678
+ * #getter
679
+ * The assembly a pending launch is for: the one it names, else the one
680
+ * the view already shows. A re-launch of a materialized view (a
681
+ * `jb.setSession` document writing `loc` beside the built state) need
682
+ * not restate it; without the default the readiness gate waited on
683
+ * `assemblyManager.get(undefined)` for good. Undefined on a fresh view
684
+ * whose blob names none, which `error` reports.
685
+ */
686
+ get launchAssemblyName() {
687
+ return self.pendingLaunch?.assembly ?? self.assemblyNames[0];
688
+ },
586
689
  /**
587
690
  * #getter
588
691
  */
@@ -616,35 +719,11 @@ export function stateModelFactory(pluginManager) {
616
719
  },
617
720
  /**
618
721
  * #getter
619
- * What the spinner says. The assembly reports which of its files it is
620
- * downloading, so this is "Downloading chromosome aliases" rather than a bare
621
- * "Loading" for the part of startup that actually takes time. Falls back
622
- * once the assembly is loaded and the remaining wait is the init autorun's
623
- * own navigation, which is local.
624
- */
625
- get loadingMessage() {
626
- return this.showLoading
627
- ? this.loadingAssembly?.statusMessage || 'Loading'
628
- : undefined;
629
- },
630
- /**
631
- * #getter
632
- * Determinate fraction for the spinner's bar, when the assembly load
633
- * reports one (a whole-file download with a Content-Length)
722
+ * The loading screen's text while `showLoading`, taken from the
723
+ * assembly being waited on; undefined otherwise.
634
724
  */
635
- get loadingProgress() {
636
- return this.showLoading
637
- ? this.loadingAssembly?.statusProgress
638
- : undefined;
639
- },
640
- /**
641
- * #getter
642
- * The URL the assembly load is currently fetching, when the phase named
643
- * one. Only the stalled-load notice reads it — see
644
- * {@link ViewLoadingScreen}.
645
- */
646
- get loadingSource() {
647
- return this.showLoading ? this.loadingAssembly?.statusSource : undefined;
725
+ get loading() {
726
+ return viewLoading(this.showLoading, () => this.loadingAssembly);
648
727
  },
649
728
  /**
650
729
  * #getter
@@ -695,12 +774,12 @@ export function stateModelFactory(pluginManager) {
695
774
  * `width`/`staticBlocks` throw by design before the view has been
696
775
  * measured and navigated.
697
776
  *
698
- * **Not `initialized`**, which is the trap this getter exists to close.
777
+ * Use this getter rather than `initialized`.
699
778
  * That one answers "have the assembly's regions loaded", which is only
700
779
  * the first of two async steps: navigating then populates
701
780
  * `displayedRegions`, and in the window between the two `initialized` is
702
781
  * already true while there is still nothing on screen. `showLoading`
703
- * folds in `awaitingInitNavigation`, the getter that exists for exactly
782
+ * folds in `awaitingInitNavigation`, the getter for exactly
704
783
  * that gap.
705
784
  *
706
785
  * `error` is the third outcome and is why this is not a bare
@@ -723,12 +802,7 @@ export function stateModelFactory(pluginManager) {
723
802
  return computeViewStatus({
724
803
  error: this.error,
725
804
  hasSomethingToShow: this.hasSomethingToShow,
726
- loading: () => this.showLoading
727
- ? {
728
- message: this.loadingMessage ?? 'Loading',
729
- progress: this.loadingProgress,
730
- }
731
- : undefined,
805
+ loading: () => this.loading,
732
806
  });
733
807
  },
734
808
  /**
@@ -758,14 +832,10 @@ export function stateModelFactory(pluginManager) {
758
832
  * #getter
759
833
  * A track's full cost beyond its display height.
760
834
  *
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.
835
+ * The track *label* is not counted here: an offset label is an in-flow
836
+ * box whose height is whatever the theme renders a Paper of icon buttons
837
+ * at, so it is measured into `trackLabelBands` per track and added by
838
+ * `trackLabelBand` rather than derived.
769
839
  */
770
840
  get trackChromeHeight() {
771
841
  return this.trackLeadingChrome + this.trackTrailingChrome;
@@ -784,28 +854,9 @@ export function stateModelFactory(pluginManager) {
784
854
  return HEADER_BAR_HEIGHT + HEADER_OVERVIEW_HEIGHT;
785
855
  }
786
856
  },
787
- /**
788
- * #getter
789
- * Where the scalebar pins when the view's chrome is sticky: everything
790
- * stacked above it, which is the view's own title bar plus this view's
791
- * header. Expressed from `headerHeight` rather than re-summing its
792
- * constants — the two are the same box, and a second spelling of the sum
793
- * is free to drift from the first while both typecheck.
794
- */
795
- get rubberbandTop() {
796
- return self.stickyViewHeaders
797
- ? VIEW_HEADER_HEIGHT + this.headerHeight
798
- : 0;
799
- },
800
- /**
801
- * #getter
802
- */
803
- get pinnedTracksTop() {
804
- return this.rubberbandTop + this.scalebarHeight;
805
- },
806
857
  /**
807
858
  * #method
808
- * rendered height of a single track, collapsing to a fixed height when
859
+ * rendered height of a single track, using a fixed height when
809
860
  * minimized. Shared by trackHeights and getTrackYOffset so the two can't
810
861
  * disagree. Reads `activeDisplay` — the display TrackContainer actually
811
862
  * mounts — rather than re-picking `displays[0]`, so the view's height math
@@ -816,6 +867,14 @@ export function stateModelFactory(pluginManager) {
816
867
  ? MINIMIZED_TRACK_HEIGHT
817
868
  : track.activeDisplay.height;
818
869
  },
870
+ /**
871
+ * #method
872
+ * the measured in-flow label band above a track's rendering container,
873
+ * 0 until TrackContainer has measured one or when no label is in flow
874
+ */
875
+ trackLabelBand(track) {
876
+ return self.trackLabelBands.get(track.configuration.trackId) ?? 0;
877
+ },
819
878
  /**
820
879
  * #getter
821
880
  */
@@ -826,7 +885,9 @@ export function stateModelFactory(pluginManager) {
826
885
  * #getter
827
886
  */
828
887
  get trackHeightsWithChrome() {
829
- return this.trackHeights + self.tracks.length * this.trackChromeHeight;
888
+ return (this.trackHeights +
889
+ self.tracks.length * this.trackChromeHeight +
890
+ sum(self.tracks.map(t => this.trackLabelBand(t))));
830
891
  },
831
892
  /**
832
893
  * #getter
@@ -835,7 +896,22 @@ export function stateModelFactory(pluginManager) {
835
896
  if (self.scalebarOnly) {
836
897
  return this.headerHeight + this.scalebarHeight;
837
898
  }
838
- return (this.trackHeightsWithChrome + this.headerHeight + this.scalebarHeight);
899
+ return (this.trackHeightsWithChrome +
900
+ this.headerHeight +
901
+ this.scalebarHeight +
902
+ (self.tracks.length && self.isTopLevelView
903
+ ? RESIZE_ALL_HANDLE_HEIGHT
904
+ : 0));
905
+ },
906
+ /**
907
+ * #getter
908
+ * The display height of each track a whole-stack resize moves, by track
909
+ * instance id: every shown track that is not minimized.
910
+ */
911
+ get resizableTrackHeights() {
912
+ return new Map(self.tracks
913
+ .filter(t => !t.minimized)
914
+ .map(t => [t.id, t.activeDisplay.height]));
839
915
  },
840
916
  /**
841
917
  * #method
@@ -844,20 +920,27 @@ export function stateModelFactory(pluginManager) {
844
920
  * first, then unpinned), from the same constants TrackContainer lays its
845
921
  * Paper out with. Returns `undefined` if the track is not present.
846
922
  *
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.
923
+ * Includes each track's measured label band, this track's own included,
924
+ * since an in-flow label sits above the rendering container inside the
925
+ * same Paper. Exact once TrackContainer has measured; before that it is
926
+ * short by the unmeasured bands.
851
927
  */
852
928
  getTrackYOffset(trackId) {
929
+ return this.trackYOffsets.get(trackId);
930
+ },
931
+ /**
932
+ * #getter
933
+ * `getTrackYOffset` for every shown track, by trackId, from one walk
934
+ */
935
+ get trackYOffsets() {
936
+ const offsets = new Map();
853
937
  let y = this.headerHeight + this.scalebarHeight + this.trackLeadingChrome;
854
938
  for (const t of [...self.pinnedTracks, ...self.unpinnedTracks]) {
855
- if (t.configuration.trackId === trackId) {
856
- return y;
857
- }
939
+ y += this.trackLabelBand(t);
940
+ offsets.set(t.configuration.trackId, y);
858
941
  y += this.trackHeight(t) + this.trackChromeHeight;
859
942
  }
860
- return undefined;
943
+ return offsets;
861
944
  },
862
945
  /**
863
946
  * #method
@@ -883,24 +966,22 @@ export function stateModelFactory(pluginManager) {
883
966
  if (this.totalBp === 0 || self.width === 0) {
884
967
  return 1;
885
968
  }
886
- // Floor at minBpPerPx so tiny displayed regions (totalBp small enough
887
- // that the fill-scaled ratio drops below MIN_BP_PER_PX) can't produce
888
- // maxBpPerPx < minBpPerPx, which would invert the zoom-slider bounds
889
- // and the clamp() range in zoomTo.
890
- return Math.max(MIN_BP_PER_PX, this.totalBp / (self.width * SHOW_ALL_REGIONS_FILL));
969
+ // floored so a tiny region set cannot put maxBpPerPx under
970
+ // minBpPerPx, which would invert the zoom slider and zoomTo's clamp
971
+ return Math.max(this.minBpPerPx, this.totalBp / (self.width * SHOW_ALL_REGIONS_FILL));
891
972
  },
892
973
  /**
893
974
  * #getter
894
- * The zoom-out limit. This view's own fit, except while a container holds
895
- * it on a shared scale coarser than that — a small genome next to a large
896
- * one, drawn short so the two compare by length.
975
+ * The zoom-out limit. Equal to the fit for this view's regions, except
976
+ * while a container holds it on a coarser shared scale, such as a small
977
+ * genome next to a large one, drawn short so the two compare by length.
897
978
  *
898
- * Raising the LIMIT is what makes such a scale survive: written past the
899
- * limit instead, it is undone by the first thing that clamps — a wheel
900
- * tick, a rubberband, a `setDisplayedRegions` — which is how the dotplot's
901
- * locked aspect ratio once turned "zoom out" into a zoom in
902
- * (`axisMaxBpPerPx`). Here every route to a zoom clamps against the same
903
- * ceiling, so full zoom-out LANDS on the shared scale.
979
+ * The shared scale raises the limit. A bpPerPx written past the limit
980
+ * would be undone by the first operation that clamps (a wheel tick, a
981
+ * rubberband, a `setDisplayedRegions`), as with the dotplot's locked
982
+ * aspect ratio turning "zoom out" into a zoom in (`axisMaxBpPerPx`).
983
+ * Every zoom clamps against the same ceiling, so full zoom-out stops at
984
+ * the shared scale.
904
985
  */
905
986
  get maxBpPerPx() {
906
987
  return Math.max(this.fitBpPerPx, this.sharedFitBpPerPx);
@@ -911,8 +992,8 @@ export function stateModelFactory(pluginManager) {
911
992
  * or 0 for a standalone view, a mode that is off, and a stack that cannot
912
993
  * answer yet.
913
994
  *
914
- * Pulled rather than pushed down into a volatile: the number is a
915
- * function of every row's fit, all of which move on a resize, and a
995
+ * A getter rather than a volatile the container writes: the number is a
996
+ * function of every row's fit, all of which change on a resize, and a
916
997
  * stored copy would be stale by the next layout.
917
998
  */
918
999
  get sharedFitBpPerPx() {
@@ -944,12 +1025,12 @@ export function stateModelFactory(pluginManager) {
944
1025
  // hand-authored JSON is what fills it, so the type is not a guarantee.
945
1026
  // Naming the authoring mistake beats the downstream symptom, which is
946
1027
  // the literal string "Assembly undefined not found".
947
- if (!self.pendingLaunch.assembly) {
1028
+ if (!this.launchAssemblyName) {
948
1029
  return 'LinearGenomeView init needs an "assembly"';
949
1030
  }
950
1031
  const asm = this.initAssembly;
951
1032
  if (!asm) {
952
- return `Assembly ${self.pendingLaunch.assembly} not found`;
1033
+ return `Assembly ${this.launchAssemblyName} not found`;
953
1034
  }
954
1035
  if (asm.error) {
955
1036
  return asm.error;
@@ -961,15 +1042,13 @@ export function stateModelFactory(pluginManager) {
961
1042
  * #getter
962
1043
  */
963
1044
  get maxOffset() {
964
- // objectively determined to keep the linear genome on the main screen
965
- return this.displayedRegionsTotalPx - MAX_OFFSET_PADDING_PX;
1045
+ return offsetBounds(this.displayedRegionsTotalPx, self.width).max;
966
1046
  },
967
1047
  /**
968
1048
  * #getter
969
1049
  */
970
1050
  get minOffset() {
971
- // objectively determined to keep the linear genome on the main screen
972
- return -self.width + MIN_OFFSET_PADDING_PX;
1051
+ return offsetBounds(this.displayedRegionsTotalPx, self.width).min;
973
1052
  },
974
1053
  /**
975
1054
  * #getter
@@ -1014,6 +1093,15 @@ export function stateModelFactory(pluginManager) {
1014
1093
  getActiveDisplayId(trackId) {
1015
1094
  return this.getTrack(trackId)?.activeDisplay.configuration.displayId;
1016
1095
  },
1096
+ }))
1097
+ .actions(self => ({
1098
+ /**
1099
+ * #action
1100
+ * Dismiss the search picker without choosing a place.
1101
+ */
1102
+ closeSearchPicker() {
1103
+ self.searchPicker = undefined;
1104
+ },
1017
1105
  }))
1018
1106
  .actions(self => ({
1019
1107
  /**
@@ -1021,6 +1109,7 @@ export function stateModelFactory(pluginManager) {
1021
1109
  */
1022
1110
  setShowTrackOutlines(arg) {
1023
1111
  self.showTrackOutlines = arg;
1112
+ localStorageSetBoolean('lgv-showTrackOutlines', arg);
1024
1113
  },
1025
1114
  /**
1026
1115
  * #action
@@ -1031,38 +1120,69 @@ export function stateModelFactory(pluginManager) {
1031
1120
  /**
1032
1121
  * #action
1033
1122
  */
1034
- setColorByCDS(flag) {
1035
- self.colorByCDS = flag;
1123
+ setColorByFrame(flag) {
1124
+ self.colorByFrame = flag;
1125
+ localStorageSetBoolean('lgv-colorByFrame', flag);
1036
1126
  },
1037
1127
  /**
1038
1128
  * #action
1039
1129
  */
1040
1130
  setShowAminoAcids(flag) {
1041
1131
  self.showAminoAcids = flag;
1132
+ localStorageSetBoolean('lgv-showAminoAcids', flag);
1042
1133
  },
1043
1134
  /**
1044
1135
  * #action
1045
1136
  */
1046
1137
  setShowCytobands(flag) {
1047
1138
  self.showCytobands = flag;
1139
+ localStorageSetBoolean('lgv-showCytobands', flag);
1140
+ },
1141
+ /**
1142
+ * #action
1143
+ */
1144
+ setTrackLabelBand(trackId, band) {
1145
+ if (band === 0) {
1146
+ self.trackLabelBands.delete(trackId);
1147
+ }
1148
+ else {
1149
+ self.trackLabelBands.set(trackId, band);
1150
+ }
1151
+ },
1152
+ /**
1153
+ * #action
1154
+ */
1155
+ setScrollPortExcess(px) {
1156
+ self.scrollPortExcess = px;
1157
+ },
1158
+ /**
1159
+ * #action
1160
+ * Grow or shrink every unminimized track by `distance` px in all, each
1161
+ * by its share of the height it has above the display minimum. `from` is
1162
+ * the heights to scale, by track instance id; a drag passes the heights
1163
+ * it started with, so whole-pixel rounding does not compound frame by
1164
+ * frame. Returns how far the stack moved.
1165
+ */
1166
+ resizeTracks(distance, from = self.resizableTrackHeights) {
1167
+ const tracks = self.tracks.filter(t => !t.minimized && from.has(t.id));
1168
+ const start = tracks.map(t => from.get(t.id));
1169
+ const next = scaleBandHeights(start, distance, MIN_DISPLAY_HEIGHT);
1170
+ for (const [i, track] of tracks.entries()) {
1171
+ const display = track.activeDisplay;
1172
+ const delta = next[i] - display.height;
1173
+ if (delta !== 0) {
1174
+ display.resizeHeight(delta);
1175
+ }
1176
+ }
1177
+ return sum(next) - sum(start);
1048
1178
  },
1049
1179
  /**
1050
1180
  * #action
1181
+ * A resize keeps the window in bp, so `bpPerPx` follows the new width
1182
+ * and the view keeps framing the same sequence (ADR-070).
1051
1183
  */
1052
- // A resize needs no arithmetic: the window is what is stored, so a new
1053
- // width simply divides into it and `bpPerPx` follows. The view keeps the
1054
- // sequence it was framing rather than letting the right edge eat into it.
1055
- //
1056
- // Keeping the scale instead — which is what storing pixels amounted to —
1057
- // was a block-cache optimization: block boundaries are
1058
- // `blockNum * ceil(800 * bpPerPx)` and block keys embed start/end, so
1059
- // holding bpPerPx across a resize kept every block key, and so every
1060
- // fetched region, identical. Rescaling makes a resize a zoom, and a zoom
1061
- // is already a solved case: FetchVisibleRegions' 300ms debounce coalesces
1062
- // the gesture into one refetch, its in-flight guard caps concurrent
1063
- // batches at one, and rpcDataMap is overwritten in place rather than
1064
- // cleared, so nothing blanks (ADR-008, ADR-006).
1065
1184
  setWidth(newWidth) {
1185
+ const unmeasured = self.bpPerPx <= 0;
1066
1186
  self.volatileWidth = newWidth;
1067
1187
  if (newWidth > 0 && self.windowWidthBp <= 0) {
1068
1188
  // First measure, and nothing has established a window yet: a migrated
@@ -1073,6 +1193,12 @@ export function stateModelFactory(pluginManager) {
1073
1193
  self.windowWidthBp = (self.legacyBpPerPx || 1) * newWidth;
1074
1194
  self.legacyBpPerPx = 0;
1075
1195
  }
1196
+ if (unmeasured && self.bpPerPx > 0) {
1197
+ // The debounced coarse autorun is 500ms out; a restored snapshot's
1198
+ // consumers would lay out and fetch at 1 bp/px until then.
1199
+ self.coarseBpPerPx = self.bpPerPx;
1200
+ self.coarseOffsetPx = self.offsetPx;
1201
+ }
1076
1202
  },
1077
1203
  /**
1078
1204
  * #action
@@ -1086,12 +1212,6 @@ export function stateModelFactory(pluginManager) {
1086
1212
  setIsScalebarRefNameMenuOpen(isOpen) {
1087
1213
  self.isScalebarRefNameMenuOpen = isOpen;
1088
1214
  },
1089
- /**
1090
- * #action
1091
- */
1092
- setScalebarRefNameClickPending(pending) {
1093
- self.scalebarRefNameClickPending = pending;
1094
- },
1095
1215
  /**
1096
1216
  * #action
1097
1217
  */
@@ -1119,14 +1239,8 @@ export function stateModelFactory(pluginManager) {
1119
1239
  /**
1120
1240
  * #action
1121
1241
  */
1122
- setShowGridlines(b) {
1123
- self.showGridlines = b;
1124
- },
1125
- /**
1126
- * #action
1127
- */
1128
- setLabelsVisible(arg) {
1129
- self.labelsVisible = arg;
1242
+ setAxisGrid(grid) {
1243
+ self.axis = cast({ ...getSnapshot(self.axis), grid });
1130
1244
  },
1131
1245
  /**
1132
1246
  * #action
@@ -1200,18 +1314,49 @@ export function stateModelFactory(pluginManager) {
1200
1314
  },
1201
1315
  /**
1202
1316
  * #action
1203
- */
1204
- setSearchResults(searchResults, searchQuery, assemblyName) {
1205
- getDialogHost(self).queueDialog(handleClose => [
1206
- SearchResultsDialog,
1207
- {
1208
- model: self,
1209
- searchResults,
1210
- searchQuery,
1211
- handleClose,
1212
- assemblyName,
1317
+ * Raise the picker over search results that name more than one place.
1318
+ * @param onPick - where a picked result lands; by default it navigates
1319
+ * there and shows the track its index came from
1320
+ */
1321
+ setSearchResults(searchResults, searchQuery, assemblyName, onPick = result => navToOption({
1322
+ option: result,
1323
+ model: self,
1324
+ assemblyName,
1325
+ })) {
1326
+ const hits = searchHitsOf(self, searchResults, searchQuery, assemblyName, onPick);
1327
+ const picker = {
1328
+ ...hits,
1329
+ pick: async (id) => {
1330
+ await hits.pick(id);
1331
+ if (isAlive(self) && self.searchPicker === picker) {
1332
+ self.closeSearchPicker();
1333
+ }
1213
1334
  },
1214
- ]);
1335
+ };
1336
+ self.searchPicker = picker;
1337
+ },
1338
+ /**
1339
+ * #action
1340
+ * Search the view's assembly by name, for a host listing the hits
1341
+ * itself. A row's `pick` lands as the search box does: it navigates
1342
+ * there and shows the track the hit's index came from.
1343
+ */
1344
+ async search(query, opts) {
1345
+ const assemblyName = self.assemblyNames[0] ?? self.launchAssemblyName;
1346
+ if (!assemblyName) {
1347
+ throw new Error('the view has no assembly to search yet');
1348
+ }
1349
+ const results = await fetchResults({
1350
+ session: getSession(self),
1351
+ queryString: query,
1352
+ assemblyName,
1353
+ signal: opts?.signal,
1354
+ });
1355
+ return searchHitsOf(self, results, query, assemblyName, result => navToOption({
1356
+ option: result,
1357
+ model: self,
1358
+ assemblyName,
1359
+ }));
1215
1360
  },
1216
1361
  /**
1217
1362
  * #action
@@ -1227,6 +1372,16 @@ export function stateModelFactory(pluginManager) {
1227
1372
  },
1228
1373
  }))
1229
1374
  .actions(self => ({
1375
+ /**
1376
+ * #action
1377
+ * Resize every unminimized track so the view is exactly as tall as the
1378
+ * scroll port it sits in. Does nothing until the view has measured one.
1379
+ */
1380
+ fitTracksToWindow() {
1381
+ if (self.scrollPortExcess) {
1382
+ self.resizeTracks(-self.scrollPortExcess);
1383
+ }
1384
+ },
1230
1385
  /**
1231
1386
  * #action
1232
1387
  * showTrack for a track whose display state model may be lazily
@@ -1235,68 +1390,108 @@ export function stateModelFactory(pluginManager) {
1235
1390
  async launchTrack(trackId, initialSnapshot = {}, displayInitialSnapshot = {}, inlineConf) {
1236
1391
  return launchTrackGeneric(self, trackId, initialSnapshot, displayInitialSnapshot, inlineConf);
1237
1392
  },
1238
- }))
1239
- .actions(self => ({
1240
1393
  /**
1241
1394
  * #action
1395
+ * Replace the highlights `highlights` reads, each written as the launch
1396
+ * spec's `highlight` writes one: a locstring, or an object whose
1397
+ * `assemblyName` defaults to the view's. Throws on one that names no
1398
+ * region, writing none.
1242
1399
  */
1243
- moveTrackDown(id) {
1244
- const section = self.trackSection(id);
1245
- const idx = section.findIndex(t => t.id === id);
1246
- if (idx !== -1 && idx < section.length - 1) {
1247
- this.moveTrack(id, section[idx + 1].id);
1248
- }
1400
+ setHighlights(highlights) {
1401
+ const session = getSession(self);
1402
+ const assemblyName = self.assemblyNames[0] ?? self.launchAssemblyName ?? '';
1403
+ session.setHighlights(highlights.map(h => {
1404
+ const highlight = coerceHighlight(h, assemblyName, (ref, asm) => session.assemblyManager.isValidRefName(ref, asm ?? assemblyName));
1405
+ if (!highlight) {
1406
+ throw new Error(`not a region to highlight: ${JSON.stringify(h)}`);
1407
+ }
1408
+ return highlight;
1409
+ }));
1249
1410
  },
1250
1411
  /**
1251
1412
  * #action
1413
+ * Show files from the user's disk as session tracks: one per data file,
1414
+ * beside the index among them that names it, its type guessed from its
1415
+ * name. Adds nothing when one file has no known type. Resolves the new
1416
+ * tracks' ids.
1252
1417
  */
1253
- moveTrackUp(id) {
1254
- const section = self.trackSection(id);
1255
- const idx = section.findIndex(t => t.id === id);
1256
- if (idx > 0) {
1257
- this.moveTrack(id, section[idx - 1].id);
1258
- }
1418
+ async showFiles(files) {
1419
+ return showFiles(self, files);
1259
1420
  },
1421
+ }))
1422
+ .views(self => ({
1260
1423
  /**
1261
- * #action
1424
+ * #method
1425
+ * The shown track a move action means: the menu passes a track model's
1426
+ * own `id`, an agent passes the `trackId` it knows the track by, and
1427
+ * both name one shown track. Answers the model id, or the argument
1428
+ * unchanged when nothing shown matches, so the move reports it.
1262
1429
  */
1263
- moveTrackToTop(id) {
1264
- const section = self.trackSection(id);
1265
- if (section.length && section[0].id !== id) {
1266
- this.moveTrack(id, section[0].id);
1267
- }
1430
+ trackModelId(idOrTrackId) {
1431
+ return ((self.tracks.find(t => t.id === idOrTrackId) ??
1432
+ self.tracks.find(t => t.configuration.trackId === idOrTrackId))?.id ?? idOrTrackId);
1268
1433
  },
1269
- /**
1270
- * #action
1271
- */
1272
- moveTrackToBottom(id) {
1273
- const section = self.trackSection(id);
1274
- const last = section[section.length - 1];
1275
- if (last && last.id !== id) {
1276
- this.moveTrack(id, last.id);
1434
+ }))
1435
+ .actions(self => {
1436
+ function shownIndex(idOrTrackId) {
1437
+ const id = self.trackModelId(idOrTrackId);
1438
+ const index = self.tracks.findIndex(track => track.id === id);
1439
+ if (index === -1) {
1440
+ 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'}`);
1277
1441
  }
1278
- },
1442
+ return index;
1443
+ }
1279
1444
  /**
1280
1445
  * #action
1281
- */
1282
- moveTrack(movingId, targetId) {
1283
- const oldIndex = self.tracks.findIndex(track => track.id === movingId);
1284
- if (oldIndex === -1) {
1285
- throw new Error(`Track ID ${movingId} not found`);
1286
- }
1287
- const newIndex = self.tracks.findIndex(track => track.id === targetId);
1288
- if (newIndex === -1) {
1289
- throw new Error(`Track ID ${targetId} not found`);
1290
- }
1291
- // direction-aware placement: filtering out oldIndex shifts the target
1292
- // left by one when dragging down (oldIndex < newIndex), so splicing at
1293
- // newIndex lands the track *after* the target; dragging up leaves the
1294
- // target's index intact, landing *before* it. This matches the side
1295
- // the dragged track approaches from.
1446
+ * Move one shown track to another's position; each is a track model id
1447
+ * or a trackId.
1448
+ */
1449
+ function moveTrack(moving, target) {
1450
+ const oldIndex = shownIndex(moving);
1451
+ const newIndex = shownIndex(target);
1452
+ // splicing at newIndex after removing oldIndex lands a track dragged
1453
+ // down after its target and one dragged up before it
1296
1454
  const tracks = self.tracks.filter((_, idx) => idx !== oldIndex);
1297
1455
  tracks.splice(newIndex, 0, self.tracks[oldIndex]);
1298
1456
  self.tracks = cast(tracks);
1299
- },
1457
+ }
1458
+ function moveWithinSection(idOrTrackId, pick) {
1459
+ const moving = self.tracks[shownIndex(idOrTrackId)];
1460
+ const section = self.trackSection(moving.id);
1461
+ const target = pick(section, section.indexOf(moving));
1462
+ if (target && target !== moving) {
1463
+ moveTrack(moving.id, target.id);
1464
+ }
1465
+ }
1466
+ return {
1467
+ moveTrack,
1468
+ /**
1469
+ * #action
1470
+ */
1471
+ moveTrackDown(idOrTrackId) {
1472
+ moveWithinSection(idOrTrackId, (section, i) => section[i + 1]);
1473
+ },
1474
+ /**
1475
+ * #action
1476
+ */
1477
+ moveTrackUp(idOrTrackId) {
1478
+ moveWithinSection(idOrTrackId, (section, i) => section[i - 1]);
1479
+ },
1480
+ /**
1481
+ * #action
1482
+ */
1483
+ moveTrackToTop(idOrTrackId) {
1484
+ moveWithinSection(idOrTrackId, section => section[0]);
1485
+ },
1486
+ /**
1487
+ * #action
1488
+ */
1489
+ moveTrackToBottom(idOrTrackId) {
1490
+ moveWithinSection(idOrTrackId, section => section.at(-1));
1491
+ },
1492
+ };
1493
+ })
1494
+ .actions(self => ({
1300
1495
  /**
1301
1496
  * #action
1302
1497
  */
@@ -1315,12 +1510,14 @@ export function stateModelFactory(pluginManager) {
1315
1510
  */
1316
1511
  setTrackLabels(setting) {
1317
1512
  self.trackLabels = setting;
1513
+ localStorageSetItem('lgv-trackLabels', setting);
1318
1514
  },
1319
1515
  /**
1320
1516
  * #action
1321
1517
  */
1322
1518
  setShowCenterLine(b) {
1323
1519
  self.showCenterLine = b;
1520
+ localStorageSetBoolean('lgv-showCenterLine', b);
1324
1521
  },
1325
1522
  /**
1326
1523
  * #action
@@ -1334,8 +1531,9 @@ export function stateModelFactory(pluginManager) {
1334
1531
  * #action
1335
1532
  */
1336
1533
  toggleTrackSelector() {
1534
+ const wasVisible = isTrackSelectorVisible(self);
1337
1535
  const { session, selector } = openTrackSelectorWidget(self);
1338
- if (isTrackSelectorVisible(self)) {
1536
+ if (wasVisible) {
1339
1537
  session.hideWidget(selector);
1340
1538
  }
1341
1539
  else {
@@ -1343,39 +1541,6 @@ export function stateModelFactory(pluginManager) {
1343
1541
  }
1344
1542
  return selector;
1345
1543
  },
1346
- /**
1347
- * #method
1348
- * Helper method for the fetchSequence.
1349
- * Retrieves the corresponding regions that were selected by the
1350
- * rubberband
1351
- *
1352
- * @param leftOffset - `object as {start, end, index, offset}`, offset = start
1353
- * of user drag
1354
- * @param rightOffset - `object as {start, end, index, offset}`,
1355
- * offset = end of user drag
1356
- * @returns array of Region[]
1357
- */
1358
- getSelectedRegions(leftOffset, rightOffset) {
1359
- if (!leftOffset || !rightOffset) {
1360
- return [];
1361
- }
1362
- const layout = {
1363
- displayedRegions: self.displayedRegions,
1364
- bpPerPx: self.bpPerPx,
1365
- offsetPx: self.offsetPx,
1366
- width: self.width,
1367
- minimumBlockWidth: self.minimumBlockWidth,
1368
- };
1369
- const { bpPerPx, offsetPx: rawOffsetPx } = computeMoveToLayout(layout, leftOffset, rightOffset);
1370
- // mirror Base1DView.scrollTo clamping: raw offsetPx can be far outside
1371
- // the valid range when both offsets are oob on the same side
1372
- const offsetPx = clamp(rawOffsetPx, self.minOffset, self.totalBp / bpPerPx - MAX_OFFSET_PADDING_PX);
1373
- return wholeBaseRegions(calculateDynamicBlocks({
1374
- ...layout,
1375
- bpPerPx,
1376
- offsetPx,
1377
- }).contentBlocks);
1378
- },
1379
1544
  /**
1380
1545
  * #action
1381
1546
  */
@@ -1394,12 +1559,6 @@ export function stateModelFactory(pluginManager) {
1394
1559
  self.lastTrackDragY = undefined;
1395
1560
  }
1396
1561
  },
1397
- /**
1398
- * #action
1399
- */
1400
- setLastTrackDragY(y) {
1401
- self.lastTrackDragY = y;
1402
- },
1403
1562
  /**
1404
1563
  * #action
1405
1564
  * called while dragging a track over the track at `targetId`; reorders
@@ -1415,7 +1574,7 @@ export function stateModelFactory(pluginManager) {
1415
1574
  const movingDown = targetIdx > draggingIdx;
1416
1575
  if (shouldSwapTracks(self.lastTrackDragY, currentY, movingDown)) {
1417
1576
  self.lastTrackDragY = currentY;
1418
- this.moveTrack(draggingTrackId, targetId);
1577
+ self.moveTrack(draggingTrackId, targetId);
1419
1578
  }
1420
1579
  }
1421
1580
  }
@@ -1426,15 +1585,41 @@ export function stateModelFactory(pluginManager) {
1426
1585
  setLaunch(launch) {
1427
1586
  self.launch = launch;
1428
1587
  },
1588
+ }))
1589
+ .views(self => ({
1429
1590
  /**
1430
1591
  * #method
1431
- * creates an svg export and save using FileSaver
1592
+ * Helper method for the fetchSequence.
1593
+ * Returns the regions the user selected with the
1594
+ * rubberband
1595
+ *
1596
+ * @param leftOffset - `object as {start, end, index, offset}`, offset = start
1597
+ * of user drag
1598
+ * @param rightOffset - `object as {start, end, index, offset}`,
1599
+ * offset = end of user drag
1600
+ * @returns array of Region[]
1432
1601
  */
1433
- async exportSvg(opts = {}) {
1434
- const { renderToSvg } = await import('./svgcomponents/SVGLinearGenomeView.js');
1435
- const html = await renderToSvg(self, opts);
1436
- const { saveSvgAsImage } = await import('@jbrowse/core/svg/saveSvgAsImage');
1437
- await saveSvgAsImage(html, opts);
1602
+ getSelectedRegions(leftOffset, rightOffset) {
1603
+ if (!leftOffset || !rightOffset) {
1604
+ return [];
1605
+ }
1606
+ const layout = {
1607
+ displayedRegions: self.displayedRegions,
1608
+ bpPerPx: self.bpPerPx,
1609
+ offsetPx: self.offsetPx,
1610
+ width: self.width,
1611
+ minimumBlockWidth: self.minimumBlockWidth,
1612
+ };
1613
+ const { bpPerPx, offsetPx: rawOffsetPx } = computeMoveToLayout(layout, leftOffset, rightOffset);
1614
+ // mirror Base1DView.scrollTo clamping: raw offsetPx can be far outside
1615
+ // the valid range when both offsets are oob on the same side
1616
+ const { min, max } = offsetBounds(self.totalBp / bpPerPx, self.width);
1617
+ const offsetPx = clamp(rawOffsetPx, min, max);
1618
+ return wholeBaseRegions(calculateDynamicBlocks({
1619
+ ...layout,
1620
+ bpPerPx,
1621
+ offsetPx,
1622
+ }).contentBlocks);
1438
1623
  },
1439
1624
  }))
1440
1625
  .actions(self => {
@@ -1491,8 +1676,8 @@ export function stateModelFactory(pluginManager) {
1491
1676
  }
1492
1677
  /**
1493
1678
  * #action
1494
- * cancel an in-flight animated zoom. The animation already yields to any
1495
- * other zoom on its own, so this is for stopping it *without* changing the
1679
+ * cancel an in-flight animated zoom. Any other zoom already stops the
1680
+ * animation, so this is for stopping it *without* changing the
1496
1681
  * zoom — the slider grabbing the thumb, before it has a value to commit.
1497
1682
  */
1498
1683
  function cancelZoomAnimation() {
@@ -1520,25 +1705,26 @@ export function stateModelFactory(pluginManager) {
1520
1705
  * #getter
1521
1706
  * an ideogram only reads correctly against an entire chromosome: on a
1522
1707
  * sub-region it is a meaningless slice of bands, and the centromere shows
1523
- * up as a lone half-triangle
1708
+ * up as a lone half-triangle. The chromosome also needs bands: a cytoband
1709
+ * file often lists only the main chromosomes, not chrM or the
1710
+ * unplaced scaffolds
1524
1711
  */
1525
1712
  get canShowCytobands() {
1526
- return this.showsWholeChromosome && this.anyCytobandsExist;
1713
+ const region = self.displayedRegions[0];
1714
+ return (this.showsWholeChromosome &&
1715
+ !!region &&
1716
+ getCytobands(getSession(self).assemblyManager.get(region.assemblyName), region.refName).length > 0);
1527
1717
  },
1528
1718
  /**
1529
1719
  * #getter
1530
1720
  * the `showCytobands` setting gated by whether cytobands can be shown at
1531
- * all (whole chromosome + data present) — i.e. actually on screen
1721
+ * all (whole chromosome with bands in the cytoband file) and by the header
1722
+ * overview they draw in being on, which the SVG export follows too
1532
1723
  */
1533
1724
  get effectiveShowCytobands() {
1534
- return this.canShowCytobands && self.showCytobands;
1535
- },
1536
- /**
1537
- * #getter
1538
- */
1539
- get anyCytobandsExist() {
1540
- const { assemblyManager } = getSession(self);
1541
- return self.assemblyNames.some(a => assemblyManager.get(a)?.cytobands?.length);
1725
+ return (this.canShowCytobands &&
1726
+ self.showCytobands &&
1727
+ !self.hideHeaderOverview);
1542
1728
  },
1543
1729
  /**
1544
1730
  * #getter
@@ -1565,6 +1751,13 @@ export function stateModelFactory(pluginManager) {
1565
1751
  menuItems() {
1566
1752
  return buildMenuItems(self);
1567
1753
  },
1754
+ /**
1755
+ * #method
1756
+ * the view menu's Highlights submenu; extend this to add a row there
1757
+ */
1758
+ highlightsSubMenuItems() {
1759
+ return buildHighlightsSubMenuItems(self);
1760
+ },
1568
1761
  /**
1569
1762
  * #method
1570
1763
  * what a plugin can start from the selected region — a synteny view, a
@@ -1595,7 +1788,6 @@ export function stateModelFactory(pluginManager) {
1595
1788
  let coverageRightPx = 0;
1596
1789
  let prevBpPerPx;
1597
1790
  let prevWidth;
1598
- let prevMinimumBlockWidth;
1599
1791
  let prevDisplayedRegions;
1600
1792
  return {
1601
1793
  /**
@@ -1607,16 +1799,13 @@ export function stateModelFactory(pluginManager) {
1607
1799
  * blocks to render their data for the region represented by the block
1608
1800
  */
1609
1801
  get staticBlocks() {
1610
- const { offsetPx, bpPerPx, width, minimumBlockWidth, displayedRegions, } = self;
1802
+ const { offsetPx, bpPerPx, width, displayedRegions } = self;
1611
1803
  // Fast path: if only offsetPx changed and viewport is still within
1612
1804
  // the coverage range of existing blocks, skip the expensive
1613
- // calculateStaticBlocks call entirely. minimumBlockWidth is read here
1614
- // (not just in calculateStaticBlocks) so MobX still invalidates this
1615
- // computed if it changes while the viewport stays within coverage.
1805
+ // calculateStaticBlocks call entirely.
1616
1806
  if (currentlyCalculatedStaticBlocks !== undefined &&
1617
1807
  bpPerPx === prevBpPerPx &&
1618
1808
  width === prevWidth &&
1619
- minimumBlockWidth === prevMinimumBlockWidth &&
1620
1809
  displayedRegions === prevDisplayedRegions &&
1621
1810
  offsetPx >= coverageLeftPx &&
1622
1811
  offsetPx + width <= coverageRightPx) {
@@ -1625,16 +1814,13 @@ export function stateModelFactory(pluginManager) {
1625
1814
  const newBlocks = calculateStaticBlocks(self);
1626
1815
  // Hand back the previous BlockSet when the recompute produced the same
1627
1816
  // blocks, so a sideways scroll past the coverage edge doesn't re-render
1628
- // every track. minimumBlockWidth is part of the guard because block
1629
- // keys don't encode ContentBlock-vs-ElidedBlock, so a region can flip
1630
- // type with an unchanged key. Geometry is compared first: on a zoom it
1631
- // always differs, which skips the key walk entirely — the whole-genome
1632
- // view has one block per refName, so joining those keys into a string
1633
- // (as this used to) allocated an array and a string per zoom frame.
1817
+ // every track. Geometry is compared first: on a zoom it always
1818
+ // differs, which skips the key walk entirely — the whole-genome view
1819
+ // has one block per refName, so joining those keys into a string (as
1820
+ // this used to) allocated an array and a string per zoom frame.
1634
1821
  if (currentlyCalculatedStaticBlocks === undefined ||
1635
1822
  bpPerPx !== prevBpPerPx ||
1636
1823
  width !== prevWidth ||
1637
- minimumBlockWidth !== prevMinimumBlockWidth ||
1638
1824
  !sameBlockKeys(currentlyCalculatedStaticBlocks, newBlocks)) {
1639
1825
  currentlyCalculatedStaticBlocks = newBlocks;
1640
1826
  }
@@ -1650,16 +1836,14 @@ export function stateModelFactory(pluginManager) {
1650
1836
  }
1651
1837
  prevBpPerPx = bpPerPx;
1652
1838
  prevWidth = width;
1653
- prevMinimumBlockWidth = minimumBlockWidth;
1654
1839
  prevDisplayedRegions = displayedRegions;
1655
1840
  return currentlyCalculatedStaticBlocks;
1656
1841
  },
1657
1842
  /**
1658
1843
  * #getter
1659
1844
  * dynamic blocks represent the exact coordinates of the currently
1660
- * visible genome regions on the screen. they are similar to static
1661
- * blocks, but static blocks can go offscreen while dynamic blocks
1662
- * represent exactly what is on screen
1845
+ * visible genome regions on the screen. Static blocks can go
1846
+ * offscreen, while dynamic blocks represent exactly what is on screen
1663
1847
  */
1664
1848
  get dynamicBlocks() {
1665
1849
  return calculateDynamicBlocks(self);
@@ -1701,12 +1885,10 @@ export function stateModelFactory(pluginManager) {
1701
1885
  * The frame exists because those three are laid out across every
1702
1886
  * displayed region rather than across the viewport — it overhangs on
1703
1887
  * both sides — so their `x` values are stable under a scroll and only
1704
- * this number moves. `scalebarRefNameLabels` is the exception and says
1705
- * so: its `transform` is already a screen x, because a sticky label's
1888
+ * this number moves. `scalebarRefNameLabels` is the exception: its `transform` is already a screen x, because a sticky label's
1706
1889
  * position is a function of the scroll rather than of block geometry.
1707
1890
  *
1708
- * **The subtraction must happen here, in float64, and that is the
1709
- * load-bearing part.** `offsetPx` is a whole-genome pixel coordinate —
1891
+ * **The subtraction must happen here, in float64.** `offsetPx` is a whole-genome pixel coordinate —
1710
1892
  * hg38 chr1 at base resolution is already past 1e10 — and a length that
1711
1893
  * size does not survive the trip through CSS: the transform matrix is
1712
1894
  * float32 by the time it reaches the compositor, where consecutive
@@ -1718,13 +1900,12 @@ export function stateModelFactory(pluginManager) {
1718
1900
  * assemblies at high zoom, which is not where anyone tests. Both
1719
1901
  * operands here are large and their difference is bounded by the
1720
1902
  * overhang (a block or two), so what reaches CSS is small and exact.
1721
- * Same rule as the GPU side, one layer up —
1903
+ * This applies the GPU side's rule one layer up; see
1722
1904
  * `agent-docs/reference/BP_PRECISION.md`.
1723
1905
  *
1724
1906
  * Published because it is the one piece of coordinate arithmetic a host
1725
1907
  * drawing its own chrome would otherwise have to know, and because
1726
- * having it written out at each call site is how the two in-tree copies
1727
- * came to disagree about rounding. Round it where the content is text
1908
+ * copies written out at each call site disagree about rounding. Round it where the content is text
1728
1909
  * (a fractional offset blurs a label); leave it alone for paths and
1729
1910
  * boxes.
1730
1911
  */
@@ -1739,21 +1920,12 @@ export function stateModelFactory(pluginManager) {
1739
1920
  * than recomputing the makeTicks loop per component.
1740
1921
  */
1741
1922
  get gridlineTicks() {
1742
- const { bpPerPx } = self;
1743
1923
  const { blocks, offsetPx: firstBlockOffset } = this.staticBlocks;
1744
- const ticks = [];
1745
- // iterate merged region runs (like scalebarLabels) so a tick on an
1746
- // internal ~800px chunk boundary isn't emitted twice (once as a
1747
- // chunk's right edge, once as the next chunk's left edge)
1748
- for (const run of groupContiguousBlocks(blocks)) {
1749
- const runLeft = run.offsetPx - firstBlockOffset;
1750
- for (const { type, x } of makeBlockTicks(run, bpPerPx)) {
1751
- if (x >= 0 && x <= run.widthPx) {
1752
- ticks.push({ x: runLeft + x, major: type === 'major' });
1753
- }
1754
- }
1755
- }
1756
- return ticks;
1924
+ return getGridlineTicks({
1925
+ blocks,
1926
+ firstBlockOffset,
1927
+ bpPerPx: self.bpPerPx,
1928
+ });
1757
1929
  },
1758
1930
  /**
1759
1931
  * #getter
@@ -1771,33 +1943,13 @@ export function stateModelFactory(pluginManager) {
1771
1943
  * reads as the same number repeated across the row.
1772
1944
  */
1773
1945
  get scalebarLabels() {
1774
- const { bpPerPx } = self;
1775
1946
  const { blocks, offsetPx: firstBlockOffset } = this.staticBlocks;
1776
- const labels = [];
1777
- const runs = groupContiguousBlocks(blocks);
1778
- const refNameLabelPx = runRefNameLabelPx(runs, self.displayedRegionsOrientation);
1779
- for (const [i, run] of runs.entries()) {
1780
- const runLeft = run.offsetPx - firstBlockOffset;
1781
- const runLabels = [];
1782
- for (const { base, x } of makeBlockTicks(run, bpPerPx, true, false)) {
1783
- const label = getTickDisplayStr(base + 1, bpPerPx);
1784
- const w = tickLabelWidth(label);
1785
- // the bold refName label pinned at the run's left edge takes
1786
- // precedence, as it does on the overview scalebar
1787
- if (x - w / 2 >= refNameLabelPx[i] &&
1788
- labelFitsInBlock(x - w / 2, w, run.widthPx)) {
1789
- runLabels.push({
1790
- x: runLeft + x,
1791
- label,
1792
- key: `${run.offsetPx}-${base}`,
1793
- });
1794
- }
1795
- }
1796
- if (tickLabelsWorthDrawing(runLabels.length)) {
1797
- labels.push(...runLabels);
1798
- }
1799
- }
1800
- return labels;
1947
+ return getScalebarLabels({
1948
+ blocks,
1949
+ firstBlockOffset,
1950
+ bpPerPx: self.bpPerPx,
1951
+ orientation: self.displayedRegionsOrientation,
1952
+ });
1801
1953
  },
1802
1954
  /**
1803
1955
  * #getter
@@ -1806,11 +1958,11 @@ export function stateModelFactory(pluginManager) {
1806
1958
  * sticky, transform, maxWidth, paddingLeft, text}` each. One per run of
1807
1959
  * same-refName regions, plus a "sticky" one pinned to the viewport's
1808
1960
  * left edge naming the refName under it, so panning into a chromosome
1809
- * does not scroll its own name off the screen.
1961
+ * does not scroll the chromosome's name off the screen.
1810
1962
  *
1811
- * Three rules live in here that a host drawing region names will
1812
- * otherwise rediscover the hard way, and two of them are invisible until
1813
- * the data is awkward:
1963
+ * This getter applies three rules that a host drawing region names would
1964
+ * otherwise have to rediscover, and two of them are invisible until the
1965
+ * data is awkward:
1814
1966
  *
1815
1967
  * - the sticky label rides the rightmost run that has scrolled off the
1816
1968
  * left, not the region's first block — that one is gone from
@@ -1822,7 +1974,7 @@ export function stateModelFactory(pluginManager) {
1822
1974
  * is fitted to the whole run, and `displayedRegionIndex` /
1823
1975
  * `lastDisplayedRegionIndex` bracket what it stands for.
1824
1976
  * - a name is drawn whole or not at all, measured against the space its
1825
- * run leaves. Clipped to its own width, `chr16` reads as `chr1` —
1977
+ * run leaves. Clipped to that space, `chr16` reads as `chr1` —
1826
1978
  * a different chromosome rather than a shortened name, which is why
1827
1979
  * this is a fit test rather than an ellipsis.
1828
1980
  *
@@ -1838,10 +1990,11 @@ export function stateModelFactory(pluginManager) {
1838
1990
  * into that through `scalebarDisplayPrefix`) and whether it is flipped.
1839
1991
  * A ` [rev]` there is a fact about the ROW; the same marker on a
1840
1992
  * chromosome name means that one region, which only happens under mixed
1841
- * orientation. `captionSpanPx` is the width it takes, which the
1842
- * coordinate numbers stay out from under. The SVG export deliberately
1993
+ * orientation. `captionSpanPx` is the width it takes: the sticky label
1994
+ * pins to its right edge rather than the viewport's, and the coordinate
1995
+ * numbers stay out from under it. The SVG export deliberately
1843
1996
  * calls `getScalebarRefNameLabels` itself with no prefix rather than
1844
- * reading this, since it draws its own assembly name above the ruler.
1997
+ * reading this, since it draws the assembly name itself above the ruler.
1845
1998
  */
1846
1999
  get scalebarRefNameLabels() {
1847
2000
  return getScalebarRefNameLabels({
@@ -1856,36 +2009,36 @@ export function stateModelFactory(pluginManager) {
1856
2009
  * Every span along the row that is not track data, as plain geometry in
1857
2010
  * the staticBlocks frame — the same frame as gridlineTicks and
1858
2011
  * scalebarLabels, so one `translateX(staticBlocksTranslateX)` places all
1859
- * three. Three kinds, and a host drawing its own chrome needs all of
1860
- * them:
2012
+ * three. A host that draws the row's chrome itself needs all three kinds:
1861
2013
  *
1862
2014
  * - `seam`: the 3px bar at a region's right edge. Displayed regions are
1863
- * laid out **contiguously** — calculateStaticBlocks emits boundary
1864
- * padding only before the first region and after the last — so this
2015
+ * laid out **contiguously** (calculateStaticBlocks emits boundary
2016
+ * padding only before the first region and after the last), so this
1865
2017
  * bar is the only thing separating two of them. Without it a
1866
- * two-region view reads as a one-region view scrolled somewhere
1867
- * strange. `isRightEndOfDisplayedRegion` is what marks it, and the
1868
- * right edge of the blocks currently *loaded* — which is what block
1869
- * geometry hands you — is not the same filter.
2018
+ * two-region view looks like a single region.
2019
+ * `isRightEndOfDisplayedRegion` marks it; the right edge of the
2020
+ * currently *loaded* blocks, which block geometry gives you, is a
2021
+ * different set.
1870
2022
  * - `elided`: a region too narrow to draw at this zoom. Whole-genome on
1871
- * a real assembly is mostly these — hg38 has 455 sequences and all
1872
- * but the 24 chromosomes land sub-pixel — so a host that skips them
1873
- * renders that tail as nothing at all.
2023
+ * a real assembly is mostly these (hg38 has 455 sequences, and all
2024
+ * but the 24 chromosomes are under a pixel wide), so a host that
2025
+ * skips them leaves that part of the row empty.
1874
2026
  * - `boundary`: past the start of the first region or the end of the
1875
- * last. Greying it is what makes the last region's seam read as the
1876
- * edge of a filled area rather than a rule floating in the track.
2027
+ * last. Greying it makes the last region's seam read as the edge of a
2028
+ * filled area, where it would otherwise look like a line in the
2029
+ * middle of the track.
1877
2030
  *
1878
- * Elided blocks get no seam even though they carry the flag: at the
1879
- * zoom where regions elide, one bar per region is a solid grey wall.
2031
+ * Elided blocks get no seam even though they have the flag: at the
2032
+ * zoom where regions elide, one bar per region would fill the row solid
2033
+ * grey.
1880
2034
  *
1881
- * PaddingBlocks is the in-tree consumer. The SVG export is deliberately
1882
- * NOT one: `SVGRegionSeparators` walks dynamicBlocks itself and draws
1883
- * only the seam, because `elided` and `boundary` are chrome for an
1884
- * interactive row rather than information a figure carries — striped
1885
- * grey saying "regions here are too narrow to draw" is noise in a
1886
- * static image, and at whole-genome zoom it would be most of the row.
1887
- * The seam is the one that must survive: regions lay out contiguously,
1888
- * so it is all that separates two of them.
2035
+ * PaddingBlocks is the in-tree consumer. The SVG export does not use
2036
+ * it: `SVGRegionSeparators` walks dynamicBlocks itself and draws only
2037
+ * the seam, because `elided` and `boundary` mark state for an
2038
+ * interactive row. In a static figure the striped grey would cover most
2039
+ * of the row at whole-genome zoom. The export keeps the seam because
2040
+ * regions lay out contiguously and the seam is all that separates two
2041
+ * of them.
1889
2042
  */
1890
2043
  // Annotated with the shape spelled out rather than as `PaddingSpan`,
1891
2044
  // because the API doc's type column is where a host drawing its own
@@ -1894,38 +2047,7 @@ export function stateModelFactory(pluginManager) {
1894
2047
  // cannot drift without failing here.
1895
2048
  get paddingSpans() {
1896
2049
  const { blocks, offsetPx: firstBlockOffset } = this.staticBlocks;
1897
- const spans = [];
1898
- for (const block of blocks) {
1899
- const x = block.offsetPx - firstBlockOffset;
1900
- if (block.type === 'ContentBlock') {
1901
- if (block.isRightEndOfDisplayedRegion) {
1902
- spans.push({
1903
- key: `${block.key}-sep`,
1904
- x: x + block.widthPx - 1,
1905
- width: 3,
1906
- kind: 'seam',
1907
- });
1908
- }
1909
- }
1910
- else {
1911
- spans.push({
1912
- key: block.key,
1913
- x,
1914
- width: block.widthPx,
1915
- kind: block.type === 'ElidedBlock'
1916
- ? 'elided'
1917
- : 'boundary',
1918
- });
1919
- }
1920
- }
1921
- // One shared array when there is nothing to draw, so the computed's
1922
- // value repeats and MobX's `===` stops the chain here. This is the
1923
- // COMMON case, not an edge one: a view sitting inside one contig has
1924
- // no region seam, no elision and no boundary, and that is where a
1925
- // reader spends nearly all of a session. A fresh `[]` each frame
1926
- // instead re-rendered a PaddingBlocks per track plus one for the
1927
- // container, every frame of every gesture, to draw nothing.
1928
- return spans.length > 0 ? spans : NO_PADDING_SPANS;
2050
+ return getPaddingSpans(blocks, firstBlockOffset);
1929
2051
  },
1930
2052
  /**
1931
2053
  * #getter
@@ -1957,8 +2079,8 @@ export function stateModelFactory(pluginManager) {
1957
2079
  * viewport. False both when the view holds no regions at all and when
1958
2080
  * it holds some but is scrolled entirely off them; either way there is
1959
2081
  * no visible span for the scalebar, ruler and refName labels to
1960
- * describe, which is what the SVG export's header checks before drawing
1961
- * one. Distinct from `hasDisplayedRegions`, which only asks whether the
2082
+ * describe. The SVG export checks it before drawing its header.
2083
+ * Distinct from `hasDisplayedRegions`, which only asks whether the
1962
2084
  * view has been given regions, not whether any are on screen.
1963
2085
  */
1964
2086
  get hasVisibleContent() {
@@ -1998,7 +2120,7 @@ export function stateModelFactory(pluginManager) {
1998
2120
  /**
1999
2121
  * #getter
2000
2122
  * Right edge (px, viewport-relative) of the on-screen content, clamped
2001
- * to the track — where a right-pinned overlay such as a wiggle's colour
2123
+ * to the track — where a right-pinned overlay such as a wiggle's color
2002
2124
  * or score legend belongs, rather than out in the empty gutter the
2003
2125
  * regions can leave at whole-genome zoom. `@jbrowse/display-kit`'s
2004
2126
  * `contentRightEdgePx` states the rule; an SVG export applies the same
@@ -2008,7 +2130,7 @@ export function stateModelFactory(pluginManager) {
2008
2130
  * array of fresh objects on every pan and zoom frame, so a component
2009
2131
  * that derived this number itself re-rendered on every frame of every
2010
2132
  * gesture — to produce, whenever content fills the track, the unchanged
2011
- * `trackWidthPx`. Reading the scalar lets MobX's `===` stop the chain at
2133
+ * `trackWidthPx`. A component that reads the scalar lets MobX's `===` stop the chain at
2012
2134
  * this computed instead. Measured over 20 zoom frames: the wiggle
2013
2135
  * family's three body instances fell from 66 renders to 7.
2014
2136
  */
@@ -2060,23 +2182,22 @@ export function stateModelFactory(pluginManager) {
2060
2182
  },
2061
2183
  /**
2062
2184
  * #getter
2063
- * **What a debounced consumer clips to**: the coarse blocks once the
2064
- * view has settled at least once, the live ones before that.
2185
+ * **The blocks a debounced consumer clips to**: the coarse blocks once
2186
+ * the view has settled at least once, the live ones before that.
2065
2187
  *
2066
- * The coarse blocks exist so a per-bp scan does not recompute on every
2067
- * animation frame during a pan or zoom — wiggle's autoscale domain, the
2068
- * alignments coverage scale and MAF's coverage band are the three — and
2069
- * while they are stale
2070
- * the answer is merely a frame or two old, which is what the debounce
2071
- * means. **Empty is different in kind.** A scan over no blocks yields no
2072
- * entries, and no entries is not a stale domain, it is the fallback one:
2073
- * `[0,1]`, which draws a line plot blank and a density plot saturated.
2074
- * That window is the 500ms between a view initializing and the coarse
2075
- * autorun's first run, and data can now land inside it — the per-region
2076
- * fetch used to be trailing-edge at 600ms, so it never did.
2188
+ * The coarse blocks stop a per-bp scan from recomputing on every
2189
+ * animation frame during a pan or zoom. Wiggle's autoscale domain, the
2190
+ * alignments coverage scale and MAF's coverage band use them. While the
2191
+ * coarse blocks are stale the result is a frame or two old, as the
2192
+ * debounce intends. **Empty coarse blocks are a different case.** A scan
2193
+ * over no blocks yields no entries, and the consumer falls back to the
2194
+ * domain `[0,1]`, which draws a line plot blank and a density plot
2195
+ * saturated. The coarse blocks are empty for the 500ms between a view
2196
+ * initializing and the coarse autorun's first run, and data can load
2197
+ * within that window.
2077
2198
  *
2078
- * One recompute at that transition, against N per frame, which is the
2079
- * trade the guard was making anyway.
2199
+ * Falling back to the live blocks costs one extra recompute at that
2200
+ * transition, compared with N per frame without the debounce.
2080
2201
  */
2081
2202
  get settledDynamicBlocks() {
2082
2203
  return self.coarseDynamicBlocks.length
@@ -2100,7 +2221,7 @@ export function stateModelFactory(pluginManager) {
2100
2221
  * #getter
2101
2222
  */
2102
2223
  get effectiveTotalBp() {
2103
- return self.bpPerPx * self.width;
2224
+ return self.windowWidthBp;
2104
2225
  },
2105
2226
  /**
2106
2227
  * #getter
@@ -2130,6 +2251,9 @@ export function stateModelFactory(pluginManager) {
2130
2251
  self.coarseTotalBp = blocks.totalBp;
2131
2252
  self.coarseBpPerPx = bpPerPx;
2132
2253
  }
2254
+ // on every call, since a scroll through the padding before the first
2255
+ // region moves the viewport and leaves the blocks the same
2256
+ self.coarseOffsetPx = self.offsetPx;
2133
2257
  },
2134
2258
  }))
2135
2259
  .actions(self => ({
@@ -2140,7 +2264,7 @@ export function stateModelFactory(pluginManager) {
2140
2264
  *
2141
2265
  * The coarse blocks are a 500ms throttle, and every consumer trades
2142
2266
  * freshness for not recomputing per animation frame — the location box,
2143
- * canvas's on-screen feature set, and the two per-bp scans behind
2267
+ * the feature display's on-screen feature set, and the two per-bp scans behind
2144
2268
  * `settledDynamicBlocks`. That trade is only sound while the answer is a
2145
2269
  * few frames old. **A jump makes it unrelated instead**, because there is
2146
2270
  * nothing to coalesce: the viewport left behind is not an approximation of
@@ -2148,17 +2272,16 @@ export function stateModelFactory(pluginManager) {
2148
2272
  * as a navigation that didn't happen, and wiggle's autoscale domain is
2149
2273
  * computed over the window the user just left — measured at `[0,200]`
2150
2274
  * against a correct `[0,300]` when a 40bp coarse window survived a jump to
2151
- * 4040bp on screen. Only the per-region fetch moving to the leading edge
2152
- * made that reachable: at 600ms the data never landed inside the hole.
2275
+ * 4040bp on screen.
2153
2276
  *
2154
- * **The continuous paths deliberately do not come through here.** The
2277
+ * **The continuous paths do not call this.** The
2155
2278
  * spring zoom writes through `zoomTo` per frame and a drag through
2156
2279
  * `scrollTo`, so this adds no per-frame work; the settle is idempotent
2157
2280
  * (`setCoarseDynamicBlocks` compares block keys), so the debounced
2158
- * autorun's own follow-up run costs nothing.
2281
+ * autorun's follow-up run costs nothing.
2159
2282
  *
2160
- * Every placer calls it, and `placersSettleCoarseBlocks.test.ts` is what
2161
- * makes that true of the next one — it scans this file for the writes and
2283
+ * Every placer calls it, and `placersSettleCoarseBlocks.test.ts` enforces
2284
+ * that for the next one: it scans this file for the writes and
2162
2285
  * fails on a placer that reaches neither the settle nor its named list of
2163
2286
  * continuous paths. Two passes by hand missed four.
2164
2287
  */
@@ -2168,6 +2291,42 @@ export function stateModelFactory(pluginManager) {
2168
2291
  }
2169
2292
  },
2170
2293
  }))
2294
+ .actions(self => {
2295
+ function layOutAt(width) {
2296
+ self.setWidth(width);
2297
+ self.settleCoarseBlocks();
2298
+ }
2299
+ return {
2300
+ /**
2301
+ * #method
2302
+ * renders the view to SVG markup, which it returns; saves it through
2303
+ * FileSaver unless `save: false`. A `viewWidth` lays the view out at
2304
+ * that many pixels for the export, as a window that wide would, and
2305
+ * puts the on-screen width back after.
2306
+ */
2307
+ // Promise<string> is stated: renderToSvg is typed against this very
2308
+ // model, so an inferred return makes the type reference itself (TS2456)
2309
+ async exportSvg(opts = {}) {
2310
+ const { width } = self;
2311
+ const { viewWidth = width } = opts;
2312
+ // The on-screen tracks redraw at `viewWidth` until the export ends. A
2313
+ // hidden copy of the view would keep them still, but it needs a
2314
+ // session slot that autosave and undo skip, and new ids for the view,
2315
+ // its tracks and displays, since widgets find them by id.
2316
+ if (viewWidth !== width) {
2317
+ layOutAt(viewWidth);
2318
+ }
2319
+ try {
2320
+ return await exportViewSvg(self, opts, () => import('./svgcomponents/SVGLinearGenomeView.js'));
2321
+ }
2322
+ finally {
2323
+ if (viewWidth !== width) {
2324
+ layOutAt(width);
2325
+ }
2326
+ }
2327
+ },
2328
+ };
2329
+ })
2171
2330
  .actions(self => ({
2172
2331
  /**
2173
2332
  * #action
@@ -2183,11 +2342,11 @@ export function stateModelFactory(pluginManager) {
2183
2342
  },
2184
2343
  /**
2185
2344
  * #action
2186
- * Place the viewport by the pair of PIXEL quantities it used to be stored
2187
- * as. Prefer `setWindow`: pixels mean nothing without the width they were
2345
+ * Place the viewport by `bpPerPx` and `offsetPx`, the pixel pair the view
2346
+ * stored before it stored the window. Prefer `setWindow`: pixels mean nothing without the width they were
2188
2347
  * measured at, so a round trip through here is only exact while the width
2189
2348
  * holds still. Kept for callers that genuinely have pixels — a wheel
2190
- * gesture, a rubberband — and for reading old snapshots.
2349
+ * gesture, a rubberband.
2191
2350
  */
2192
2351
  setNewView(bpPerPx, offsetPx) {
2193
2352
  self.zoomTo(bpPerPx);
@@ -2197,7 +2356,7 @@ export function stateModelFactory(pluginManager) {
2197
2356
  /**
2198
2357
  * #action
2199
2358
  * Place the viewport by the pair it is actually STORED as: the window's
2200
- * width and left edge, both in bp. `setNewView`'s bp-space twin, and the
2359
+ * width and left edge, both in bp. The bp-space counterpart of `setNewView`, and the
2201
2360
  * right one for anything that captures a viewport and puts it back later
2202
2361
  * (an Undo, a saved location) — those two moments can be a window resize
2203
2362
  * apart, and this pair survives one where a pixel pair does not.
@@ -2227,8 +2386,8 @@ export function stateModelFactory(pluginManager) {
2227
2386
  },
2228
2387
  /**
2229
2388
  * #action
2230
- * The worst jump of them to leave unsettled: the coarse blocks are keyed
2231
- * by `displayedRegionIndex`, so a set that outlives the region list has a
2389
+ * Settles the coarse blocks, since this is the worst jump to leave
2390
+ * unsettled: they are keyed by `displayedRegionIndex`, so a set that outlives the region list has a
2232
2391
  * consumer reading one contig's data against another's blocks.
2233
2392
  */
2234
2393
  setDisplayedRegions(regions) {
@@ -2238,9 +2397,13 @@ export function stateModelFactory(pluginManager) {
2238
2397
  // [minOffset, maxOffset]. scrollTo is called explicitly rather than
2239
2398
  // left to zoomTo's internal scrollTo, which is skipped when bpPerPx
2240
2399
  // is already in range — shrinking the region set would otherwise
2241
- // strand the view scrolled past the end, on blank space.
2242
- self.zoomTo(self.bpPerPx);
2243
- self.scrollTo(self.offsetPx);
2400
+ // strand the view scrolled past the end, on blank space. An unmeasured
2401
+ // view has no bounds to clamp to, and keeps its window as a restored
2402
+ // snapshot does.
2403
+ if (self.bpPerPx > 0) {
2404
+ self.zoomTo(self.bpPerPx);
2405
+ self.scrollTo(self.offsetPx);
2406
+ }
2244
2407
  self.settleCoarseBlocks();
2245
2408
  },
2246
2409
  /**
@@ -2339,14 +2502,6 @@ export function stateModelFactory(pluginManager) {
2339
2502
  clearView() {
2340
2503
  self.setDisplayedRegions([]);
2341
2504
  self.tracks.clear();
2342
- // it is necessary to run these after setting displayed regions empty
2343
- // or else model.offsetPx gets set to Infinity and breaks
2344
- // @jbrowse/mobx-state-tree snapshot
2345
- self.scrollTo(0);
2346
- self.zoomTo(10);
2347
- // after them, not through setDisplayedRegions, whose own settle ran at
2348
- // the intermediate viewport these two lines exist to leave
2349
- self.settleCoarseBlocks();
2350
2505
  },
2351
2506
  /**
2352
2507
  * #action
@@ -2455,8 +2610,7 @@ export function stateModelFactory(pluginManager) {
2455
2610
  // unrelated files lose their parameter types to it.
2456
2611
  async navToLocString(input, optAssemblyName, grow, opts) {
2457
2612
  const { assemblyNames } = self;
2458
- const session = getSession(self);
2459
- const { assemblyManager } = session;
2613
+ const { assemblyManager } = getSession(self);
2460
2614
  const assemblyName = optAssemblyName || assemblyNames[0];
2461
2615
  if (assemblyName) {
2462
2616
  await assemblyManager.waitForAssembly(assemblyName);
@@ -2491,7 +2645,18 @@ export function stateModelFactory(pluginManager) {
2491
2645
  return;
2492
2646
  }
2493
2647
  const { assemblyManager } = getSession(self);
2494
- await when(() => self.volatileWidth !== undefined);
2648
+ // A one-sided `volatileWidth !== undefined` parks forever on a view
2649
+ // that never mounts, and `navToMultiLevelBreak` awaits a Promise.all of
2650
+ // these one line before the `whenViewSettled` that was written to cure
2651
+ // exactly that hang. `isAlive` is the second terminal state and the
2652
+ // guard below is what it means. NOT `self.error`, which is a transient
2653
+ // truth (`assembliesNotFound` while a session assembly is registering)
2654
+ // independent of mounting, so it would return without navigating where
2655
+ // waiting would have navigated correctly.
2656
+ await when(() => self.volatileWidth !== undefined || !isAlive(self));
2657
+ if (!isAlive(self) || self.volatileWidth === undefined) {
2658
+ return;
2659
+ }
2495
2660
  // Generate locations from the parsed regions
2496
2661
  const locations = await generateLocations({
2497
2662
  regions,
@@ -2554,6 +2719,41 @@ export function stateModelFactory(pluginManager) {
2554
2719
  async navToLocation(parsedLocString, assemblyName, grow) {
2555
2720
  return self.navToLocations([parsedLocString], assemblyName, grow);
2556
2721
  },
2722
+ /**
2723
+ * #action
2724
+ * adds the region in view to the session's highlights
2725
+ */
2726
+ highlightCurrentRegion() {
2727
+ const [region] = self.visibleWholeBaseRegions;
2728
+ if (region) {
2729
+ getSession(self).addHighlight(region);
2730
+ }
2731
+ },
2732
+ /**
2733
+ * #action
2734
+ * navigates to a highlight, zoomed out a little for context. Goes through
2735
+ * a locstring so it reaches a refName the view is not displaying
2736
+ */
2737
+ navToHighlight(highlight) {
2738
+ const { assemblyName, ...region } = highlight;
2739
+ return self.navToLocString(assembleLocString(region), assemblyName, 0.2);
2740
+ },
2741
+ }))
2742
+ .actions(self => ({
2743
+ /**
2744
+ * #action
2745
+ * navigates to the most recently added highlight on this view's
2746
+ * assemblies
2747
+ */
2748
+ async navigateNewestHighlight() {
2749
+ const newest = self.highlights.at(-1);
2750
+ if (newest) {
2751
+ await self.navToHighlight(newest);
2752
+ }
2753
+ else {
2754
+ getSession(self).notify('There are no highlights to navigate to.', 'info');
2755
+ }
2756
+ },
2557
2757
  }))
2558
2758
  .views(self => ({
2559
2759
  /**
@@ -2570,14 +2770,29 @@ export function stateModelFactory(pluginManager) {
2570
2770
  },
2571
2771
  /**
2572
2772
  * #method
2573
- * Map a highlight or bookmark region to its pixel position+width inside
2773
+ * Map a highlight region to its pixel position+width inside
2574
2774
  * the tracks container. Falls back to the raw refName if the region's
2575
2775
  * assemblyName is missing or unknown so highlights authored without an
2576
2776
  * assembly still render in single-assembly views.
2577
2777
  */
2578
2778
  getHighlightCoords(region) {
2579
- const refName = resolveCanonicalRefName(self, region);
2580
- return getLayoutHighlightCoords(self, { ...region, refName });
2779
+ return getLayoutHighlightCoords(self, canonicalHighlight(self, region));
2780
+ },
2781
+ /**
2782
+ * #method
2783
+ * y ranges, from the top of the highlight bands, of tracks whose display
2784
+ * sets `drawsGenomicCoordinates` to false; the bands skip them
2785
+ */
2786
+ nonGenomicTrackBands() {
2787
+ const y0 = self.headerHeight + self.scalebarHeight;
2788
+ const { trackYOffsets } = self;
2789
+ return [...self.pinnedTracks, ...self.unpinnedTracks].flatMap(track => {
2790
+ const top = trackYOffsets.get(track.configuration.trackId);
2791
+ return track.activeDisplay.drawsGenomicCoordinates === false &&
2792
+ top !== undefined
2793
+ ? [{ top: top - y0, height: self.trackHeight(track) }]
2794
+ : [];
2795
+ });
2581
2796
  },
2582
2797
  /**
2583
2798
  * #method
@@ -2585,11 +2800,7 @@ export function stateModelFactory(pluginManager) {
2585
2800
  * shifted by the cytoband offset
2586
2801
  */
2587
2802
  getOverviewHighlightCoords(region) {
2588
- const refName = resolveCanonicalRefName(self, region);
2589
- const coords = getLayoutHighlightCoords(self.overviewLayout, {
2590
- ...region,
2591
- refName,
2592
- });
2803
+ const coords = getLayoutHighlightCoords(self.overviewLayout, canonicalHighlight(self, region));
2593
2804
  return coords
2594
2805
  ? { ...coords, left: coords.left + self.cytobandOffset }
2595
2806
  : undefined;
@@ -2628,6 +2839,17 @@ export function stateModelFactory(pluginManager) {
2628
2839
  ? this.pxToBp(self.width / 2)
2629
2840
  : undefined;
2630
2841
  },
2842
+ }))
2843
+ .actions(self => ({
2844
+ /**
2845
+ * #action
2846
+ * a region edit from the ruler's menu as one action, which a synteny
2847
+ * stack's follow reads as the reader's gesture on this row; see
2848
+ * `setDisplayedRegionsKeepingCenter`
2849
+ */
2850
+ editDisplayedRegions(edit) {
2851
+ setDisplayedRegionsKeepingCenter(self, edit);
2852
+ },
2631
2853
  }))
2632
2854
  .views(self => ({
2633
2855
  /**
@@ -2638,11 +2860,10 @@ export function stateModelFactory(pluginManager) {
2638
2860
  },
2639
2861
  /**
2640
2862
  * #method
2641
- * returns menu items for a highlight context menu. plugins can extend
2642
- * this via Core-extendPluggableElement to add their own items
2863
+ * a highlight chip's menu rows after Dismiss; extend this to add one
2643
2864
  */
2644
2865
  highlightMenuItems(_highlight) {
2645
- return [];
2866
+ return highlightListMenuItems(getSession(self), getEnv(self).pluginManager);
2646
2867
  },
2647
2868
  }))
2648
2869
  .actions(self => {
@@ -2656,21 +2877,21 @@ export function stateModelFactory(pluginManager) {
2656
2877
  let heading;
2657
2878
  /**
2658
2879
  * #action
2659
- * Travel to a window rather than appear in it: the Van Wijk arc from
2660
- * where the view is to where it is going, played over its own duration.
2880
+ * Animate to a window along the Van Wijk arc from the current window to
2881
+ * the target, over a duration derived from the arc.
2661
2882
  *
2662
- * WHAT LANDS IS WHAT `setWindow` WOULD HAVE LANDED — only the path in
2663
- * between is new. That is what lets a caller offer an Undo, a snackbar or
2664
- * a follow anchor around this exactly as it did around the instant move.
2883
+ * The final state is the one `setWindow` would produce; only the path in
2884
+ * between differs. A caller can therefore offer an Undo, a snackbar or a
2885
+ * follow anchor around this exactly as around the instant move.
2665
2886
  *
2666
- * It YIELDS to anything else that moves the view, by reading back what it
2667
- * wrote and stopping the moment the view holds something else: a wheel
2668
- * zoom, a drag, a locstring nav, or the Undo on the very snackbar the
2669
- * flight was launched with. Compared against what was WRITTEN rather than
2670
- * what was asked for, because the write clamps — an arc that pulls back
2671
- * past `maxBpPerPx` reads its own clamped result back, and treating that
2672
- * as interference would end the flight one frame in. `springAnimate`
2673
- * defends the same value the same way.
2887
+ * The flight stops when anything else moves the view: it reads back what
2888
+ * it wrote and stops as soon as the view holds something else, such as a
2889
+ * wheel zoom, a drag, a locstring nav, or the Undo on the snackbar the
2890
+ * flight was launched with. It compares against the written value, not
2891
+ * the requested one, because the write clamps: an arc that pulls back
2892
+ * past `maxBpPerPx` reads back its clamped result, and treating that as
2893
+ * interference would end the flight after one frame. `springAnimate`
2894
+ * handles the same value the same way.
2674
2895
  */
2675
2896
  function flyTo(centerBp, windowWidthBp) {
2676
2897
  cancelLastFlight();
@@ -2737,7 +2958,7 @@ export function stateModelFactory(pluginManager) {
2737
2958
  * `centerAt`'s animated twin: the same destination, reached along the
2738
2959
  * arc instead of jumped to, at the zoom the view is already on.
2739
2960
  */
2740
- flyToCenter(coord, refName) {
2961
+ flyToCenter(coord, refName, displayedRegionIndex) {
2741
2962
  // `bpToLinearBp` rather than `bpToPx`, which rounds to a whole pixel
2742
2963
  // at the current zoom: mid-flight that zoom is a point on somebody
2743
2964
  // else's arc, and a destination quantized against it moves with it.
@@ -2745,11 +2966,22 @@ export function stateModelFactory(pluginManager) {
2745
2966
  refName,
2746
2967
  coord,
2747
2968
  displayedRegions: self.displayedRegions,
2969
+ displayedRegionIndex,
2748
2970
  });
2749
2971
  if (centerBp !== undefined) {
2750
2972
  flyTo(centerBp, heading ? heading.windowWidthBp : self.windowWidthBp);
2751
2973
  }
2752
2974
  },
2975
+ /**
2976
+ * #action
2977
+ * A flight to a linear bp centre at the zoom the view is on, or is
2978
+ * already flying to, widened where that is narrower than `fitWidthBp`.
2979
+ * The current width mid-flight is a point on the arc, so a second
2980
+ * flight read from it would land pulled back.
2981
+ */
2982
+ flyToFit(centerBp, fitWidthBp) {
2983
+ flyTo(centerBp, Math.max(heading ? heading.windowWidthBp : self.windowWidthBp, fitWidthBp));
2984
+ },
2753
2985
  };
2754
2986
  })
2755
2987
  .actions(self => ({
@@ -2760,23 +2992,23 @@ export function stateModelFactory(pluginManager) {
2760
2992
  doAfterAttach(self);
2761
2993
  },
2762
2994
  }));
2763
- return withLaunchInput(model, lgvLaunchKeys)
2995
+ return withLaunchInput(model, lgvLaunchKeys, {
2996
+ registry: pluginManager,
2997
+ materialized: snap => !!snap.displayedRegions?.length,
2998
+ })
2764
2999
  .preProcessSnapshot((snap) => {
2765
3000
  if (!snap) {
2766
3001
  return snap;
2767
3002
  }
2768
- // The cytobands setting has been `showCytobandsSetting` and (briefly)
2769
- // `cytobandsVisible`; both now persist as the bare `showCytobands` prop
2770
- // (the capability-gated getter is `effectiveShowCytobands`).
2771
- const { highlight, showCytobandsSetting, cytobandsVisible, offsetPx, bpPerPx, ...rest } = snap;
2772
- const legacyShowCytobands = showCytobandsSetting ?? cytobandsVisible;
3003
+ // v4's `showCytobandsSetting` persists as the bare `showCytobands` prop
3004
+ // (the capability-gated getter is `effectiveShowCytobands`), and v4's
3005
+ // `colorByCDS` as `colorByFrame`.
3006
+ const { showCytobandsSetting, colorByCDS, offsetPx, bpPerPx, ...rest } = snap;
2773
3007
  return {
2774
- highlight: Array.isArray(highlight) || highlight === undefined
2775
- ? highlight
2776
- : [highlight],
2777
- ...(legacyShowCytobands !== undefined
2778
- ? { showCytobands: legacyShowCytobands }
3008
+ ...(showCytobandsSetting !== undefined
3009
+ ? { showCytobands: showCytobandsSetting }
2779
3010
  : {}),
3011
+ ...(colorByCDS !== undefined ? { colorByFrame: colorByCDS } : {}),
2780
3012
  // The viewport used to persist as pixels. Half of that converts
2781
3013
  // exactly and needs no width — the left edge in bp is offsetPx *
2782
3014
  // bpPerPx — and half of it cannot convert at all, because the width
@@ -2802,30 +3034,13 @@ export function stateModelFactory(pluginManager) {
2802
3034
  };
2803
3035
  })
2804
3036
  .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;
2813
- return {
2814
- ...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
- ...(!showCytobands ? { showCytobands } : {}),
2824
- ...(trackLabels ? { trackLabels } : {}),
2825
- ...(colorByCDS ? { colorByCDS } : {}),
2826
- ...(!showAminoAcids ? { showAminoAcids } : {}),
2827
- ...(!showTrackOutlines ? { showTrackOutlines } : {}),
2828
- };
3037
+ // showCenterLine is a personal preference and stays out of the session.
3038
+ // The other localStorage-seeded settings are always written: the
3039
+ // receiving browser's localStorage fills an absent one, so stripping a
3040
+ // default would show a share link's reader their own setting instead
3041
+ // of the author's.
3042
+ const { showCenterLine, ...rest } = snap;
3043
+ return rest;
2829
3044
  });
2830
3045
  }
2831
3046
  // #endregion