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

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 (357) hide show
  1. package/esm/FeatureTrack/configSchema.d.ts +104 -65
  2. package/esm/FeatureTrack/configSchema.js +47 -1
  3. package/esm/FeatureTrack/index.js +1 -1
  4. package/esm/LaunchLinearGenomeView/index.d.ts +14 -0
  5. package/esm/LaunchLinearGenomeView/index.js +17 -12
  6. package/esm/LinearGenomeView/FloatingSvgOverlay.d.ts +5 -0
  7. package/esm/LinearGenomeView/FloatingSvgOverlay.js +25 -0
  8. package/esm/LinearGenomeView/afterAttach.d.ts +20 -2
  9. package/esm/LinearGenomeView/afterAttach.js +253 -122
  10. package/esm/LinearGenomeView/components/CenterLine.d.ts +1 -1
  11. package/esm/LinearGenomeView/components/CenterLine.js +16 -7
  12. package/esm/LinearGenomeView/components/ConnectedHoverHighlight.d.ts +7 -0
  13. package/esm/LinearGenomeView/components/ConnectedHoverHighlight.js +22 -0
  14. package/esm/LinearGenomeView/components/CrisprGuidePanel.d.ts +3 -0
  15. package/esm/LinearGenomeView/components/CrisprGuidePanel.js +144 -0
  16. package/esm/LinearGenomeView/components/Cytobands.d.ts +6 -6
  17. package/esm/LinearGenomeView/components/Cytobands.js +76 -53
  18. package/esm/LinearGenomeView/components/ExportSvgDialog.d.ts +7 -3
  19. package/esm/LinearGenomeView/components/ExportSvgDialog.js +11 -67
  20. package/esm/LinearGenomeView/components/GetSequenceDialog.d.ts +5 -9
  21. package/esm/LinearGenomeView/components/GetSequenceDialog.js +84 -88
  22. package/esm/LinearGenomeView/components/Gridlines.d.ts +1 -1
  23. package/esm/LinearGenomeView/components/Gridlines.js +29 -58
  24. package/esm/LinearGenomeView/components/Header.d.ts +1 -1
  25. package/esm/LinearGenomeView/components/Header.js +32 -16
  26. package/esm/LinearGenomeView/components/HeaderClearHighlightButton.d.ts +21 -0
  27. package/esm/LinearGenomeView/components/HeaderClearHighlightButton.js +35 -0
  28. package/esm/LinearGenomeView/components/HeaderPanControls.d.ts +3 -2
  29. package/esm/LinearGenomeView/components/HeaderPanControls.js +16 -7
  30. package/esm/LinearGenomeView/components/HeaderRegionWidth.d.ts +1 -1
  31. package/esm/LinearGenomeView/components/HeaderRegionWidth.js +9 -6
  32. package/esm/LinearGenomeView/components/HeaderSearchBoxRow.d.ts +6 -0
  33. package/esm/LinearGenomeView/components/HeaderSearchBoxRow.js +27 -0
  34. package/esm/LinearGenomeView/components/HeaderSearchBoxes.d.ts +5 -0
  35. package/esm/LinearGenomeView/components/HeaderSearchBoxes.js +28 -0
  36. package/esm/LinearGenomeView/components/HeaderTrackSelectorButton.d.ts +3 -2
  37. package/esm/LinearGenomeView/components/HeaderTrackSelectorButton.js +11 -4
  38. package/esm/LinearGenomeView/components/HeaderZoomControls.d.ts +3 -2
  39. package/esm/LinearGenomeView/components/HeaderZoomControls.js +67 -51
  40. package/esm/LinearGenomeView/components/Highlight.d.ts +1 -1
  41. package/esm/LinearGenomeView/components/Highlight.js +19 -23
  42. package/esm/LinearGenomeView/components/HighlightBand.d.ts +3 -2
  43. package/esm/LinearGenomeView/components/HighlightBand.js +25 -8
  44. package/esm/LinearGenomeView/components/HighlightChip.d.ts +9 -0
  45. package/esm/LinearGenomeView/components/HighlightChip.js +15 -0
  46. package/esm/LinearGenomeView/components/HoverPositionHighlight.d.ts +12 -0
  47. package/esm/LinearGenomeView/components/HoverPositionHighlight.js +25 -0
  48. package/esm/LinearGenomeView/components/ImportForm.d.ts +3 -4
  49. package/esm/LinearGenomeView/components/ImportForm.js +95 -61
  50. package/esm/LinearGenomeView/components/LinearGenomeView.d.ts +1 -1
  51. package/esm/LinearGenomeView/components/LinearGenomeView.js +6 -5
  52. package/esm/LinearGenomeView/components/LinearGenomeViewContainer.d.ts +1 -1
  53. package/esm/LinearGenomeView/components/LinearGenomeViewContainer.js +89 -30
  54. package/esm/LinearGenomeView/components/MiniControls.d.ts +1 -1
  55. package/esm/LinearGenomeView/components/MiniControls.js +13 -10
  56. package/esm/LinearGenomeView/components/MotifListPanel.d.ts +3 -0
  57. package/esm/LinearGenomeView/components/MotifListPanel.js +59 -0
  58. package/esm/LinearGenomeView/components/NavigationAnnouncer.d.ts +32 -0
  59. package/esm/LinearGenomeView/components/NavigationAnnouncer.js +51 -0
  60. package/esm/LinearGenomeView/components/NoTracksActiveButton.d.ts +1 -1
  61. package/esm/LinearGenomeView/components/NoTracksActiveButton.js +1 -1
  62. package/esm/LinearGenomeView/components/OverviewHighlight.d.ts +2 -4
  63. package/esm/LinearGenomeView/components/OverviewHighlight.js +23 -44
  64. package/esm/LinearGenomeView/components/OverviewHighlightBand.d.ts +9 -0
  65. package/esm/LinearGenomeView/components/OverviewHighlightBand.js +26 -0
  66. package/esm/LinearGenomeView/components/OverviewRubberband.d.ts +3 -3
  67. package/esm/LinearGenomeView/components/OverviewRubberband.js +44 -57
  68. package/esm/LinearGenomeView/components/OverviewRubberbandHoverTooltip.d.ts +4 -5
  69. package/esm/LinearGenomeView/components/OverviewRubberbandHoverTooltip.js +13 -20
  70. package/esm/LinearGenomeView/components/OverviewScalebar.d.ts +3 -2
  71. package/esm/LinearGenomeView/components/OverviewScalebar.js +84 -79
  72. package/esm/LinearGenomeView/components/OverviewScalebarPolygon.d.ts +19 -5
  73. package/esm/LinearGenomeView/components/OverviewScalebarPolygon.js +55 -32
  74. package/esm/LinearGenomeView/components/OverviewScalebarTickLabels.d.ts +4 -5
  75. package/esm/LinearGenomeView/components/OverviewScalebarTickLabels.js +16 -18
  76. package/esm/LinearGenomeView/components/PaddingBlocks.d.ts +7 -0
  77. package/esm/LinearGenomeView/components/PaddingBlocks.js +51 -0
  78. package/esm/LinearGenomeView/components/RangeSelectOverlay.d.ts +9 -0
  79. package/esm/LinearGenomeView/components/RangeSelectOverlay.js +20 -0
  80. package/esm/LinearGenomeView/components/RegionWidthEditorDialog.d.ts +2 -3
  81. package/esm/LinearGenomeView/components/RegionWidthEditorDialog.js +19 -26
  82. package/esm/LinearGenomeView/components/Rubberband.d.ts +1 -1
  83. package/esm/LinearGenomeView/components/Rubberband.js +13 -16
  84. package/esm/LinearGenomeView/components/SVGHighlightBand.d.ts +10 -0
  85. package/esm/LinearGenomeView/components/SVGHighlightBand.js +9 -0
  86. package/esm/LinearGenomeView/components/Scalebar.d.ts +2 -2
  87. package/esm/LinearGenomeView/components/Scalebar.js +16 -21
  88. package/esm/LinearGenomeView/components/ScalebarCoordinateLabels.d.ts +1 -1
  89. package/esm/LinearGenomeView/components/ScalebarCoordinateLabels.js +54 -35
  90. package/esm/LinearGenomeView/components/ScalebarRefNameLabels.d.ts +1 -1
  91. package/esm/LinearGenomeView/components/ScalebarRefNameLabels.js +142 -126
  92. package/esm/LinearGenomeView/components/SearchBox.d.ts +22 -2
  93. package/esm/LinearGenomeView/components/SearchBox.js +58 -66
  94. package/esm/LinearGenomeView/components/SearchPanelForm.d.ts +9 -0
  95. package/esm/LinearGenomeView/components/SearchPanelForm.js +18 -0
  96. package/esm/LinearGenomeView/components/SearchResultsDialog.d.ts +3 -3
  97. package/esm/LinearGenomeView/components/SearchResultsDialog.js +5 -7
  98. package/esm/LinearGenomeView/components/SearchResultsTable.d.ts +4 -3
  99. package/esm/LinearGenomeView/components/SearchResultsTable.js +45 -53
  100. package/esm/LinearGenomeView/components/SequenceFeatureHoverHighlight.d.ts +5 -0
  101. package/esm/LinearGenomeView/components/SequenceFeatureHoverHighlight.js +37 -0
  102. package/esm/LinearGenomeView/components/SequenceFeatureHoverHighlightExtension.d.ts +2 -0
  103. package/esm/LinearGenomeView/components/SequenceFeatureHoverHighlightExtension.js +5 -0
  104. package/esm/LinearGenomeView/components/SequencePatternPanel.d.ts +3 -0
  105. package/esm/LinearGenomeView/components/SequencePatternPanel.js +42 -0
  106. package/esm/LinearGenomeView/components/SequenceSearchDialog.d.ts +1 -1
  107. package/esm/LinearGenomeView/components/SequenceSearchDialog.js +31 -53
  108. package/esm/LinearGenomeView/components/StrandCheckboxes.d.ts +8 -0
  109. package/esm/LinearGenomeView/components/StrandCheckboxes.js +13 -0
  110. package/esm/LinearGenomeView/components/TrackContainer.d.ts +1 -1
  111. package/esm/LinearGenomeView/components/TrackContainer.js +83 -39
  112. package/esm/LinearGenomeView/components/TrackLabel.d.ts +2 -3
  113. package/esm/LinearGenomeView/components/TrackLabel.js +11 -24
  114. package/esm/LinearGenomeView/components/TrackLabelButtons.d.ts +7 -0
  115. package/esm/LinearGenomeView/components/TrackLabelButtons.js +33 -0
  116. package/esm/LinearGenomeView/components/TrackLabelDragHandle.d.ts +2 -5
  117. package/esm/LinearGenomeView/components/TrackLabelDragHandle.js +6 -2
  118. package/esm/LinearGenomeView/components/TrackLabelMenu.d.ts +1 -1
  119. package/esm/LinearGenomeView/components/TrackLabelMenu.js +32 -85
  120. package/esm/LinearGenomeView/components/TrackLabelRefNameWarning.d.ts +22 -0
  121. package/esm/LinearGenomeView/components/TrackLabelRefNameWarning.js +46 -0
  122. package/esm/LinearGenomeView/components/TrackRenderingContainer.d.ts +1 -1
  123. package/esm/LinearGenomeView/components/TrackRenderingContainer.js +72 -18
  124. package/esm/LinearGenomeView/components/TracksContainer.d.ts +1 -1
  125. package/esm/LinearGenomeView/components/TracksContainer.js +65 -32
  126. package/esm/LinearGenomeView/components/VerticalGuide.d.ts +1 -1
  127. package/esm/LinearGenomeView/components/VerticalGuide.js +6 -13
  128. package/esm/LinearGenomeView/components/ZoomButton.d.ts +7 -0
  129. package/esm/LinearGenomeView/components/ZoomButton.js +22 -0
  130. package/esm/LinearGenomeView/components/ZoomTransform.d.ts +9 -0
  131. package/esm/LinearGenomeView/components/ZoomTransform.js +33 -0
  132. package/esm/LinearGenomeView/components/defaultMotifs.d.ts +1 -0
  133. package/esm/LinearGenomeView/components/defaultMotifs.js +22 -0
  134. package/esm/LinearGenomeView/components/fetchSequence.d.ts +7 -8
  135. package/esm/LinearGenomeView/components/fetchSequence.js +17 -9
  136. package/esm/LinearGenomeView/components/recentLocationsMenu.d.ts +15 -0
  137. package/esm/LinearGenomeView/components/recentLocationsMenu.js +36 -0
  138. package/esm/LinearGenomeView/components/searchModes.d.ts +13 -0
  139. package/esm/LinearGenomeView/components/searchModes.js +22 -0
  140. package/esm/LinearGenomeView/components/trackLabelMenuItems.d.ts +7 -0
  141. package/esm/LinearGenomeView/components/trackLabelMenuItems.js +67 -0
  142. package/esm/LinearGenomeView/components/useRangeSelect.d.ts +11 -66
  143. package/esm/LinearGenomeView/components/useRangeSelect.js +82 -91
  144. package/esm/LinearGenomeView/components/useSearchBoxPrefs.d.ts +26 -0
  145. package/esm/LinearGenomeView/components/useSearchBoxPrefs.js +58 -0
  146. package/esm/LinearGenomeView/components/useSideScroll.d.ts +1 -1
  147. package/esm/LinearGenomeView/components/useSideScroll.js +87 -19
  148. package/esm/LinearGenomeView/components/util.d.ts +20 -6
  149. package/esm/LinearGenomeView/components/util.js +27 -6
  150. package/esm/LinearGenomeView/consts.d.ts +7 -2
  151. package/esm/LinearGenomeView/consts.js +18 -2
  152. package/esm/LinearGenomeView/flyTo.d.ts +38 -0
  153. package/esm/LinearGenomeView/flyTo.js +135 -0
  154. package/esm/LinearGenomeView/headerFit.d.ts +27 -0
  155. package/esm/LinearGenomeView/headerFit.js +59 -0
  156. package/esm/LinearGenomeView/index.d.ts +31 -2
  157. package/esm/LinearGenomeView/index.js +8 -5
  158. package/esm/LinearGenomeView/initKeys.d.ts +13 -0
  159. package/esm/LinearGenomeView/initKeys.js +104 -0
  160. package/esm/LinearGenomeView/keyboardHandler.d.ts +7 -0
  161. package/esm/LinearGenomeView/keyboardHandler.js +10 -0
  162. package/esm/LinearGenomeView/lazyDialogs.d.ts +6 -10
  163. package/esm/LinearGenomeView/lazyDialogs.js +3 -3
  164. package/esm/LinearGenomeView/linkedViewSync.d.ts +20 -0
  165. package/esm/LinearGenomeView/linkedViewSync.js +52 -0
  166. package/esm/LinearGenomeView/menuItems.d.ts +27 -2
  167. package/esm/LinearGenomeView/menuItems.js +137 -101
  168. package/esm/LinearGenomeView/model.d.ts +1536 -146
  169. package/esm/LinearGenomeView/model.js +2348 -515
  170. package/esm/LinearGenomeView/sharedScaleContainer.d.ts +18 -0
  171. package/esm/LinearGenomeView/sharedScaleContainer.js +17 -0
  172. package/esm/LinearGenomeView/showRegionsWithUndo.d.ts +35 -0
  173. package/esm/LinearGenomeView/showRegionsWithUndo.js +44 -0
  174. package/esm/LinearGenomeView/stickyChrome.d.ts +19 -0
  175. package/esm/LinearGenomeView/stickyChrome.js +30 -0
  176. package/esm/LinearGenomeView/svgcomponents/SVGGridlines.d.ts +1 -1
  177. package/esm/LinearGenomeView/svgcomponents/SVGGridlines.js +15 -16
  178. package/esm/LinearGenomeView/svgcomponents/SVGHeader.d.ts +2 -3
  179. package/esm/LinearGenomeView/svgcomponents/SVGHeader.js +45 -33
  180. package/esm/LinearGenomeView/svgcomponents/SVGHighlights.d.ts +5 -0
  181. package/esm/LinearGenomeView/svgcomponents/SVGHighlights.js +26 -0
  182. package/esm/LinearGenomeView/svgcomponents/SVGHighlightsOverlay.d.ts +5 -0
  183. package/esm/LinearGenomeView/svgcomponents/SVGHighlightsOverlay.js +13 -0
  184. package/esm/LinearGenomeView/svgcomponents/SVGLinearGenomeView.d.ts +1 -3
  185. package/esm/LinearGenomeView/svgcomponents/SVGLinearGenomeView.js +77 -41
  186. package/esm/LinearGenomeView/svgcomponents/SVGRegionSeparators.d.ts +1 -1
  187. package/esm/LinearGenomeView/svgcomponents/SVGRegionSeparators.js +23 -3
  188. package/esm/LinearGenomeView/svgcomponents/SVGRowHeader.d.ts +8 -0
  189. package/esm/LinearGenomeView/svgcomponents/SVGRowHeader.js +37 -0
  190. package/esm/LinearGenomeView/svgcomponents/SVGRuler.d.ts +3 -3
  191. package/esm/LinearGenomeView/svgcomponents/SVGRuler.js +64 -36
  192. package/esm/LinearGenomeView/svgcomponents/SVGScalebar.d.ts +1 -1
  193. package/esm/LinearGenomeView/svgcomponents/SVGScalebar.js +7 -5
  194. package/esm/LinearGenomeView/svgcomponents/SVGTrackLabel.d.ts +5 -3
  195. package/esm/LinearGenomeView/svgcomponents/SVGTrackLabel.js +43 -7
  196. package/esm/LinearGenomeView/svgcomponents/SVGTracks.d.ts +8 -14
  197. package/esm/LinearGenomeView/svgcomponents/SVGTracks.js +16 -21
  198. package/esm/LinearGenomeView/svgcomponents/SVGView.d.ts +18 -0
  199. package/esm/LinearGenomeView/svgcomponents/SVGView.js +13 -0
  200. package/esm/LinearGenomeView/svgcomponents/figureContract.d.ts +45 -0
  201. package/esm/LinearGenomeView/svgcomponents/figureContract.js +130 -0
  202. package/esm/LinearGenomeView/svgcomponents/renderViewTracks.d.ts +76 -0
  203. package/esm/LinearGenomeView/svgcomponents/renderViewTracks.js +66 -0
  204. package/esm/LinearGenomeView/svgcomponents/useViewSvgFigure.d.ts +70 -0
  205. package/esm/LinearGenomeView/svgcomponents/useViewSvgFigure.js +263 -0
  206. package/esm/LinearGenomeView/svgcomponents/util.d.ts +92 -5
  207. package/esm/LinearGenomeView/svgcomponents/util.js +236 -3
  208. package/esm/LinearGenomeView/types.d.ts +17 -31
  209. package/esm/LinearGenomeView/types.js +1 -0
  210. package/esm/LinearGenomeView/util.d.ts +332 -12
  211. package/esm/LinearGenomeView/util.js +606 -62
  212. package/esm/MultiLevelRubberband/MultiLevelRubberband.d.ts +6 -0
  213. package/esm/MultiLevelRubberband/MultiLevelRubberband.js +37 -0
  214. package/esm/MultiLevelRubberband/VerticalGuide.d.ts +6 -0
  215. package/esm/MultiLevelRubberband/VerticalGuide.js +21 -0
  216. package/esm/MultiLevelRubberband/index.d.ts +2 -0
  217. package/esm/MultiLevelRubberband/index.js +1 -0
  218. package/esm/MultiLevelRubberband/types.d.ts +6 -0
  219. package/esm/MultiLevelRubberband/useRangeSelect.d.ts +24 -0
  220. package/esm/MultiLevelRubberband/useRangeSelect.js +136 -0
  221. package/esm/displayKitTests/perRegionTestEnv.d.ts +1263 -0
  222. package/esm/displayKitTests/perRegionTestEnv.js +141 -0
  223. package/esm/index.d.ts +112 -1237
  224. package/esm/index.js +102 -24
  225. package/esm/lazyPluginExports.d.ts +23 -0
  226. package/esm/lazyPluginExports.js +36 -0
  227. package/esm/searchUtils.d.ts +52 -11
  228. package/esm/searchUtils.js +317 -46
  229. package/esm/shared/RubberbandSpan.d.ts +15 -0
  230. package/esm/shared/RubberbandSpan.js +33 -0
  231. package/esm/shared/coordLabels.d.ts +40 -0
  232. package/esm/shared/coordLabels.js +119 -0
  233. package/package.json +18 -19
  234. package/esm/BaseLinearDisplay/SVGLegend.d.ts +0 -6
  235. package/esm/BaseLinearDisplay/SVGLegend.js +0 -17
  236. package/esm/BaseLinearDisplay/calculateSvgLegendWidth.d.ts +0 -2
  237. package/esm/BaseLinearDisplay/calculateSvgLegendWidth.js +0 -11
  238. package/esm/BaseLinearDisplay/components/BaseLinearDisplay.d.ts +0 -8
  239. package/esm/BaseLinearDisplay/components/BaseLinearDisplay.js +0 -53
  240. package/esm/BaseLinearDisplay/components/Block.d.ts +0 -14
  241. package/esm/BaseLinearDisplay/components/Block.js +0 -45
  242. package/esm/BaseLinearDisplay/components/BlockErrorMessage.d.ts +0 -7
  243. package/esm/BaseLinearDisplay/components/BlockErrorMessage.js +0 -22
  244. package/esm/BaseLinearDisplay/components/BlockMsg.d.ts +0 -6
  245. package/esm/BaseLinearDisplay/components/BlockMsg.js +0 -26
  246. package/esm/BaseLinearDisplay/components/CollapseIntronsDialog/CollapseIntronsDialog.d.ts +0 -9
  247. package/esm/BaseLinearDisplay/components/CollapseIntronsDialog/CollapseIntronsDialog.js +0 -42
  248. package/esm/BaseLinearDisplay/components/CollapseIntronsDialog/TranscriptTable.d.ts +0 -11
  249. package/esm/BaseLinearDisplay/components/CollapseIntronsDialog/TranscriptTable.js +0 -40
  250. package/esm/BaseLinearDisplay/components/CollapseIntronsDialog/util.d.ts +0 -19
  251. package/esm/BaseLinearDisplay/components/CollapseIntronsDialog/util.js +0 -45
  252. package/esm/BaseLinearDisplay/components/FloatingLabels.d.ts +0 -5
  253. package/esm/BaseLinearDisplay/components/FloatingLabels.js +0 -75
  254. package/esm/BaseLinearDisplay/components/FloatingLegend.d.ts +0 -8
  255. package/esm/BaseLinearDisplay/components/FloatingLegend.js +0 -43
  256. package/esm/BaseLinearDisplay/components/LinearBlocks.d.ts +0 -5
  257. package/esm/BaseLinearDisplay/components/LinearBlocks.js +0 -23
  258. package/esm/BaseLinearDisplay/components/LoadingOverlay.d.ts +0 -5
  259. package/esm/BaseLinearDisplay/components/LoadingOverlay.js +0 -49
  260. package/esm/BaseLinearDisplay/components/MaxHeightReachedIndicator.d.ts +0 -4
  261. package/esm/BaseLinearDisplay/components/MaxHeightReachedIndicator.js +0 -26
  262. package/esm/BaseLinearDisplay/components/MenuPage.d.ts +0 -8
  263. package/esm/BaseLinearDisplay/components/MenuPage.js +0 -24
  264. package/esm/BaseLinearDisplay/components/NonBlockCanvasDisplayComponent.d.ts +0 -21
  265. package/esm/BaseLinearDisplay/components/NonBlockCanvasDisplayComponent.js +0 -52
  266. package/esm/BaseLinearDisplay/components/RenderedBlocks.d.ts +0 -12
  267. package/esm/BaseLinearDisplay/components/RenderedBlocks.js +0 -22
  268. package/esm/BaseLinearDisplay/components/ServerSideRenderedBlockContent.d.ts +0 -12
  269. package/esm/BaseLinearDisplay/components/ServerSideRenderedBlockContent.js +0 -18
  270. package/esm/BaseLinearDisplay/components/TooLargeMessage.d.ts +0 -10
  271. package/esm/BaseLinearDisplay/components/TooLargeMessage.js +0 -20
  272. package/esm/BaseLinearDisplay/components/Tooltip.d.ts +0 -13
  273. package/esm/BaseLinearDisplay/components/Tooltip.js +0 -22
  274. package/esm/BaseLinearDisplay/components/types.d.ts +0 -1
  275. package/esm/BaseLinearDisplay/components/util.d.ts +0 -21
  276. package/esm/BaseLinearDisplay/components/util.js +0 -107
  277. package/esm/BaseLinearDisplay/index.d.ts +0 -17
  278. package/esm/BaseLinearDisplay/index.js +0 -13
  279. package/esm/BaseLinearDisplay/model.d.ts +0 -1197
  280. package/esm/BaseLinearDisplay/model.js +0 -432
  281. package/esm/BaseLinearDisplay/models/FeatureDensityMixin.d.ts +0 -32
  282. package/esm/BaseLinearDisplay/models/FeatureDensityMixin.js +0 -113
  283. package/esm/BaseLinearDisplay/models/NonBlockCanvasDisplayMixin.d.ts +0 -26
  284. package/esm/BaseLinearDisplay/models/NonBlockCanvasDisplayMixin.js +0 -57
  285. package/esm/BaseLinearDisplay/models/SvgFloatingLabels.d.ts +0 -8
  286. package/esm/BaseLinearDisplay/models/SvgFloatingLabels.js +0 -19
  287. package/esm/BaseLinearDisplay/models/TrackHeightMixin.d.ts +0 -11
  288. package/esm/BaseLinearDisplay/models/TrackHeightMixin.js +0 -31
  289. package/esm/BaseLinearDisplay/models/autorunFeatureDensityStats.d.ts +0 -2
  290. package/esm/BaseLinearDisplay/models/autorunFeatureDensityStats.js +0 -29
  291. package/esm/BaseLinearDisplay/models/configSchema.d.ts +0 -29
  292. package/esm/BaseLinearDisplay/models/configSchema.js +0 -33
  293. package/esm/BaseLinearDisplay/models/serverSideRenderedBlock.d.ts +0 -98
  294. package/esm/BaseLinearDisplay/models/serverSideRenderedBlock.js +0 -242
  295. package/esm/BaseLinearDisplay/models/util.d.ts +0 -9
  296. package/esm/BaseLinearDisplay/models/util.js +0 -43
  297. package/esm/BaseLinearDisplay/renderSvg.d.ts +0 -3
  298. package/esm/BaseLinearDisplay/renderSvg.js +0 -63
  299. package/esm/BaseLinearDisplay/types.d.ts +0 -43
  300. package/esm/BaseLinearDisplay/types.js +0 -3
  301. package/esm/BaseLinearDisplay/util.d.ts +0 -16
  302. package/esm/BaseLinearDisplay/util.js +0 -71
  303. package/esm/BasicTrack/configSchema.d.ts +0 -85
  304. package/esm/BasicTrack/configSchema.js +0 -8
  305. package/esm/BasicTrack/index.d.ts +0 -2
  306. package/esm/BasicTrack/index.js +0 -12
  307. package/esm/LinearBareDisplay/configSchema.d.ts +0 -32
  308. package/esm/LinearBareDisplay/configSchema.js +0 -11
  309. package/esm/LinearBareDisplay/index.d.ts +0 -4
  310. package/esm/LinearBareDisplay/index.js +0 -20
  311. package/esm/LinearBareDisplay/model.d.ts +0 -1196
  312. package/esm/LinearBareDisplay/model.js +0 -32
  313. package/esm/LinearBasicDisplay/components/LinearBasicDisplayComponent.d.ts +0 -5
  314. package/esm/LinearBasicDisplay/components/LinearBasicDisplayComponent.js +0 -9
  315. package/esm/LinearBasicDisplay/configSchema.d.ts +0 -32
  316. package/esm/LinearBasicDisplay/configSchema.js +0 -11
  317. package/esm/LinearBasicDisplay/index.d.ts +0 -4
  318. package/esm/LinearBasicDisplay/index.js +0 -20
  319. package/esm/LinearBasicDisplay/model.d.ts +0 -1391
  320. package/esm/LinearBasicDisplay/model.js +0 -158
  321. package/esm/LinearFeatureDisplay/components/AddFiltersDialog.d.ts +0 -9
  322. package/esm/LinearFeatureDisplay/components/AddFiltersDialog.js +0 -58
  323. package/esm/LinearFeatureDisplay/components/SetMaxHeightDialog.d.ts +0 -8
  324. package/esm/LinearFeatureDisplay/components/SetMaxHeightDialog.js +0 -26
  325. package/esm/LinearFeatureDisplay/configSchema.d.ts +0 -32
  326. package/esm/LinearFeatureDisplay/configSchema.js +0 -11
  327. package/esm/LinearFeatureDisplay/index.d.ts +0 -3
  328. package/esm/LinearFeatureDisplay/index.js +0 -2
  329. package/esm/LinearFeatureDisplay/model.d.ts +0 -1443
  330. package/esm/LinearFeatureDisplay/model.js +0 -305
  331. package/esm/LinearGenomeView/SVGErrorBox.d.ts +0 -5
  332. package/esm/LinearGenomeView/SVGErrorBox.js +0 -4
  333. package/esm/LinearGenomeView/components/ImportFormRefNameAutocomplete.d.ts +0 -11
  334. package/esm/LinearGenomeView/components/ImportFormRefNameAutocomplete.js +0 -27
  335. package/esm/LinearGenomeView/components/RefNameAutocomplete/AutocompleteTextField.d.ts +0 -5
  336. package/esm/LinearGenomeView/components/RefNameAutocomplete/AutocompleteTextField.js +0 -13
  337. package/esm/LinearGenomeView/components/RefNameAutocomplete/EndAdornment.d.ts +0 -3
  338. package/esm/LinearGenomeView/components/RefNameAutocomplete/EndAdornment.js +0 -17
  339. package/esm/LinearGenomeView/components/RefNameAutocomplete/HelpDialog.d.ts +0 -3
  340. package/esm/LinearGenomeView/components/RefNameAutocomplete/HelpDialog.js +0 -8
  341. package/esm/LinearGenomeView/components/RefNameAutocomplete/index.d.ts +0 -16
  342. package/esm/LinearGenomeView/components/RefNameAutocomplete/index.js +0 -101
  343. package/esm/LinearGenomeView/components/RefNameAutocomplete/util.d.ts +0 -10
  344. package/esm/LinearGenomeView/components/RefNameAutocomplete/util.js +0 -45
  345. package/esm/LinearGenomeView/components/RubberbandSpan.d.ts +0 -15
  346. package/esm/LinearGenomeView/components/RubberbandSpan.js +0 -32
  347. package/esm/LinearGenomeView/components/RubberbandTooltip.d.ts +0 -5
  348. package/esm/LinearGenomeView/components/RubberbandTooltip.js +0 -17
  349. package/esm/LinearGenomeView/components/TrackLabelContainer.d.ts +0 -8
  350. package/esm/LinearGenomeView/components/TrackLabelContainer.js +0 -25
  351. package/esm/LinearGenomeView/components/useWheelScroll.d.ts +0 -7
  352. package/esm/LinearGenomeView/components/useWheelScroll.js +0 -69
  353. package/esm/LinearGenomeView/svgExportUtil.d.ts +0 -9
  354. package/esm/LinearGenomeView/svgExportUtil.js +0 -6
  355. package/esm/LinearGenomeView/svgcomponents/SVGBackground.d.ts +0 -5
  356. package/esm/LinearGenomeView/svgcomponents/SVGBackground.js +0 -7
  357. /package/esm/{BaseLinearDisplay/components → MultiLevelRubberband}/types.js +0 -0
@@ -1,186 +1,774 @@
1
1
  import { lazy } from 'react';
2
2
  import { getConf } from '@jbrowse/core/configuration';
3
- import { BaseViewModel } from '@jbrowse/core/pluggableElementTypes/models';
3
+ import { BaseViewModel, HighlightsMixin, } from '@jbrowse/core/pluggableElementTypes/models';
4
4
  import { VIEW_HEADER_HEIGHT } from '@jbrowse/core/ui';
5
- import { assembleLocString, clamp, findLast, getBpDisplayStr, getSession, isSessionModelWithWidgets, localStorageGetBoolean, localStorageGetItem, measureText, springAnimate, sum, } from '@jbrowse/core/util';
6
- import { bpToPx, moveTo, pxToBp } from '@jbrowse/core/util/Base1DUtils';
7
- import Base1DView from '@jbrowse/core/util/Base1DViewModel';
5
+ import { assembleLocString, clamp, getBpDisplayStr, getDialogHost, getSession, getTickDisplayStr, isSessionModelWithWidgets, localStorageGetBoolean, localStorageGetItem, springAnimate, sum, } from '@jbrowse/core/util';
6
+ import { bpToLinearBp, bpToPx, computeMoveToLayout, createOverviewLayout, getLayoutHighlightCoords, getOverviewRegionPxSpan, moveTo, pxToBp, } from '@jbrowse/core/util/Base1DUtils';
7
+ import { wholeBaseRegions } from '@jbrowse/core/util/blockTypes';
8
8
  import calculateDynamicBlocks from '@jbrowse/core/util/calculateDynamicBlocks';
9
9
  import calculateStaticBlocks from '@jbrowse/core/util/calculateStaticBlocks';
10
- import { getParentRenderProps, hideTrackGeneric, showTrackGeneric, toggleTrackGeneric, } from '@jbrowse/core/util/tracks';
10
+ import { tickLabelsWorthDrawing } from '@jbrowse/core/util/tickLabels';
11
+ import { hideTrackGeneric, showTrackGeneric, toggleTrackGeneric, } from '@jbrowse/core/util/tracks';
11
12
  import { ElementId } from '@jbrowse/core/util/types/mst';
12
- import { cast, getParent, getSnapshot, types } from '@jbrowse/mobx-state-tree';
13
- import { isSessionWithMultipleViews } from '@jbrowse/product-core';
13
+ import { computeViewStatus } from '@jbrowse/core/util/viewStatus';
14
+ import { cast, getParent, hasParent, isAlive, types, } from '@jbrowse/mobx-state-tree';
14
15
  import { when } from 'mobx';
15
- import { handleSelectedRegion } from "../searchUtils.js";
16
- import { doAfterAttach } from "./afterAttach.js";
17
- import Header from "./components/Header.js";
18
- import MiniControls from "./components/MiniControls.js";
19
- import { HEADER_BAR_HEIGHT, HEADER_OVERVIEW_HEIGHT, INTER_REGION_PADDING_WIDTH, RESIZE_HANDLE_HEIGHT, SCALE_BAR_HEIGHT, } from "./consts.js";
20
- import { setupKeyboardHandler } from "./keyboardHandler.js";
21
- import { buildMenuItems, buildRubberBandMenuItems, buildRubberbandClickMenuItems, rewriteOnClicks, } from "./menuItems.js";
22
- import { calculateVisibleLocStrings, expandRegion, generateLocations, parseLocStrings, } from "./util.js";
23
- const SearchResultsDialog = lazy(() => import("./components/SearchResultsDialog.js"));
16
+ import { handleSelectedRegion } from '../searchUtils.js';
17
+ import { doAfterAttach } from './afterAttach.js';
18
+ import { shouldSwapTracks } from './components/util.js';
19
+ import { HEADER_BAR_HEIGHT, HEADER_OVERVIEW_HEIGHT, MIN_BP_PER_PX, MINIMIZED_TRACK_HEIGHT, RESIZE_HANDLE_HEIGHT, SCALE_BAR_HEIGHT, SHOW_ALL_REGIONS_FILL, TRACK_OUTLINE_BORDER, TRACK_TOP_GAP, } from './consts.js';
20
+ import { planFlight } from './flyTo.js';
21
+ import { setupKeyboardHandler } from './keyboardHandler.js';
22
+ import { buildMenuItems, buildRubberBandMenuItems, buildRubberbandClickMenuItems, } from './menuItems.js';
23
+ import { sharedScaleContainerOf } from './sharedScaleContainer.js';
24
+ import { calculateVisibleLocStrings, cytobandLabelGutterWidth, expandRegion, fitAllRegionsWindow, generateLocations, getScalebarRefNameLabels, regionsOrientation, groupContiguousBlocks, labelFitsInBlock, makeBlockTicks, runRefNameLabelPx, tickLabelWidth, } from './util.js';
25
+ // lazies
26
+ const SearchResultsDialog = lazy(() => import('./components/SearchResultsDialog.js'));
27
+ /**
28
+ * Calculate the offsetPx needed to center content within a viewport.
29
+ * Returns a negative offset when content is smaller than viewport (padding on left).
30
+ */
24
31
  function getCenteredOffsetPx(contentPx, viewportPx) {
25
32
  return Math.round(contentPx / 2 - viewportPx / 2);
26
33
  }
34
+ /**
35
+ * Resolve a region's refName to the assembly's canonical name, falling back to
36
+ * the raw refName when the assemblyName is missing or unknown (so highlights
37
+ * authored without an assembly still render in single-assembly views).
38
+ */
39
+ function resolveCanonicalRefName(self, region) {
40
+ const { assemblyManager } = getSession(self);
41
+ const asm = region.assemblyName
42
+ ? assemblyManager.get(region.assemblyName)
43
+ : undefined;
44
+ return asm?.getCanonicalRefName2(region.refName) ?? region.refName;
45
+ }
46
+ // bpPerPx deltas smaller than this are treated as no zoom change, avoiding
47
+ // pointless offset re-anchoring on micro-steps
48
+ const BP_PER_PX_EPSILON = 0.000001;
49
+ // px of the rightmost content kept on-screen at max scroll-right, so the genome
50
+ // can't be scrolled entirely off the left edge. Shared by maxOffset and
51
+ // getSelectedRegions' clamp so the two bounds can't drift apart.
52
+ const MAX_OFFSET_PADDING_PX = 10;
53
+ // px of the leftmost content kept on-screen at max scroll-left, mirroring
54
+ // MAX_OFFSET_PADDING_PX at the other end
55
+ const MIN_OFFSET_PADDING_PX = 30;
56
+ // whether two BlockSets cover the same blocks, by key and in order
57
+ function sameBlockKeys(a, b) {
58
+ return (a.blocks.length === b.blocks.length &&
59
+ a.blocks.every((block, i) => block.key === b.blocks[i].key));
60
+ }
61
+ /**
62
+ * Resolve a NavLocation's refName to the assembly's canonical name, falling
63
+ * back to the raw refName (and the view's default assembly) when the assembly
64
+ * is missing or unknown.
65
+ */
66
+ function navLocationRefName(assemblyManager, defaultAssemblyName, location) {
67
+ return (assemblyManager
68
+ .get(location.assemblyName || defaultAssemblyName)
69
+ ?.getCanonicalRefName2(location.refName) || location.refName);
70
+ }
71
+ /**
72
+ * Resolve one end of a navigation range to the displayedRegion index that
73
+ * contains it plus the bp offset into that region. `side` selects which edge of
74
+ * the (grow-expanded) interval anchors the offset — 'left' for the range start,
75
+ * 'right' for the range end — accounting for reversed regions. Omitted
76
+ * start/end default to the first occurrence of the refName; containment is then
77
+ * resolved against those raw coords, and only the region it picks bounds `grow`.
78
+ */
79
+ function resolveNavEndpoint({ location, assemblyManager, defaultAssemblyName, side, displayedRegions, grow, }) {
80
+ const refName = navLocationRefName(assemblyManager, defaultAssemblyName, location);
81
+ const first = displayedRegions.find(r => r.refName === refName);
82
+ if (!first) {
83
+ throw new Error(`could not find a region with refName "${refName}"`);
84
+ }
85
+ const rawStart = location.start ?? first.start;
86
+ const rawEnd = location.end ?? first.end;
87
+ const index = displayedRegions.findIndex(r => r.refName === refName &&
88
+ rawStart >= r.start &&
89
+ rawStart <= r.end &&
90
+ rawEnd <= r.end &&
91
+ rawEnd >= r.start);
92
+ if (index === -1) {
93
+ throw new Error(`could not find a region that contained "${assembleLocString(location)}"`);
94
+ }
95
+ const r = displayedRegions[index];
96
+ // grow AFTER resolving the containing region, and clamp to that region — not
97
+ // to the first occurrence of the refName. Growing first clamped the padded
98
+ // interval to a region that need not be the one containing it, so on a
99
+ // duplicated refName (collapsed introns, a refName displayed twice) the
100
+ // clamp dragged an endpoint back to the wrong region and the containment
101
+ // search below then found nothing: `navTo(loc)` succeeded where
102
+ // `navTo(loc, 0.2)` threw for the very same loc.
103
+ const { start, end } = grow
104
+ ? expandRegion(rawStart, rawEnd, grow, r.start, r.end)
105
+ : { start: rawStart, end: rawEnd };
106
+ const leftEdge = r.reversed ? r.end - end : start - r.start;
107
+ const rightEdge = r.reversed ? r.end - start : end - r.start;
108
+ return { index, offset: side === 'left' ? leftEdge : rightEdge };
109
+ }
110
+ // Widget id of the hierarchical track selector. One id, shared by the two
111
+ // helpers below, since `isTrackSelectorVisible` identifies the widget by id
112
+ // rather than by holding a reference to it.
113
+ const TRACK_SELECTOR_WIDGET_ID = 'hierarchicalTrackSelector';
114
+ /**
115
+ * Add (or reuse) the hierarchical track-selector widget for this view. Returns
116
+ * the widget alongside the (narrowed) session so callers can show/hide it.
117
+ */
118
+ function openTrackSelectorWidget(self) {
119
+ const session = getSession(self);
120
+ if (isSessionModelWithWidgets(session)) {
121
+ const selector = session.addWidget('HierarchicalTrackSelectorWidget', TRACK_SELECTOR_WIDGET_ID, { view: self });
122
+ return { session, selector };
123
+ }
124
+ throw new Error('session does not support widgets');
125
+ }
126
+ /**
127
+ * Whether the track selector is the drawer widget on screen right now. One
128
+ * spelling for the `isTrackSelectorOpen` getter (which lights up the header
129
+ * button) and for `toggleTrackSelector` (which decides hide-vs-show) — the
130
+ * `!minimized` term is exactly the kind of thing that goes into one of two
131
+ * hand-written copies and not the other.
132
+ */
133
+ function isTrackSelectorVisible(self) {
134
+ const session = getSession(self);
135
+ return (isSessionModelWithWidgets(session) &&
136
+ session.visibleWidget?.id === TRACK_SELECTOR_WIDGET_ID &&
137
+ !session.minimized);
138
+ }
139
+ /**
140
+ * #stateModel LinearGenomeView
141
+ * #category view
142
+ *
143
+ * #example
144
+ * A `LinearGenomeView` is what you hand-author under `defaultSession.views`. The
145
+ * `init` shorthand fills in `displayedRegions`/`bpPerPx`/`offsetPx` for you:
146
+ * ```js
147
+ * defaultSession: {
148
+ * name: 'My session',
149
+ * views: [
150
+ * {
151
+ * type: 'LinearGenomeView',
152
+ * // plain persisted props sit alongside init, not inside it
153
+ * colorByCDS: true,
154
+ * init: {
155
+ * assembly: 'hg38',
156
+ * loc: 'chr1:1,000,000-1,100,000',
157
+ * tracks: ['genes', 'alignments'],
158
+ * },
159
+ * },
160
+ * ],
161
+ * }
162
+ * ```
163
+ * `init` holds only keys that need on-attach resolution — also `tracklist`,
164
+ * `nav`, `highlight` (see the `init` property below). Plain view props like
165
+ * `colorByCDS`, `showAminoAcids`, `showCenterLine`, `trackLabels`,
166
+ * `showHighlightChips` are set
167
+ * directly on the view (MST restores them natively).
168
+ * At runtime the same model is driven imperatively — every property and action
169
+ * below is reachable on `viewState.session.views[0]`:
170
+ * ```js
171
+ * const view = viewState.session.views[0]
172
+ * await view.navToLocString('chr1:2,000,000-2,100,000')
173
+ * view.showTrack('alignments')
174
+ * view.zoomTo(view.bpPerPx * 2) // zoom out 2x
175
+ * ```
176
+ */
27
177
  export function stateModelFactory(pluginManager) {
28
178
  return types
29
- .compose('LinearGenomeView', BaseViewModel, types.model({
179
+ .compose('LinearGenomeView', BaseViewModel, HighlightsMixin(), types.model({
180
+ /**
181
+ * #property
182
+ */
30
183
  id: ElementId,
184
+ /**
185
+ * #property
186
+ * this is a string instead of the const literal 'LinearGenomeView' to
187
+ * reduce some typescripting strictness, but you should pass the string
188
+ * 'LinearGenomeView' to the model explicitly
189
+ */
31
190
  type: types.literal('LinearGenomeView'),
32
- offsetPx: 0,
33
- bpPerPx: 1,
34
- displayedRegions: types.optional(types.frozen(), []),
191
+ /**
192
+ * #property
193
+ * Left edge of the viewport, in linearized bp — the concatenated
194
+ * `displayedRegions` space that `offsetPx` indexes, which carries no
195
+ * inter-region padding, so the two differ only by `bpPerPx`. May be
196
+ * negative, which is the view scrolled past the left end.
197
+ *
198
+ * The viewport is stored as the genomic WINDOW it frames rather than as
199
+ * the pixels that framed it, because pixels mean nothing without the
200
+ * width they were measured at and a snapshot does not carry one. Storing
201
+ * them anyway is why a session authored in a 1000px window used to open
202
+ * at 500px showing half the region its author was looking at, while the
203
+ * same location as a `&loc=` opened correctly — the two ways to share a
204
+ * view disagreed, and only the one that stores intent was right.
205
+ */
206
+ windowStartBp: types.stripDefault(types.number, 0),
207
+ /**
208
+ * #property
209
+ * Width of the viewport in bp. Zero means "not established yet": no
210
+ * width has been measured, so there is nothing to divide by. The first
211
+ * measure fills it in, and `bpPerPx` is `windowWidthBp / width` from
212
+ * then on.
213
+ */
214
+ windowWidthBp: types.stripDefault(types.number, 0),
215
+ /**
216
+ * #property
217
+ * MIGRATION ONLY, and safe to delete once pre-window sessions are no
218
+ * longer in circulation.
219
+ *
220
+ * A snapshot written before the window was stored carries `offsetPx` and
221
+ * `bpPerPx` but not the width they were measured at, so the window they
222
+ * framed cannot be recovered. `windowStartBp` can (it is
223
+ * `offsetPx * bpPerPx`, no width needed); the width in bp cannot. This
224
+ * carries the old `bpPerPx` to the first measure, which adopts it at
225
+ * whatever width arrives — exactly what the old code did — and clears
226
+ * this. So an old link keeps its old behavior rather than being
227
+ * reinterpreted, and everything authored since restores its window.
228
+ */
229
+ legacyBpPerPx: types.stripDefault(types.number, 0),
230
+ /**
231
+ * #property
232
+ * currently displayed regions, can be a single chromosome, arbitrary
233
+ * subsections, or the entire set of chromosomes in the genome, but it not
234
+ * advised to use the entire set of chromosomes if your assembly is very
235
+ * fragmented
236
+ */
237
+ displayedRegions: types.stripDefault(types.frozen(), []),
238
+ /**
239
+ * #property
240
+ * array of currently displayed tracks state models instances
241
+ */
35
242
  tracks: types.array(pluginManager.pluggableMstType('track', 'stateModel')),
36
- hideHeader: false,
37
- hideHeaderOverview: false,
38
- hideNoTracksActive: false,
39
- trackSelectorType: types.optional(types.enumeration(['hierarchical']), 'hierarchical'),
243
+ /**
244
+ * #property
245
+ * drop the header bar entirely — location box, navigation buttons and
246
+ * overview
247
+ */
248
+ hideHeader: types.stripDefault(types.boolean, false),
249
+ /**
250
+ * #property
251
+ * keep the header, drop the whole-chromosome overview strip below it
252
+ */
253
+ hideHeaderOverview: types.stripDefault(types.boolean, false),
254
+ /**
255
+ * #property
256
+ * suppress the "No tracks active" placeholder, for an embed that opens
257
+ * with no tracks on purpose
258
+ */
259
+ hideNoTracksActive: types.stripDefault(types.boolean, false),
260
+ /**
261
+ * #property
262
+ * vestigial: the hierarchical selector is the only one that exists, so
263
+ * this value is ignored. Retained because saved sessions and configs
264
+ * persist it.
265
+ */
266
+ trackSelectorType: types.stripDefault(types.enumeration(['hierarchical']), 'hierarchical'),
267
+ /**
268
+ * #property
269
+ * show the "center line"
270
+ */
40
271
  showCenterLine: types.optional(types.boolean, () => localStorageGetBoolean('lgv-showCenterLine', false)),
41
- showCytobandsSetting: types.optional(types.boolean, () => localStorageGetBoolean('lgv-showCytobands', true)),
42
- trackLabels: types.optional(types.string, () => localStorageGetItem('lgv-trackLabels') || ''),
43
- showGridlines: true,
44
- highlight: types.optional(types.array(types.frozen()), []),
45
- highlightsVisible: types.optional(types.boolean, true),
46
- labelsVisible: types.optional(types.boolean, true),
272
+ /**
273
+ * #property
274
+ * whether to show the "cytobands" in the overview scale bar (the
275
+ * resolved, capability-gated value is the `effectiveShowCytobands`
276
+ * getter)
277
+ */
278
+ showCytobands: types.optional(types.boolean, () => localStorageGetBoolean('lgv-showCytobands', true)),
279
+ /**
280
+ * #property
281
+ * how to display the track labels, can be "overlapping", "offset", or
282
+ * "hidden", or empty string "" (which results in the
283
+ * LinearGenomeViewPlugin config default being used). the resolved value
284
+ * is the `effectiveTrackLabels` getter. see LinearGenomeViewPlugin
285
+ * https://jbrowse.org/jb2/docs/config/lineargenomeviewplugin/ docs for
286
+ * how conf is used
287
+ */
288
+ trackLabels: types.optional(types.string, () => localStorageGetItem('lgv-trackLabels') ?? ''),
289
+ /**
290
+ * #property
291
+ * show the "gridlines" in the track area
292
+ */
293
+ showGridlines: types.stripDefault(types.boolean, true),
294
+ /**
295
+ * #property
296
+ * controls whether highlight/bookmark chip labels are shown inline
297
+ */
298
+ labelsVisible: types.stripDefault(types.boolean, true),
299
+ /**
300
+ * #property
301
+ * color CDS segments by reading frame
302
+ */
47
303
  colorByCDS: types.optional(types.boolean, () => localStorageGetBoolean('lgv-colorByCDS', false)),
304
+ /**
305
+ * #property
306
+ * draw translated codons on coding features once zoomed in far enough:
307
+ * an alternating per-codon shading, and the amino acid letters on top of
308
+ * it at base-level zoom. Independent of `colorByCDS`, which only recolors
309
+ * the segments by frame.
310
+ */
311
+ showAminoAcids: types.optional(types.boolean, () => localStorageGetBoolean('lgv-showAminoAcids', true)),
312
+ /**
313
+ * #property
314
+ * show the track outlines
315
+ */
48
316
  showTrackOutlines: types.optional(types.boolean, () => localStorageGetBoolean('lgv-showTrackOutlines', true)),
317
+ /**
318
+ * #property
319
+ * when true, only the header and coordinate scalebar are rendered
320
+ */
321
+ scalebarOnly: types.stripDefault(types.boolean, false),
322
+ /**
323
+ * #property
324
+ * transient declarative launch spec: assembly + optional location,
325
+ * tracks, and highlights to apply once the view attaches. It is applied
326
+ * by the afterAttach autorun and then cleared (setInit(undefined)), so a
327
+ * saved session never retains it. Shared by all three launch surfaces —
328
+ * URL params, createViewState(), and session/config JSON. example:
329
+ * ```json
330
+ * {
331
+ * "assembly": "hg19",
332
+ * "loc": "chr1:1,000,000-2,000,000",
333
+ * "tracks": ["genes", "variants"]
334
+ * }
335
+ * ```
336
+ */
49
337
  init: types.frozen(),
50
338
  }))
51
- .volatile(() => ({
52
- volatileWidth: undefined,
53
- minimumBlockWidth: 3,
54
- draggingTrackId: undefined,
55
- lastTrackDragY: undefined,
56
- volatileError: undefined,
57
- scaleFactor: 1,
58
- targetBpPerPx: undefined,
59
- trackRefs: {},
60
- coarseDynamicBlocks: [],
61
- coarseTotalBp: 0,
62
- leftOffset: undefined,
63
- rightOffset: undefined,
64
- isScalebarRefNameMenuOpen: false,
65
- scalebarRefNameClickPending: false,
66
- volatileGuides: [],
67
- }))
339
+ .volatile(self => {
340
+ // typed locals so `unknown`/`Record` aren't narrowed to `undefined`/`{}`; inline
341
+ // type assertions here get stripped by no-unnecessary-type-assertion
342
+ const volatileError = undefined;
343
+ const trackRefs = {};
344
+ return {
345
+ /**
346
+ * #volatile
347
+ */
348
+ volatileWidth: undefined,
349
+ /**
350
+ * #volatile
351
+ */
352
+ minimumBlockWidth: 3,
353
+ /**
354
+ * #volatile
355
+ */
356
+ draggingTrackId: undefined,
357
+ /**
358
+ * #volatile
359
+ */
360
+ lastTrackDragY: undefined,
361
+ /**
362
+ * #volatile
363
+ */
364
+ volatileError,
365
+ /**
366
+ * #volatile
367
+ */
368
+ trackRefs,
369
+ /**
370
+ * #volatile
371
+ */
372
+ coarseDynamicBlocks: [],
373
+ /**
374
+ * #volatile
375
+ */
376
+ coarseTotalBp: 0,
377
+ /**
378
+ * #volatile
379
+ */
380
+ // What `bpPerPx` used to hold at creation — the snapshot's value, or
381
+ // the default of 1 — which cannot be read here now that it is derived
382
+ // and declared after volatile. It has to be a real scale rather than
383
+ // the not-yet-measured 0: the canvas displays lay out against
384
+ // `coarseBpPerPx` rather than the live one, and a zero there is a
385
+ // degenerate layout for the frame or two before the debounced autorun
386
+ // first writes it.
387
+ coarseBpPerPx: self.legacyBpPerPx || 1,
388
+ /**
389
+ * #volatile
390
+ */
391
+ leftOffset: undefined,
392
+ /**
393
+ * #volatile
394
+ */
395
+ rightOffset: undefined,
396
+ /**
397
+ * #volatile
398
+ */
399
+ isScalebarRefNameMenuOpen: false,
400
+ /**
401
+ * #volatile
402
+ */
403
+ scalebarRefNameClickPending: false,
404
+ /**
405
+ * #volatile
406
+ * temporary vertical guides that can be set by displays (e.g., LD display hover)
407
+ */
408
+ volatileGuides: [],
409
+ };
410
+ })
68
411
  .views(self => ({
412
+ // The viewport in pixels, derived from the window it frames — declared
413
+ // ahead of every other getter so that the ~300 reads of `self.bpPerPx` /
414
+ // `self.offsetPx` across the codebase go on resolving by name. Only the
415
+ // writes had to move, and they are six actions below.
416
+ /**
417
+ * #getter
418
+ * corresponds roughly to the zoom level, base-pairs per pixel.
419
+ *
420
+ * Zero before a width is measured, which is the same not-yet-measured
421
+ * sentinel `Base1DView` uses and which `displayedRegionsTotalPx` and the
422
+ * offset getters already guard for. Reads `volatileWidth` rather than the
423
+ * `width` getter on purpose: that one throws when unmeasured, and these
424
+ * are read from the first render.
425
+ */
426
+ get bpPerPx() {
427
+ const width = self.volatileWidth;
428
+ return width === undefined || width <= 0 || self.windowWidthBp <= 0
429
+ ? 0
430
+ : self.windowWidthBp / width;
431
+ },
432
+ /**
433
+ * #getter
434
+ * corresponds roughly to the horizontal scroll of the LGV
435
+ */
436
+ get offsetPx() {
437
+ const { bpPerPx } = this;
438
+ return bpPerPx <= 0 ? 0 : self.windowStartBp / bpPerPx;
439
+ },
440
+ /**
441
+ * #getter
442
+ * scroll-to-zoom is a global, personal preference resolved from the
443
+ * session; toggling it in any view applies everywhere
444
+ */
445
+ get scrollZoom() {
446
+ return getSession(self).scrollZoom;
447
+ },
448
+ /**
449
+ * #getter
450
+ */
69
451
  get pinnedTracks() {
70
452
  return self.tracks.filter(t => t.pinned);
71
453
  },
454
+ /**
455
+ * #getter
456
+ */
72
457
  get unpinnedTracks() {
73
458
  return self.tracks.filter(t => !t.pinned);
74
459
  },
75
- get trackLabelsSetting() {
460
+ /**
461
+ * #getter
462
+ * the effective track labels setting, resolving the stored `trackLabels`
463
+ * against the LinearGenomeViewPlugin config default
464
+ */
465
+ get effectiveTrackLabels() {
76
466
  const sessionSetting = getConf(getSession(self), [
77
467
  'LinearGenomeViewPlugin',
78
468
  'trackLabels',
79
469
  ]);
80
470
  return self.trackLabels || sessionSetting;
81
471
  },
472
+ /**
473
+ * #getter
474
+ */
82
475
  get width() {
83
476
  if (self.volatileWidth === undefined) {
84
477
  throw new Error('width undefined, make sure to check for model.initialized');
85
478
  }
86
479
  return self.volatileWidth;
87
480
  },
88
- get interRegionPaddingWidth() {
89
- return INTER_REGION_PADDING_WIDTH;
481
+ /**
482
+ * #getter
483
+ * width minus track outline borders (1px each side when shown)
484
+ */
485
+ get trackWidthPx() {
486
+ return this.width - (self.showTrackOutlines ? 2 : 0);
90
487
  },
488
+ /**
489
+ * #getter
490
+ */
91
491
  get assemblyNames() {
92
492
  return [...new Set(self.displayedRegions.map(r => r.assemblyName))];
93
493
  },
494
+ /**
495
+ * #getter
496
+ */
94
497
  get assemblyDisplayNames() {
95
498
  const { assemblyManager } = getSession(self);
96
- return this.assemblyNames.map(a => assemblyManager.get(a)?.displayName ?? a);
499
+ return this.assemblyNames.map(a => assemblyManager.getDisplayName(a));
97
500
  },
501
+ /**
502
+ * #getter
503
+ * checking if lgv is a 'top-level' view is used for toggling pin track
504
+ * capability, sticky positioning
505
+ */
98
506
  get isTopLevelView() {
99
507
  return getSession(self).views.some(r => r.id === self.id);
100
508
  },
509
+ /**
510
+ * #getter
511
+ * only uses sticky view headers when it is a 'top-level' view and
512
+ * session allows it
513
+ */
101
514
  get stickyViewHeaders() {
102
- const session = getSession(self);
103
- return isSessionWithMultipleViews(session)
104
- ? this.isTopLevelView && session.stickyViewHeaders
105
- : false;
106
- },
107
- get rubberbandTop() {
108
- let pinnedTracksTop = 0;
109
- if (this.stickyViewHeaders) {
110
- pinnedTracksTop = VIEW_HEADER_HEIGHT;
111
- if (!self.hideHeader) {
112
- pinnedTracksTop += HEADER_BAR_HEIGHT;
113
- if (!self.hideHeaderOverview) {
114
- pinnedTracksTop += HEADER_OVERVIEW_HEIGHT;
115
- }
116
- }
117
- }
118
- return pinnedTracksTop;
119
- },
120
- get pinnedTracksTop() {
121
- return this.rubberbandTop + SCALE_BAR_HEIGHT;
515
+ // `=== true` rather than a session guard: the member is optional on
516
+ // AbstractSessionModel, and a session with no such notion (the embedded
517
+ // products) should read as "don't pin", which is what absent gives.
518
+ // Tested before isTopLevelView, the order the guard used to impose — a
519
+ // session without the preference is also the one least likely to have
520
+ // anything useful behind `views`, and there is no reason to walk it
521
+ return (getSession(self).stickyViewHeaders === true && this.isTopLevelView);
122
522
  },
123
523
  }))
124
524
  .views(self => ({
125
- scalebarDisplayPrefix() {
126
- return getParent(self, 2).type === 'LinearSyntenyView'
525
+ /**
526
+ * #getter
527
+ * Assembly-name prefix for the scalebar refName labels, or undefined for
528
+ * none. A container view (e.g. LinearSyntenyView) opts its sub-views in by
529
+ * exposing showAssemblyNameInSubviewScalebar; duck-typed rather than
530
+ * matching a concrete view type so no upward plugin dependency is needed
531
+ * and any container can opt in. A wrong nesting depth simply yields no
532
+ * prefix — hence the hasParent guard, since getParent throws (rather than
533
+ * returning undefined) when the view sits shallower than depth 2.
534
+ */
535
+ get scalebarDisplayPrefix() {
536
+ const parent = hasParent(self, 2)
537
+ ? getParent(self, 2)
538
+ : undefined;
539
+ return parent?.showAssemblyNameInSubviewScalebar
127
540
  ? self.assemblyDisplayNames[0]
128
- : '';
129
- },
130
- MiniControlsComponent() {
131
- return MiniControls;
132
- },
133
- HeaderComponent() {
134
- return Header;
541
+ : undefined;
135
542
  },
543
+ /**
544
+ * #getter
545
+ */
136
546
  get assembliesNotFound() {
137
547
  const { assemblyManager } = getSession(self);
138
548
  const r0 = self.assemblyNames
139
- .map(a => (!assemblyManager.get(a) ? a : undefined))
140
- .filter(f => !!f)
549
+ .filter(a => !assemblyManager.assemblyNameMap[a])
141
550
  .join(',');
142
551
  return r0 ? `Assemblies ${r0} not found` : undefined;
143
552
  },
553
+ /**
554
+ * #getter
555
+ */
144
556
  get assemblyErrors() {
145
557
  const { assemblyManager } = getSession(self);
146
558
  return self.assemblyNames
147
- .map(a => assemblyManager.get(a)?.error)
148
- .filter(f => !!f)
559
+ .map(name => assemblyManager.get(name)?.error)
560
+ .filter(e => !!e)
149
561
  .join(', ');
150
562
  },
563
+ /**
564
+ * #getter
565
+ */
151
566
  get assembliesInitialized() {
152
567
  const { assemblyManager } = getSession(self);
153
- return self.assemblyNames.every(a => assemblyManager.get(a)?.initialized);
568
+ return self.assemblyNames.every(name => assemblyManager.get(name)?.initialized);
569
+ },
570
+ /**
571
+ * #getter
572
+ * the assembly named by a pending `init`, or undefined when no init is
573
+ * set. `init`'s assembly isn't in `assemblyNames` yet (that derives from
574
+ * displayedRegions, still empty pre-navigation), so init-phase readiness
575
+ * and error checks resolve it directly through here.
576
+ */
577
+ get initAssembly() {
578
+ return self.init
579
+ ? getSession(self).assemblyManager.get(self.init.assembly)
580
+ : undefined;
154
581
  },
582
+ /**
583
+ * #getter
584
+ */
155
585
  get initialized() {
156
586
  if (self.volatileWidth === undefined) {
157
587
  return false;
158
588
  }
589
+ // if init is set, wait for that assembly to have regions loaded
159
590
  if (self.init) {
160
- const { assemblyManager } = getSession(self);
161
- const asm = assemblyManager.get(self.init.assembly);
591
+ const asm = this.initAssembly;
162
592
  return !!(asm?.initialized && asm.regions);
163
593
  }
164
594
  return this.assembliesInitialized;
165
595
  },
596
+ /**
597
+ * #getter
598
+ */
166
599
  get hasDisplayedRegions() {
167
600
  return self.displayedRegions.length > 0;
168
601
  },
602
+ /**
603
+ * #getter
604
+ * The assembly whose load the spinner is waiting on: the one `init` names
605
+ * before navigation (it isn't in assemblyNames yet), else the first of the
606
+ * displayed assemblies that hasn't finished loading.
607
+ */
608
+ get loadingAssembly() {
609
+ return self.init
610
+ ? this.initAssembly
611
+ : getSession(self).assemblyManager.loadingAssembly(self.assemblyNames);
612
+ },
613
+ /**
614
+ * #getter
615
+ * What the spinner says. The assembly reports which of its files it is
616
+ * downloading, so this is "Downloading chromosome aliases" rather than a bare
617
+ * "Loading" for the part of startup that actually takes time. Falls back
618
+ * once the assembly is loaded and the remaining wait is the init autorun's
619
+ * own navigation, which is local.
620
+ */
169
621
  get loadingMessage() {
170
- return this.showLoading ? 'Loading' : undefined;
622
+ return this.showLoading
623
+ ? this.loadingAssembly?.statusMessage || 'Loading'
624
+ : undefined;
171
625
  },
626
+ /**
627
+ * #getter
628
+ * Determinate fraction for the spinner's bar, when the assembly load
629
+ * reports one (a whole-file download with a Content-Length)
630
+ */
631
+ get loadingProgress() {
632
+ return this.showLoading
633
+ ? this.loadingAssembly?.statusProgress
634
+ : undefined;
635
+ },
636
+ /**
637
+ * #getter
638
+ * The URL the assembly load is currently fetching, when the phase named
639
+ * one. Only the stalled-load notice reads it — see
640
+ * {@link ViewLoadingScreen}.
641
+ */
642
+ get loadingSource() {
643
+ return this.showLoading ? this.loadingAssembly?.statusSource : undefined;
644
+ },
645
+ /**
646
+ * #getter
647
+ */
172
648
  get hasSomethingToShow() {
173
649
  return this.hasDisplayedRegions || !!self.init;
174
650
  },
651
+ /**
652
+ * #getter
653
+ * init is set but its async navigation (the afterAttach autorun) hasn't
654
+ * populated displayedRegions yet. `initialized` can already be true here
655
+ * (it only tracks assembly readiness), so without this the container
656
+ * would mount over empty regions and pxToBp/hover would throw.
657
+ *
658
+ * **Not dotplot's or synteny's `initPending`**, which is the bare
659
+ * `!!self.init` — "an init blob has not been applied" — and which those
660
+ * views read from their `settled` gate rather than from `showLoading`.
661
+ * This one is narrower on purpose: it closes only the window where there
662
+ * is nothing on screen. Once navigation has produced regions the view is
663
+ * usable and gets shown, even though `init` is still set while the tracks
664
+ * and highlights land. Renamed away from the shared spelling because the
665
+ * two predicates disagree exactly there, and the bare name reads as
666
+ * theirs.
667
+ */
668
+ get awaitingInitNavigation() {
669
+ return !!self.init && !this.hasDisplayedRegions;
670
+ },
671
+ /**
672
+ * #getter
673
+ * Whether to show a loading indicator instead of the import form or view
674
+ */
175
675
  get showLoading() {
176
- return !this.initialized && !this.error && this.hasSomethingToShow;
177
- },
676
+ return (this.hasSomethingToShow &&
677
+ !this.error &&
678
+ (!this.initialized || this.awaitingInitNavigation));
679
+ },
680
+ /**
681
+ * #getter
682
+ * Whether to show the import form
683
+ */
178
684
  get showImportForm() {
179
685
  return !this.hasSomethingToShow || !!this.error;
180
686
  },
181
- get scalebarHeight() {
182
- return SCALE_BAR_HEIGHT + RESIZE_HANDLE_HEIGHT;
687
+ /**
688
+ * #getter
689
+ * Is there anything to draw yet? The gate anything reading block geometry
690
+ * — a ruler, a scalebar, a display — has to pass first, because
691
+ * `width`/`staticBlocks` throw by design before the view has been
692
+ * measured and navigated.
693
+ *
694
+ * **Not `initialized`**, which is the trap this getter exists to close.
695
+ * That one answers "have the assembly's regions loaded", which is only
696
+ * the first of two async steps: navigating then populates
697
+ * `displayedRegions`, and in the window between the two `initialized` is
698
+ * already true while there is still nothing on screen. `showLoading`
699
+ * folds in `awaitingInitNavigation`, the getter that exists for exactly
700
+ * that gap.
701
+ *
702
+ * `error` is the third outcome and is why this is not a bare
703
+ * `!showLoading`: a failed assembly load also ends the loading state, so
704
+ * that alone would mount over the wreckage. Read `error` yourself if you
705
+ * want to draw it.
706
+ */
707
+ get ready() {
708
+ return !this.showLoading && !this.error;
709
+ },
710
+ /**
711
+ * #getter
712
+ * The same question as `ready` with the other three outcomes named, for a
713
+ * host that draws its own chrome and has to render all four. See
714
+ * `computeViewStatus`, whose precedence this defers to — and note its
715
+ * `ready` is narrower than the getter above, which is true when nothing
716
+ * has told the view where to look.
717
+ */
718
+ get status() {
719
+ return computeViewStatus({
720
+ error: this.error,
721
+ hasSomethingToShow: this.hasSomethingToShow,
722
+ loading: () => this.showLoading
723
+ ? {
724
+ message: this.loadingMessage ?? 'Loading',
725
+ progress: this.loadingProgress,
726
+ }
727
+ : undefined,
728
+ });
183
729
  },
730
+ /**
731
+ * #getter
732
+ */
733
+ get scalebarHeight() {
734
+ return SCALE_BAR_HEIGHT;
735
+ },
736
+ /**
737
+ * #getter
738
+ * What TrackContainer puts *above* a track's rendering container: the gap
739
+ * over its Paper, and the Paper's own top border when outlines are on.
740
+ */
741
+ get trackLeadingChrome() {
742
+ return (TRACK_TOP_GAP + (self.showTrackOutlines ? TRACK_OUTLINE_BORDER : 0));
743
+ },
744
+ /**
745
+ * #getter
746
+ * ...and what it puts below: the resize divider, plus the matching bottom
747
+ * border.
748
+ */
749
+ get trackTrailingChrome() {
750
+ return (RESIZE_HANDLE_HEIGHT +
751
+ (self.showTrackOutlines ? TRACK_OUTLINE_BORDER : 0));
752
+ },
753
+ /**
754
+ * #getter
755
+ * A track's full cost beyond its display height.
756
+ *
757
+ * The track *label* is not counted, and cannot be: an offset label is an
758
+ * in-flow box whose height is whatever the theme renders a Paper of icon
759
+ * buttons at — 31.140625px on the stock theme, and not a number this file
760
+ * can derive. So these getters are exact while labels are hidden or
761
+ * overlapping, and short by one label box per labelled track otherwise.
762
+ * Anything needing the offset to the pixel with labels showing measures
763
+ * the DOM; `BreakpointSplitViewOverlay` does, and falls back to this
764
+ * arithmetic only for a track with no mounted div.
765
+ */
766
+ get trackChromeHeight() {
767
+ return this.trackLeadingChrome + this.trackTrailingChrome;
768
+ },
769
+ /**
770
+ * #getter
771
+ */
184
772
  get headerHeight() {
185
773
  if (self.hideHeader) {
186
774
  return 0;
@@ -192,47 +780,150 @@ export function stateModelFactory(pluginManager) {
192
780
  return HEADER_BAR_HEIGHT + HEADER_OVERVIEW_HEIGHT;
193
781
  }
194
782
  },
783
+ /**
784
+ * #getter
785
+ * Where the scalebar pins when the view's chrome is sticky: everything
786
+ * stacked above it, which is the view's own title bar plus this view's
787
+ * header. Expressed from `headerHeight` rather than re-summing its
788
+ * constants — the two are the same box, and a second spelling of the sum
789
+ * is free to drift from the first while both typecheck.
790
+ */
791
+ get rubberbandTop() {
792
+ return self.stickyViewHeaders
793
+ ? VIEW_HEADER_HEIGHT + this.headerHeight
794
+ : 0;
795
+ },
796
+ /**
797
+ * #getter
798
+ */
799
+ get pinnedTracksTop() {
800
+ return this.rubberbandTop + this.scalebarHeight;
801
+ },
802
+ /**
803
+ * #method
804
+ * rendered height of a single track, collapsing to a fixed height when
805
+ * minimized. Shared by trackHeights and getTrackYOffset so the two can't
806
+ * disagree. Reads `activeDisplay` — the display TrackContainer actually
807
+ * mounts — rather than re-picking `displays[0]`, so the view's height math
808
+ * can't diverge from what is on screen.
809
+ */
810
+ trackHeight(track) {
811
+ return track.minimized
812
+ ? MINIMIZED_TRACK_HEIGHT
813
+ : track.activeDisplay.height;
814
+ },
815
+ /**
816
+ * #getter
817
+ */
195
818
  get trackHeights() {
196
- return sum(self.tracks.map(t => t.displays[0].height));
819
+ return sum(self.tracks.map(t => this.trackHeight(t)));
197
820
  },
198
- get trackHeightsWithResizeHandles() {
199
- return this.trackHeights + self.tracks.length * RESIZE_HANDLE_HEIGHT;
821
+ /**
822
+ * #getter
823
+ */
824
+ get trackHeightsWithChrome() {
825
+ return this.trackHeights + self.tracks.length * this.trackChromeHeight;
200
826
  },
827
+ /**
828
+ * #getter
829
+ */
201
830
  get height() {
202
- return (this.trackHeightsWithResizeHandles +
203
- this.headerHeight +
204
- this.scalebarHeight);
831
+ if (self.scalebarOnly) {
832
+ return this.headerHeight + this.scalebarHeight;
833
+ }
834
+ return (this.trackHeightsWithChrome + this.headerHeight + this.scalebarHeight);
835
+ },
836
+ /**
837
+ * #method
838
+ * Y offset (in pixels, from the top of the view) where a track's
839
+ * rendering container starts. Walks tracks in DOM render order (pinned
840
+ * first, then unpinned), from the same constants TrackContainer lays its
841
+ * Paper out with. Returns `undefined` if the track is not present.
842
+ *
843
+ * Exact while track labels are hidden or overlapping. With an offset
844
+ * label the answer is short by one label box per labelled track above
845
+ * this one — see `trackChromeHeight` for why that box is not derivable
846
+ * here.
847
+ */
848
+ getTrackYOffset(trackId) {
849
+ let y = this.headerHeight + this.scalebarHeight + this.trackLeadingChrome;
850
+ for (const t of [...self.pinnedTracks, ...self.unpinnedTracks]) {
851
+ if (t.configuration.trackId === trackId) {
852
+ return y;
853
+ }
854
+ y += this.trackHeight(t) + this.trackChromeHeight;
855
+ }
856
+ return undefined;
205
857
  },
858
+ /**
859
+ * #method
860
+ * the pinned or unpinned sibling list a track renders within; move
861
+ * up/down/top/bottom reorder inside this section rather than the full
862
+ * `tracks` array, since the two sections lay out independently
863
+ */
864
+ trackSection(id) {
865
+ return self.tracks.find(t => t.id === id)?.pinned
866
+ ? self.pinnedTracks
867
+ : self.unpinnedTracks;
868
+ },
869
+ /**
870
+ * #getter
871
+ */
206
872
  get totalBp() {
207
873
  return sum(self.displayedRegions.map(r => r.end - r.start));
208
874
  },
209
- getNonElidedRegionCount(bpPerPx) {
210
- if (bpPerPx <= 0) {
211
- return self.displayedRegions.length;
212
- }
213
- return self.displayedRegions.filter(r => (r.end - r.start) / bpPerPx >= self.minimumBlockWidth).length;
214
- },
215
- getInterRegionPaddingPx(bpPerPx) {
216
- const nonElidedCount = this.getNonElidedRegionCount(bpPerPx);
217
- const numPaddings = Math.max(0, nonElidedCount - 1);
218
- return numPaddings * self.interRegionPaddingWidth;
219
- },
220
- get maxBpPerPx() {
875
+ /**
876
+ * #getter
877
+ */
878
+ get fitBpPerPx() {
221
879
  if (this.totalBp === 0 || self.width === 0) {
222
880
  return 1;
223
881
  }
224
- const naiveBpPerPx = this.totalBp / (self.width * 0.9);
225
- const totalPaddingPx = this.getInterRegionPaddingPx(naiveBpPerPx);
226
- const targetWidth = self.width * 0.9;
227
- const availableForBp = targetWidth - totalPaddingPx;
228
- if (availableForBp <= 0) {
229
- return naiveBpPerPx;
230
- }
231
- return this.totalBp / availableForBp;
232
- },
882
+ // Floor at minBpPerPx so tiny displayed regions (totalBp small enough
883
+ // that the fill-scaled ratio drops below MIN_BP_PER_PX) can't produce
884
+ // maxBpPerPx < minBpPerPx, which would invert the zoom-slider bounds
885
+ // and the clamp() range in zoomTo.
886
+ return Math.max(MIN_BP_PER_PX, this.totalBp / (self.width * SHOW_ALL_REGIONS_FILL));
887
+ },
888
+ /**
889
+ * #getter
890
+ * The zoom-out limit. This view's own fit, except while a container holds
891
+ * it on a shared scale coarser than that — a small genome next to a large
892
+ * one, drawn short so the two compare by length.
893
+ *
894
+ * Raising the LIMIT is what makes such a scale survive: written past the
895
+ * limit instead, it is undone by the first thing that clamps — a wheel
896
+ * tick, a rubberband, a `setDisplayedRegions` — which is how the dotplot's
897
+ * locked aspect ratio once turned "zoom out" into a zoom in
898
+ * (`axisMaxBpPerPx`). Here every route to a zoom clamps against the same
899
+ * ceiling, so full zoom-out LANDS on the shared scale.
900
+ */
901
+ get maxBpPerPx() {
902
+ return Math.max(this.fitBpPerPx, this.sharedFitBpPerPx);
903
+ },
904
+ /**
905
+ * #getter
906
+ * The zoom-out ceiling a containing comparative view holds this row to,
907
+ * or 0 for a standalone view, a mode that is off, and a stack that cannot
908
+ * answer yet.
909
+ *
910
+ * Pulled rather than pushed down into a volatile: the number is a
911
+ * function of every row's fit, all of which move on a resize, and a
912
+ * stored copy would be stale by the next layout.
913
+ */
914
+ get sharedFitBpPerPx() {
915
+ const fit = sharedScaleContainerOf(self)?.sharedFit;
916
+ return fit?.answered ? fit.bpPerPx : 0;
917
+ },
918
+ /**
919
+ * #getter
920
+ */
233
921
  get minBpPerPx() {
234
- return 1 / 50;
922
+ return MIN_BP_PER_PX;
235
923
  },
924
+ /**
925
+ * #getter
926
+ */
236
927
  get error() {
237
928
  if (self.volatileError) {
238
929
  return self.volatileError;
@@ -243,47 +934,60 @@ export function stateModelFactory(pluginManager) {
243
934
  if (this.assembliesNotFound) {
244
935
  return this.assembliesNotFound;
245
936
  }
937
+ // Check init assembly for errors (displayedRegions may be empty during init)
246
938
  if (self.init) {
247
- const { assemblyManager } = getSession(self);
248
- const asm = assemblyManager.get(self.init.assembly);
249
- if (asm?.error) {
250
- return asm.error;
939
+ // `assembly` is required by InitState, but init is a frozen blob and
940
+ // hand-authored JSON is what fills it, so the type is not a guarantee.
941
+ // Naming the authoring mistake beats the downstream symptom, which is
942
+ // the literal string "Assembly undefined not found".
943
+ if (!self.init.assembly) {
944
+ return 'LinearGenomeView init needs an "assembly"';
251
945
  }
946
+ const asm = this.initAssembly;
252
947
  if (!asm) {
253
948
  return `Assembly ${self.init.assembly} not found`;
254
949
  }
950
+ if (asm.error) {
951
+ return asm.error;
952
+ }
255
953
  }
256
954
  return undefined;
257
955
  },
956
+ /**
957
+ * #getter
958
+ */
258
959
  get maxOffset() {
259
- const leftPadding = 10;
260
- return this.displayedRegionsTotalPx - leftPadding;
960
+ // objectively determined to keep the linear genome on the main screen
961
+ return this.displayedRegionsTotalPx - MAX_OFFSET_PADDING_PX;
261
962
  },
963
+ /**
964
+ * #getter
965
+ */
262
966
  get minOffset() {
263
- const rightPadding = 30;
264
- return -self.width + rightPadding;
265
- },
967
+ // objectively determined to keep the linear genome on the main screen
968
+ return -self.width + MIN_OFFSET_PADDING_PX;
969
+ },
970
+ /**
971
+ * #getter
972
+ * Whether the displayed regions read left-to-right, right-to-left, or
973
+ * some of each. `horizontallyFlip` reverses the order and flips every
974
+ * region at once, so a flipped row is uniformly `reversed` and `mixed`
975
+ * takes going out of your way — the scalebar label menu's per-region
976
+ * "Reverse region". Read off `displayedRegions` rather than off blocks
977
+ * because blocks cover the window and this is a fact about the row.
978
+ */
979
+ get displayedRegionsOrientation() {
980
+ return regionsOrientation(self.displayedRegions);
981
+ },
982
+ /**
983
+ * #getter
984
+ */
266
985
  get displayedRegionsTotalPx() {
267
- if (self.bpPerPx === 0) {
268
- return 0;
269
- }
270
- const totalPaddingPx = this.getInterRegionPaddingPx(self.bpPerPx);
271
- return this.totalBp / self.bpPerPx + totalPaddingPx;
272
- },
273
- renderProps() {
274
- return {
275
- ...getParentRenderProps(self),
276
- bpPerPx: self.bpPerPx,
277
- colorByCDS: self.colorByCDS,
278
- };
279
- },
280
- searchScope(assemblyName) {
281
- return {
282
- assemblyName,
283
- includeAggregateIndexes: true,
284
- tracks: self.tracks,
285
- };
986
+ return self.bpPerPx === 0 ? 0 : this.totalBp / self.bpPerPx;
286
987
  },
988
+ /**
989
+ * #getter
990
+ */
287
991
  get trackMap() {
288
992
  const map = new Map();
289
993
  for (const track of self.tracks) {
@@ -291,110 +995,210 @@ export function stateModelFactory(pluginManager) {
291
995
  }
292
996
  return map;
293
997
  },
998
+ /**
999
+ * #method
1000
+ */
294
1001
  getTrack(id) {
295
1002
  return this.trackMap.get(id);
296
1003
  },
297
- rankSearchResults(results) {
298
- return results;
299
- },
300
- get trackTypeActions() {
301
- const allActions = new Map();
302
- for (const track of self.tracks) {
303
- const trackInMap = allActions.get(track.type);
304
- if (!trackInMap) {
305
- const viewMenuActions = structuredClone(track.viewMenuActions);
306
- rewriteOnClicks(self, track.type, viewMenuActions);
307
- allActions.set(track.type, viewMenuActions);
308
- }
309
- }
310
- return allActions;
1004
+ /**
1005
+ * #method
1006
+ * displayId of the active (shown) display for a track in this view, used
1007
+ * by the config editor to expand the relevant display and collapse the
1008
+ * track's other displays
1009
+ */
1010
+ getActiveDisplayId(trackId) {
1011
+ return this.getTrack(trackId)?.activeDisplay.configuration.displayId;
311
1012
  },
312
1013
  }))
313
1014
  .actions(self => ({
1015
+ /**
1016
+ * #action
1017
+ */
314
1018
  setShowTrackOutlines(arg) {
315
1019
  self.showTrackOutlines = arg;
316
1020
  },
1021
+ /**
1022
+ * #action
1023
+ */
1024
+ setScrollZoom(flag) {
1025
+ getSession(self).setScrollZoom(flag);
1026
+ },
1027
+ /**
1028
+ * #action
1029
+ */
317
1030
  setColorByCDS(flag) {
318
1031
  self.colorByCDS = flag;
319
1032
  },
320
- setShowCytobands(flag) {
321
- self.showCytobandsSetting = flag;
1033
+ /**
1034
+ * #action
1035
+ */
1036
+ setShowAminoAcids(flag) {
1037
+ self.showAminoAcids = flag;
322
1038
  },
1039
+ /**
1040
+ * #action
1041
+ */
1042
+ setShowCytobands(flag) {
1043
+ self.showCytobands = flag;
1044
+ },
1045
+ /**
1046
+ * #action
1047
+ */
1048
+ // A resize needs no arithmetic: the window is what is stored, so a new
1049
+ // width simply divides into it and `bpPerPx` follows. The view keeps the
1050
+ // sequence it was framing rather than letting the right edge eat into it.
1051
+ //
1052
+ // Keeping the scale instead — which is what storing pixels amounted to —
1053
+ // was a block-cache optimization: block boundaries are
1054
+ // `blockNum * ceil(800 * bpPerPx)` and block keys embed start/end, so
1055
+ // holding bpPerPx across a resize kept every block key, and so every
1056
+ // fetched region, identical. Rescaling makes a resize a zoom, and a zoom
1057
+ // is already a solved case: FetchVisibleRegions' 300ms debounce coalesces
1058
+ // the gesture into one refetch, its in-flight guard caps concurrent
1059
+ // batches at one, and rpcDataMap is overwritten in place rather than
1060
+ // cleared, so nothing blanks (ADR-008, ADR-006).
323
1061
  setWidth(newWidth) {
324
1062
  self.volatileWidth = newWidth;
1063
+ if (newWidth > 0 && self.windowWidthBp <= 0) {
1064
+ // First measure, and nothing has established a window yet: a migrated
1065
+ // snapshot names the bpPerPx it was written with (see legacyBpPerPx),
1066
+ // and a view with no viewport at all takes the historical default of
1067
+ // 1. From here the window is the state and every later width divides
1068
+ // into it.
1069
+ self.windowWidthBp = (self.legacyBpPerPx || 1) * newWidth;
1070
+ self.legacyBpPerPx = 0;
1071
+ }
325
1072
  },
1073
+ /**
1074
+ * #action
1075
+ */
326
1076
  setError(error) {
327
1077
  self.volatileError = error;
328
1078
  },
1079
+ /**
1080
+ * #action
1081
+ */
329
1082
  setIsScalebarRefNameMenuOpen(isOpen) {
330
1083
  self.isScalebarRefNameMenuOpen = isOpen;
331
1084
  },
1085
+ /**
1086
+ * #action
1087
+ */
332
1088
  setScalebarRefNameClickPending(pending) {
333
1089
  self.scalebarRefNameClickPending = pending;
334
1090
  },
1091
+ /**
1092
+ * #action
1093
+ */
335
1094
  setHideHeader(b) {
336
1095
  self.hideHeader = b;
337
1096
  },
1097
+ /**
1098
+ * #action
1099
+ */
338
1100
  setHideHeaderOverview(b) {
339
1101
  self.hideHeaderOverview = b;
340
1102
  },
1103
+ /**
1104
+ * #action
1105
+ */
1106
+ setScalebarOnly(b) {
1107
+ self.scalebarOnly = b;
1108
+ },
1109
+ /**
1110
+ * #action
1111
+ */
341
1112
  setHideNoTracksActive(b) {
342
1113
  self.hideNoTracksActive = b;
343
1114
  },
1115
+ /**
1116
+ * #action
1117
+ */
344
1118
  setShowGridlines(b) {
345
1119
  self.showGridlines = b;
346
1120
  },
347
- addToHighlights(highlight) {
348
- self.highlight.push(highlight);
349
- },
350
- setHighlight(highlight) {
351
- self.highlight = cast(highlight);
352
- },
353
- removeHighlight(highlight) {
354
- self.highlight.remove(highlight);
355
- },
356
- updateHighlight(old, updates) {
357
- const idx = self.highlight.indexOf(old);
358
- if (idx !== -1) {
359
- self.highlight.splice(idx, 1, { ...old, ...updates });
360
- }
361
- },
362
- setHighlightsVisible(arg) {
363
- self.highlightsVisible = arg;
364
- },
1121
+ /**
1122
+ * #action
1123
+ */
365
1124
  setLabelsVisible(arg) {
366
1125
  self.labelsVisible = arg;
367
1126
  },
1127
+ /**
1128
+ * #action
1129
+ * set temporary vertical guides (e.g., for LD display hover)
1130
+ */
368
1131
  setVolatileGuides(guides) {
369
1132
  self.volatileGuides = guides;
370
1133
  },
1134
+ /**
1135
+ * #action
1136
+ */
371
1137
  scrollTo(offsetPx) {
372
1138
  const newOffsetPx = clamp(offsetPx, self.minOffset, self.maxOffset);
373
- self.offsetPx = newOffsetPx;
1139
+ self.windowStartBp = newOffsetPx * self.bpPerPx;
374
1140
  return newOffsetPx;
375
1141
  },
376
- zoomTo(bpPerPx, offset = self.width / 2, centerAtOffset = false) {
377
- const newBpPerPx = clamp(bpPerPx, self.minBpPerPx, self.maxBpPerPx);
378
- if (newBpPerPx === self.bpPerPx) {
379
- return newBpPerPx;
1142
+ /**
1143
+ * #action
1144
+ * `scrollTo`'s bp-space twin: place the window's left edge at a
1145
+ * linearized bp coordinate, clamped to the same scroll limits.
1146
+ *
1147
+ * It exists so a caller that already knows where it wants to be in bp —
1148
+ * zoomTo, anchoring the base under the cursor — does not have to convert
1149
+ * to pixels and let this convert back. That round trip is lossy once per
1150
+ * frame, and a scroll-zoom burst is a few dozen frames, which is enough
1151
+ * to walk the base out from under the cursor.
1152
+ */
1153
+ scrollToBp(startBp) {
1154
+ const { bpPerPx } = self;
1155
+ if (bpPerPx > 0) {
1156
+ self.windowStartBp = clamp(startBp, self.minOffset * bpPerPx, self.maxOffset * bpPerPx);
380
1157
  }
1158
+ return self.windowStartBp;
1159
+ },
1160
+ /**
1161
+ * #action
1162
+ */
1163
+ zoomTo(bpPerPx, offset = self.width / 2) {
1164
+ const newBpPerPx = clamp(bpPerPx, self.minBpPerPx, self.maxBpPerPx);
381
1165
  const oldBpPerPx = self.bpPerPx;
382
- if (Math.abs(oldBpPerPx - newBpPerPx) < 0.000001) {
383
- console.warn('zoomTo bpPerPx rounding error');
384
- return oldBpPerPx;
1166
+ if (Math.abs(oldBpPerPx - newBpPerPx) >= BP_PER_PX_EPSILON) {
1167
+ // Anchor the base under the cursor. The window is stored in bp, so
1168
+ // this is exact arithmetic in the units the state is already in: the
1169
+ // cursor sits at `windowStartBp + offset * oldBpPerPx` before the
1170
+ // zoom, and must sit at the same base `offset` pixels in after it.
1171
+ //
1172
+ // Neither conversion to pixels nor rounding appears, and that is the
1173
+ // point — either one loses a fraction of a pixel per frame, which at
1174
+ // high bpPerPx is a real number of bases, and a scroll-zoom burst is
1175
+ // dozens of frames of it compounding.
1176
+ const anchorBp = self.windowStartBp + offset * oldBpPerPx;
1177
+ // widen/narrow about the left edge, then put the anchor back under
1178
+ // the cursor. Guarded on displayedRegions as it always was: with
1179
+ // nothing displayed there is no scroll space to place it in.
1180
+ self.windowWidthBp = newBpPerPx * self.width;
1181
+ if (self.displayedRegions.length) {
1182
+ this.scrollToBp(anchorBp - offset * newBpPerPx);
1183
+ }
385
1184
  }
386
- self.bpPerPx = newBpPerPx;
387
- const newOffsetPx = Math.round(((self.offsetPx + offset) * oldBpPerPx) / newBpPerPx -
388
- (centerAtOffset ? self.width / 2 : offset));
389
- this.scrollTo(newOffsetPx);
390
- return newBpPerPx;
391
- },
1185
+ return self.bpPerPx;
1186
+ },
1187
+ /**
1188
+ * #action
1189
+ * sets offsets of rubberband, used in the get sequence dialog can call
1190
+ * view.getSelectedRegions(view.leftOffset,view.rightOffset) to compute
1191
+ * the selected regions from the offsets
1192
+ */
392
1193
  setOffsets(left, right) {
393
1194
  self.leftOffset = left;
394
1195
  self.rightOffset = right;
395
1196
  },
1197
+ /**
1198
+ * #action
1199
+ */
396
1200
  setSearchResults(searchResults, searchQuery, assemblyName) {
397
- getSession(self).queueDialog(handleClose => [
1201
+ getDialogHost(self).queueDialog(handleClose => [
398
1202
  SearchResultsDialog,
399
1203
  {
400
1204
  model: self,
@@ -405,48 +1209,62 @@ export function stateModelFactory(pluginManager) {
405
1209
  },
406
1210
  ]);
407
1211
  },
408
- setNewView(bpPerPx, offsetPx) {
409
- this.zoomTo(bpPerPx);
410
- this.scrollTo(offsetPx);
411
- },
412
- horizontallyFlip() {
413
- self.displayedRegions = cast([...self.displayedRegions]
414
- .reverse()
415
- .map(region => ({ ...region, reversed: !region.reversed })));
416
- this.scrollTo(self.totalBp / self.bpPerPx - self.offsetPx - self.width);
417
- },
418
- showTrack(trackId, initialSnapshot = {}, displayInitialSnapshot = {}) {
419
- return showTrackGeneric(self, trackId, initialSnapshot, displayInitialSnapshot);
1212
+ /**
1213
+ * #action
1214
+ */
1215
+ showTrack(trackId, initialSnapshot = {}, displayInitialSnapshot = {}, inlineConf) {
1216
+ return showTrackGeneric(self, trackId, initialSnapshot, displayInitialSnapshot, inlineConf);
420
1217
  },
1218
+ /**
1219
+ * #action
1220
+ */
421
1221
  hideTrack(trackId) {
422
1222
  return hideTrackGeneric(self, trackId);
423
1223
  },
424
1224
  }))
425
1225
  .actions(self => ({
1226
+ /**
1227
+ * #action
1228
+ */
426
1229
  moveTrackDown(id) {
427
- const idx = self.tracks.findIndex(v => v.id === id);
428
- if (idx !== -1 && idx < self.tracks.length - 1) {
429
- self.tracks.splice(idx, 2, self.tracks[idx + 1], self.tracks[idx]);
1230
+ const section = self.trackSection(id);
1231
+ const idx = section.findIndex(t => t.id === id);
1232
+ if (idx !== -1 && idx < section.length - 1) {
1233
+ this.moveTrack(id, section[idx + 1].id);
430
1234
  }
431
1235
  },
1236
+ /**
1237
+ * #action
1238
+ */
432
1239
  moveTrackUp(id) {
433
- const idx = self.tracks.findIndex(track => track.id === id);
1240
+ const section = self.trackSection(id);
1241
+ const idx = section.findIndex(t => t.id === id);
434
1242
  if (idx > 0) {
435
- self.tracks.splice(idx - 1, 2, self.tracks[idx], self.tracks[idx - 1]);
1243
+ this.moveTrack(id, section[idx - 1].id);
436
1244
  }
437
1245
  },
1246
+ /**
1247
+ * #action
1248
+ */
438
1249
  moveTrackToTop(id) {
439
- const track = self.tracks.find(track => track.id === id);
440
- if (track) {
441
- self.tracks = cast([track, ...self.tracks.filter(t => t.id !== id)]);
1250
+ const section = self.trackSection(id);
1251
+ if (section.length && section[0].id !== id) {
1252
+ this.moveTrack(id, section[0].id);
442
1253
  }
443
1254
  },
1255
+ /**
1256
+ * #action
1257
+ */
444
1258
  moveTrackToBottom(id) {
445
- const track = self.tracks.find(track => track.id === id);
446
- if (track) {
447
- self.tracks = cast([...self.tracks.filter(t => t.id !== id), track]);
1259
+ const section = self.trackSection(id);
1260
+ const last = section[section.length - 1];
1261
+ if (last && last.id !== id) {
1262
+ this.moveTrack(id, last.id);
448
1263
  }
449
1264
  },
1265
+ /**
1266
+ * #action
1267
+ */
450
1268
  moveTrack(movingId, targetId) {
451
1269
  const oldIndex = self.tracks.findIndex(track => track.id === movingId);
452
1270
  if (oldIndex === -1) {
@@ -456,432 +1274,1422 @@ export function stateModelFactory(pluginManager) {
456
1274
  if (newIndex === -1) {
457
1275
  throw new Error(`Track ID ${targetId} not found`);
458
1276
  }
1277
+ // direction-aware placement: filtering out oldIndex shifts the target
1278
+ // left by one when dragging down (oldIndex < newIndex), so splicing at
1279
+ // newIndex lands the track *after* the target; dragging up leaves the
1280
+ // target's index intact, landing *before* it. This matches the side
1281
+ // the dragged track approaches from.
459
1282
  const tracks = self.tracks.filter((_, idx) => idx !== oldIndex);
460
1283
  tracks.splice(newIndex, 0, self.tracks[oldIndex]);
461
1284
  self.tracks = cast(tracks);
462
1285
  },
1286
+ /**
1287
+ * #action
1288
+ */
463
1289
  toggleTrack(trackId) {
464
- toggleTrackGeneric(self, trackId);
1290
+ return toggleTrackGeneric(self, trackId);
465
1291
  },
1292
+ /**
1293
+ * #action
1294
+ */
466
1295
  setTrackLabels(setting) {
467
1296
  self.trackLabels = setting;
468
1297
  },
1298
+ /**
1299
+ * #action
1300
+ */
469
1301
  setShowCenterLine(b) {
470
1302
  self.showCenterLine = b;
471
1303
  },
472
- setDisplayedRegions(regions) {
473
- self.displayedRegions = cast(regions);
474
- self.zoomTo(self.bpPerPx);
475
- },
1304
+ /**
1305
+ * #action
1306
+ */
476
1307
  activateTrackSelector() {
477
- if (self.trackSelectorType === 'hierarchical') {
478
- const session = getSession(self);
479
- if (isSessionModelWithWidgets(session)) {
480
- const selector = session.addWidget('HierarchicalTrackSelectorWidget', 'hierarchicalTrackSelector', { view: self });
481
- session.showWidget(selector);
482
- return selector;
483
- }
1308
+ const { session, selector } = openTrackSelectorWidget(self);
1309
+ session.showWidget(selector);
1310
+ return selector;
1311
+ },
1312
+ /**
1313
+ * #action
1314
+ */
1315
+ toggleTrackSelector() {
1316
+ const { session, selector } = openTrackSelectorWidget(self);
1317
+ if (isTrackSelectorVisible(self)) {
1318
+ session.hideWidget(selector);
484
1319
  }
485
- throw new Error(`invalid track selector type ${self.trackSelectorType}`);
486
- },
1320
+ else {
1321
+ session.showWidget(selector);
1322
+ }
1323
+ return selector;
1324
+ },
1325
+ /**
1326
+ * #method
1327
+ * Helper method for the fetchSequence.
1328
+ * Retrieves the corresponding regions that were selected by the
1329
+ * rubberband
1330
+ *
1331
+ * @param leftOffset - `object as {start, end, index, offset}`, offset = start
1332
+ * of user drag
1333
+ * @param rightOffset - `object as {start, end, index, offset}`,
1334
+ * offset = end of user drag
1335
+ * @returns array of Region[]
1336
+ */
487
1337
  getSelectedRegions(leftOffset, rightOffset) {
488
- const snap = getSnapshot(self);
489
- const simView = Base1DView.create({
490
- ...snap,
491
- interRegionPaddingWidth: self.interRegionPaddingWidth,
492
- });
493
- simView.setVolatileWidth(self.width);
494
- simView.moveTo(leftOffset, rightOffset);
495
- return simView.dynamicBlocks.contentBlocks.map(region => ({
496
- assemblyName: region.assemblyName,
497
- refName: region.refName,
498
- start: Math.floor(region.start),
499
- end: Math.ceil(region.end),
500
- }));
501
- },
1338
+ if (!leftOffset || !rightOffset) {
1339
+ return [];
1340
+ }
1341
+ const layout = {
1342
+ displayedRegions: self.displayedRegions,
1343
+ bpPerPx: self.bpPerPx,
1344
+ offsetPx: self.offsetPx,
1345
+ width: self.width,
1346
+ minimumBlockWidth: self.minimumBlockWidth,
1347
+ };
1348
+ const { bpPerPx, offsetPx: rawOffsetPx } = computeMoveToLayout(layout, leftOffset, rightOffset);
1349
+ // mirror Base1DView.scrollTo clamping: raw offsetPx can be far outside
1350
+ // the valid range when both offsets are oob on the same side
1351
+ const offsetPx = clamp(rawOffsetPx, self.minOffset, self.totalBp / bpPerPx - MAX_OFFSET_PADDING_PX);
1352
+ return wholeBaseRegions(calculateDynamicBlocks({
1353
+ ...layout,
1354
+ bpPerPx,
1355
+ offsetPx,
1356
+ }).contentBlocks);
1357
+ },
1358
+ /**
1359
+ * #action
1360
+ */
502
1361
  horizontalScroll(distance) {
503
1362
  const oldOffsetPx = self.offsetPx;
1363
+ // newOffsetPx is the actual offset after the scroll is clamped
504
1364
  const newOffsetPx = self.scrollTo(self.offsetPx + distance);
505
1365
  return newOffsetPx - oldOffsetPx;
506
1366
  },
507
- showAllRegions() {
508
- self.bpPerPx = clamp(self.maxBpPerPx, self.minBpPerPx, self.maxBpPerPx);
509
- self.scrollTo(getCenteredOffsetPx(self.displayedRegionsTotalPx, self.width));
510
- },
511
- showAllRegionsInAssembly(assemblyName) {
512
- const session = getSession(self);
513
- const { assemblyManager } = session;
514
- if (!assemblyName) {
515
- const names = new Set(self.displayedRegions.map(r => r.assemblyName));
516
- if (names.size > 1) {
517
- session.notify(`Can't perform operation with multiple assemblies currently`);
518
- return;
519
- }
520
- ;
521
- [assemblyName] = [...names];
522
- }
523
- const regions = assemblyManager.get(assemblyName)?.regions;
524
- if (!regions) {
525
- return;
526
- }
527
- this.setDisplayedRegions(regions);
528
- self.zoomTo(self.maxBpPerPx);
529
- self.scrollTo(getCenteredOffsetPx(self.displayedRegionsTotalPx, self.width));
530
- },
1367
+ /**
1368
+ * #action
1369
+ */
531
1370
  setDraggingTrackId(idx) {
532
1371
  self.draggingTrackId = idx;
533
1372
  if (idx === undefined) {
534
1373
  self.lastTrackDragY = undefined;
535
1374
  }
536
1375
  },
1376
+ /**
1377
+ * #action
1378
+ */
537
1379
  setLastTrackDragY(y) {
538
1380
  self.lastTrackDragY = y;
539
1381
  },
540
- setScaleFactor(factor) {
541
- self.scaleFactor = factor;
542
- },
543
- setTargetBpPerPx(target) {
544
- self.targetBpPerPx = target;
545
- },
546
- clearView() {
547
- this.setDisplayedRegions([]);
548
- self.tracks.clear();
549
- self.scrollTo(0);
550
- self.zoomTo(10);
1382
+ /**
1383
+ * #action
1384
+ * called while dragging a track over the track at `targetId`; reorders
1385
+ * once the cursor has moved far enough (see shouldSwapTracks) to avoid
1386
+ * jitter when a short track is dragged over a tall one
1387
+ */
1388
+ onTrackDragOver(targetId, currentY) {
1389
+ const { draggingTrackId } = self;
1390
+ if (draggingTrackId !== undefined && draggingTrackId !== targetId) {
1391
+ const draggingIdx = self.tracks.findIndex(t => t.id === draggingTrackId);
1392
+ const targetIdx = self.tracks.findIndex(t => t.id === targetId);
1393
+ if (draggingIdx !== -1 && targetIdx !== -1) {
1394
+ const movingDown = targetIdx > draggingIdx;
1395
+ if (shouldSwapTracks(self.lastTrackDragY, currentY, movingDown)) {
1396
+ self.lastTrackDragY = currentY;
1397
+ this.moveTrack(draggingTrackId, targetId);
1398
+ }
1399
+ }
1400
+ }
551
1401
  },
1402
+ /**
1403
+ * #action
1404
+ */
552
1405
  setInit(arg) {
553
1406
  self.init = arg;
554
1407
  },
1408
+ /**
1409
+ * #method
1410
+ * creates an svg export and save using FileSaver
1411
+ */
555
1412
  async exportSvg(opts = {}) {
556
- const { renderToSvg } = await import("./svgcomponents/SVGLinearGenomeView.js");
1413
+ const { renderToSvg } = await import('./svgcomponents/SVGLinearGenomeView.js');
557
1414
  const html = await renderToSvg(self, opts);
558
- const { saveAs } = await import('file-saver-es');
559
- if (opts.format === 'png') {
560
- const img = new Image();
561
- const svgBlob = new Blob([html], { type: 'image/svg+xml' });
562
- const url = URL.createObjectURL(svgBlob);
563
- await new Promise((resolve, reject) => {
564
- img.onload = () => {
565
- const canvas = document.createElement('canvas');
566
- canvas.width = img.width;
567
- canvas.height = img.height;
568
- const ctx = canvas.getContext('2d');
569
- ctx.drawImage(img, 0, 0);
570
- URL.revokeObjectURL(url);
571
- canvas.toBlob(blob => {
572
- if (blob) {
573
- saveAs(blob, opts.filename || 'image.png');
574
- resolve();
575
- }
576
- else {
577
- reject(new Error(`Failed to create PNG. The image may be too large (${img.width}x${img.height}). Try reducing the view size or use SVG format.`));
578
- }
579
- }, 'image/png');
580
- };
581
- img.onerror = () => {
582
- URL.revokeObjectURL(url);
583
- reject(new Error('Failed to load SVG for PNG conversion'));
584
- };
585
- img.src = url;
586
- });
587
- }
588
- else {
589
- saveAs(new Blob([html], { type: 'image/svg+xml' }), opts.filename || 'image.svg');
590
- }
1415
+ const { saveSvgAsImage } = await import('@jbrowse/core/svg/saveSvgAsImage');
1416
+ await saveSvgAsImage(html, opts);
591
1417
  },
592
1418
  }))
593
1419
  .actions(self => {
594
- let cancelLastAnimation = () => { };
1420
+ let cancelLastSlide = () => { };
1421
+ let cancelLastZoom = () => { };
1422
+ // Both animations pass `read`, so the spring yields the moment anything
1423
+ // else moves the view: an animation drives zoomTo/scrollTo from its own
1424
+ // position for up to a second, and a direct interaction landing in that
1425
+ // window — a wheel/pinch zoom, a locstring nav, a rubberband "zoom to
1426
+ // region", a click-drag pan — used to be overwritten on the next frame.
1427
+ // Only the zoom slider defended itself, via cancelZoomAnimation, and
1428
+ // expecting every future call site to remember that is not a workable
1429
+ // contract.
1430
+ /**
1431
+ * #action
1432
+ * perform animated slide
1433
+ */
595
1434
  function slide(viewWidths) {
596
- const [animate, cancelAnimation] = springAnimate(self.offsetPx, self.offsetPx + self.width * viewWidths, self.scrollTo, undefined, undefined, 200);
597
- cancelLastAnimation();
598
- cancelLastAnimation = cancelAnimation;
1435
+ const [animate, cancelAnimation] = springAnimate({
1436
+ from: self.offsetPx,
1437
+ to: self.offsetPx + self.width * viewWidths,
1438
+ read: () => self.offsetPx,
1439
+ write: px => {
1440
+ self.scrollTo(px);
1441
+ },
1442
+ tension: 200,
1443
+ });
1444
+ cancelLastSlide();
1445
+ cancelLastSlide = cancelAnimation;
599
1446
  animate();
600
1447
  }
601
- return { slide };
602
- })
603
- .actions(self => {
604
- let cancelLastAnimation = () => { };
1448
+ /**
1449
+ * #action
1450
+ * perform animated zoom
1451
+ */
605
1452
  function zoom(targetBpPerPx) {
606
- cancelLastAnimation();
607
- const intendedFactor = targetBpPerPx / self.bpPerPx;
608
- const effectiveBase = self.targetBpPerPx ?? self.bpPerPx;
609
- let effectiveTarget = effectiveBase * intendedFactor;
610
- effectiveTarget = Math.max(self.minBpPerPx, Math.min(self.maxBpPerPx, effectiveTarget));
611
- const currentTarget = self.targetBpPerPx ?? self.bpPerPx;
612
- if (effectiveTarget === currentTarget) {
613
- return;
1453
+ cancelLastZoom();
1454
+ const effectiveTarget = clamp(targetBpPerPx, self.minBpPerPx, self.maxBpPerPx);
1455
+ // nothing to animate when already at the limit, or at the target
1456
+ if (effectiveTarget !== self.bpPerPx) {
1457
+ const [animate, cancelAnimation] = springAnimate({
1458
+ from: self.bpPerPx,
1459
+ to: effectiveTarget,
1460
+ read: () => self.bpPerPx,
1461
+ write: bpPerPx => {
1462
+ self.zoomTo(bpPerPx);
1463
+ },
1464
+ tension: 1000,
1465
+ friction: 50,
1466
+ });
1467
+ cancelLastZoom = cancelAnimation;
1468
+ animate();
614
1469
  }
615
- self.setTargetBpPerPx(effectiveTarget);
616
- const targetScaleFactor = self.bpPerPx / effectiveTarget;
617
- const [animate, cancelAnimation] = springAnimate(self.scaleFactor, targetScaleFactor, self.setScaleFactor, () => {
618
- self.zoomTo(effectiveTarget);
619
- self.setScaleFactor(1);
620
- self.setTargetBpPerPx(undefined);
621
- }, 0, 1000, 50);
622
- cancelLastAnimation = cancelAnimation;
623
- animate();
624
1470
  }
625
- return { zoom };
1471
+ /**
1472
+ * #action
1473
+ * cancel an in-flight animated zoom. The animation already yields to any
1474
+ * other zoom on its own, so this is for stopping it *without* changing the
1475
+ * zoom — the slider grabbing the thumb, before it has a value to commit.
1476
+ */
1477
+ function cancelZoomAnimation() {
1478
+ cancelLastZoom();
1479
+ }
1480
+ return { slide, zoom, cancelZoomAnimation };
626
1481
  })
627
1482
  .views(self => ({
628
- get canShowCytobands() {
629
- return self.displayedRegions.length === 1 && this.anyCytobandsExist;
630
- },
631
- get showCytobands() {
632
- return this.canShowCytobands && self.showCytobandsSetting;
1483
+ /**
1484
+ * #getter
1485
+ */
1486
+ get showsWholeChromosome() {
1487
+ const { displayedRegions } = self;
1488
+ const region = displayedRegions.length === 1 ? displayedRegions[0] : undefined;
1489
+ const full = region
1490
+ ? getSession(self)
1491
+ .assemblyManager.get(region.assemblyName)
1492
+ ?.getRegionForRefName(region.refName)
1493
+ : undefined;
1494
+ return region && full
1495
+ ? region.start === full.start && region.end === full.end
1496
+ : false;
633
1497
  },
1498
+ /**
1499
+ * #getter
1500
+ * an ideogram only reads correctly against an entire chromosome: on a
1501
+ * sub-region it is a meaningless slice of bands, and the centromere shows
1502
+ * up as a lone half-triangle
1503
+ */
1504
+ get canShowCytobands() {
1505
+ return this.showsWholeChromosome && this.anyCytobandsExist;
1506
+ },
1507
+ /**
1508
+ * #getter
1509
+ * the `showCytobands` setting gated by whether cytobands can be shown at
1510
+ * all (whole chromosome + data present) — i.e. actually on screen
1511
+ */
1512
+ get effectiveShowCytobands() {
1513
+ return this.canShowCytobands && self.showCytobands;
1514
+ },
1515
+ /**
1516
+ * #getter
1517
+ */
634
1518
  get anyCytobandsExist() {
635
1519
  const { assemblyManager } = getSession(self);
636
1520
  return self.assemblyNames.some(a => assemblyManager.get(a)?.cytobands?.length);
637
1521
  },
1522
+ /**
1523
+ * #getter
1524
+ * the cytoband is displayed to the right of the chromosome name, and
1525
+ * that offset is calculated manually with this method
1526
+ */
638
1527
  get cytobandOffset() {
639
- return this.showCytobands
640
- ? measureText(self.displayedRegions[0]?.refName || '', 12) + 15
1528
+ return this.effectiveShowCytobands
1529
+ ? cytobandLabelGutterWidth(self.displayedRegions[0]?.refName || '')
641
1530
  : 0;
642
1531
  },
1532
+ /**
1533
+ * #getter
1534
+ */
1535
+ get isTrackSelectorOpen() {
1536
+ return isTrackSelectorVisible(self);
1537
+ },
643
1538
  }))
644
1539
  .views(self => ({
1540
+ /**
1541
+ * #method
1542
+ * return the view menu items
1543
+ */
645
1544
  menuItems() {
646
1545
  return buildMenuItems(self);
647
1546
  },
1547
+ /**
1548
+ * #method
1549
+ * what a plugin can start from the selected region — a synteny view, a
1550
+ * consensus call. Extend this rather than `rubberBandMenuItems` so the
1551
+ * entries collect under the menu's "Launch" submenu; that grouping is
1552
+ * decided once here rather than by whichever contributor runs first, and
1553
+ * keeps the rubberband menu itself short as plugins pile on.
1554
+ */
1555
+ rubberBandLaunchMenuItems() {
1556
+ return [];
1557
+ },
1558
+ /**
1559
+ * #getter
1560
+ * geometry of the overview scalebar — derived from displayedRegions,
1561
+ * width, and cytobandOffset so it stays cached by MobX
1562
+ */
1563
+ get overviewLayout() {
1564
+ return createOverviewLayout({
1565
+ displayedRegions: self.displayedRegions,
1566
+ width: self.width - self.cytobandOffset,
1567
+ minimumBlockWidth: self.minimumBlockWidth,
1568
+ });
1569
+ },
648
1570
  }))
649
1571
  .views(self => {
650
1572
  let currentlyCalculatedStaticBlocks;
651
- let currentBlockKeys;
1573
+ let coverageLeftPx = 0;
1574
+ let coverageRightPx = 0;
1575
+ let prevBpPerPx;
1576
+ let prevWidth;
1577
+ let prevMinimumBlockWidth;
1578
+ let prevDisplayedRegions;
652
1579
  return {
1580
+ /**
1581
+ * #getter
1582
+ * static blocks are an important concept jbrowse uses to avoid
1583
+ * re-rendering when you scroll to the side. when you horizontally
1584
+ * scroll to the right, old blocks to the left may be removed, and new
1585
+ * blocks may be instantiated on the right. tracks may use the static
1586
+ * blocks to render their data for the region represented by the block
1587
+ */
653
1588
  get staticBlocks() {
1589
+ const { offsetPx, bpPerPx, width, minimumBlockWidth, displayedRegions, } = self;
1590
+ // Fast path: if only offsetPx changed and viewport is still within
1591
+ // the coverage range of existing blocks, skip the expensive
1592
+ // calculateStaticBlocks call entirely. minimumBlockWidth is read here
1593
+ // (not just in calculateStaticBlocks) so MobX still invalidates this
1594
+ // computed if it changes while the viewport stays within coverage.
1595
+ if (currentlyCalculatedStaticBlocks !== undefined &&
1596
+ bpPerPx === prevBpPerPx &&
1597
+ width === prevWidth &&
1598
+ minimumBlockWidth === prevMinimumBlockWidth &&
1599
+ displayedRegions === prevDisplayedRegions &&
1600
+ offsetPx >= coverageLeftPx &&
1601
+ offsetPx + width <= coverageRightPx) {
1602
+ return currentlyCalculatedStaticBlocks;
1603
+ }
654
1604
  const newBlocks = calculateStaticBlocks(self);
655
- const newKeys = newBlocks.blocks.map(b => b.key).join(',');
1605
+ // Hand back the previous BlockSet when the recompute produced the same
1606
+ // blocks, so a sideways scroll past the coverage edge doesn't re-render
1607
+ // every track. minimumBlockWidth is part of the guard because block
1608
+ // keys don't encode ContentBlock-vs-ElidedBlock, so a region can flip
1609
+ // type with an unchanged key. Geometry is compared first: on a zoom it
1610
+ // always differs, which skips the key walk entirely — the whole-genome
1611
+ // view has one block per refName, so joining those keys into a string
1612
+ // (as this used to) allocated an array and a string per zoom frame.
656
1613
  if (currentlyCalculatedStaticBlocks === undefined ||
657
- currentBlockKeys !== newKeys) {
1614
+ bpPerPx !== prevBpPerPx ||
1615
+ width !== prevWidth ||
1616
+ minimumBlockWidth !== prevMinimumBlockWidth ||
1617
+ !sameBlockKeys(currentlyCalculatedStaticBlocks, newBlocks)) {
658
1618
  currentlyCalculatedStaticBlocks = newBlocks;
659
- currentBlockKeys = newKeys;
660
- return currentlyCalculatedStaticBlocks;
661
1619
  }
662
- else {
663
- return currentlyCalculatedStaticBlocks;
1620
+ // Update coverage range from content block extent only.
1621
+ // Using all blocks (including padding) would inflate the range
1622
+ // and let the fast path return stale blocks when the viewport
1623
+ // scrolls into the padding area where no content blocks exist.
1624
+ const cBlocks = currentlyCalculatedStaticBlocks.contentBlocks;
1625
+ if (cBlocks.length > 0) {
1626
+ const last = cBlocks[cBlocks.length - 1];
1627
+ coverageLeftPx = cBlocks[0].offsetPx;
1628
+ coverageRightPx = last.offsetPx + last.widthPx;
664
1629
  }
1630
+ prevBpPerPx = bpPerPx;
1631
+ prevWidth = width;
1632
+ prevMinimumBlockWidth = minimumBlockWidth;
1633
+ prevDisplayedRegions = displayedRegions;
1634
+ return currentlyCalculatedStaticBlocks;
665
1635
  },
1636
+ /**
1637
+ * #getter
1638
+ * dynamic blocks represent the exact coordinates of the currently
1639
+ * visible genome regions on the screen. they are similar to static
1640
+ * blocks, but static blocks can go offscreen while dynamic blocks
1641
+ * represent exactly what is on screen
1642
+ */
666
1643
  get dynamicBlocks() {
667
1644
  return calculateDynamicBlocks(self);
668
1645
  },
669
- get roundedDynamicBlocks() {
670
- return this.dynamicBlocks.contentBlocks.map(block => ({
671
- ...block,
672
- start: Math.floor(block.start),
673
- end: Math.ceil(block.end),
674
- }));
1646
+ /**
1647
+ * #getter
1648
+ * all overview scalebar blocks (content + elided), laid out on the
1649
+ * overviewLayout. memoized so the scalebar doesn't recompute it per
1650
+ * render
1651
+ */
1652
+ get overviewBlocks() {
1653
+ return calculateDynamicBlocks(self.overviewLayout).blocks;
1654
+ },
1655
+ /**
1656
+ * #getter
1657
+ * leading/trailing pixel span of the visible regions projected onto the
1658
+ * overviewLayout — the geometry of the overview's "you are here"
1659
+ * rectangle, and of the top edge of the polygon drawn under it. Elided
1660
+ * regions count: at whole-genome zoom the tail of an assembly is all
1661
+ * coalesced tiny contigs, and stopping at the last *content* block left
1662
+ * the rectangle short of (or, scrolled onto that tail, absent from) the
1663
+ * polygon it sits on.
1664
+ */
1665
+ get overviewRegionPxSpan() {
1666
+ return getOverviewRegionPxSpan({
1667
+ overview: self.overviewLayout,
1668
+ bpPerPx: self.bpPerPx,
1669
+ blocks: this.dynamicBlocks.blocks,
1670
+ });
1671
+ },
1672
+ /**
1673
+ * #getter
1674
+ * The x shift that maps the **staticBlocks frame** onto the viewport:
1675
+ * `translateX(view.staticBlocksTranslateX)` on one container places
1676
+ * every `gridlineTicks`, `scalebarLabels` and `paddingSpans` entry at
1677
+ * once, and a pan then moves that single transform rather than each
1678
+ * child.
1679
+ *
1680
+ * The frame exists because those three are laid out across every
1681
+ * displayed region rather than across the viewport — it overhangs on
1682
+ * both sides — so their `x` values are stable under a scroll and only
1683
+ * this number moves. `scalebarRefNameLabels` is the exception and says
1684
+ * so: its `transform` is already a screen x, because a sticky label's
1685
+ * position is a function of the scroll rather than of block geometry.
1686
+ *
1687
+ * **The subtraction must happen here, in float64, and that is the
1688
+ * load-bearing part.** `offsetPx` is a whole-genome pixel coordinate —
1689
+ * hg38 chr1 at base resolution is already past 1e10 — and a length that
1690
+ * size does not survive the trip through CSS: the transform matrix is
1691
+ * float32 by the time it reaches the compositor, where consecutive
1692
+ * representable values at 1e10 are ~1024px apart, and layout saturates
1693
+ * sooner still (Blink's `LayoutUnit` is int32 at 1/64px, so ±33.5M px).
1694
+ * So the shape that looks obvious — lay an overlay out in absolute
1695
+ * genome pixels, write `translateX(-view.offsetPx)` — does not lose a
1696
+ * subpixel, it puts the row somewhere else entirely, and only on large
1697
+ * assemblies at high zoom, which is not where anyone tests. Both
1698
+ * operands here are large and their difference is bounded by the
1699
+ * overhang (a block or two), so what reaches CSS is small and exact.
1700
+ * Same rule as the GPU side, one layer up —
1701
+ * `agent-docs/reference/BP_PRECISION.md`.
1702
+ *
1703
+ * Published because it is the one piece of coordinate arithmetic a host
1704
+ * drawing its own chrome would otherwise have to know, and because
1705
+ * having it written out at each call site is how the two in-tree copies
1706
+ * came to disagree about rounding. Round it where the content is text
1707
+ * (a fractional offset blurs a label); leave it alone for paths and
1708
+ * boxes.
1709
+ */
1710
+ get staticBlocksTranslateX() {
1711
+ return this.staticBlocks.offsetPx - self.offsetPx;
675
1712
  },
1713
+ /**
1714
+ * #getter
1715
+ * Gridline tick positions (x relative to the staticBlocks frame),
1716
+ * derived from staticBlocks + bpPerPx. Computed once and shared by every
1717
+ * Gridlines instance (scalebar, main view, each pinned track) rather
1718
+ * than recomputing the makeTicks loop per component.
1719
+ */
1720
+ get gridlineTicks() {
1721
+ const { bpPerPx } = self;
1722
+ const { blocks, offsetPx: firstBlockOffset } = this.staticBlocks;
1723
+ const ticks = [];
1724
+ // iterate merged region runs (like scalebarLabels) so a tick on an
1725
+ // internal ~800px chunk boundary isn't emitted twice (once as a
1726
+ // chunk's right edge, once as the next chunk's left edge)
1727
+ for (const run of groupContiguousBlocks(blocks)) {
1728
+ const runLeft = run.offsetPx - firstBlockOffset;
1729
+ for (const { type, x } of makeBlockTicks(run, bpPerPx)) {
1730
+ if (x >= 0 && x <= run.widthPx) {
1731
+ ticks.push({ x: runLeft + x, major: type === 'major' });
1732
+ }
1733
+ }
1734
+ }
1735
+ return ticks;
1736
+ },
1737
+ /**
1738
+ * #getter
1739
+ * Scalebar coordinate labels (x in the staticBlocks frame + display
1740
+ * text). Sibling of gridlineTicks sharing the same makeBlockTicks
1741
+ * formula, so labels line up exactly with their gridlines. staticBlocks
1742
+ * chop a region into ~800px chunks; groupContiguousBlocks merges them
1743
+ * back per region so a label on an internal chunk boundary isn't clipped
1744
+ * away by both neighbors — only genuine region edges clip a label.
1745
+ *
1746
+ * A run that can hold too few labels to make a ruler goes unnumbered
1747
+ * (`tickLabelsWorthDrawing`), the same rule the overview scalebar and
1748
+ * the dotplot axes apply: with a whole genome displayed each chromosome
1749
+ * catches one lone coordinate, which conveys no scale by itself and
1750
+ * reads as the same number repeated across the row.
1751
+ */
1752
+ get scalebarLabels() {
1753
+ const { bpPerPx } = self;
1754
+ const { blocks, offsetPx: firstBlockOffset } = this.staticBlocks;
1755
+ const labels = [];
1756
+ const runs = groupContiguousBlocks(blocks);
1757
+ const refNameLabelPx = runRefNameLabelPx(runs, self.displayedRegionsOrientation);
1758
+ for (const [i, run] of runs.entries()) {
1759
+ const runLeft = run.offsetPx - firstBlockOffset;
1760
+ const runLabels = [];
1761
+ for (const { base, x } of makeBlockTicks(run, bpPerPx, true, false)) {
1762
+ const label = getTickDisplayStr(base + 1, bpPerPx);
1763
+ const w = tickLabelWidth(label);
1764
+ // the bold refName label pinned at the run's left edge takes
1765
+ // precedence, as it does on the overview scalebar
1766
+ if (x - w / 2 >= refNameLabelPx[i] &&
1767
+ labelFitsInBlock(x - w / 2, w, run.widthPx)) {
1768
+ runLabels.push({
1769
+ x: runLeft + x,
1770
+ label,
1771
+ key: `${run.offsetPx}-${base}`,
1772
+ });
1773
+ }
1774
+ }
1775
+ if (tickLabelsWorthDrawing(runLabels.length)) {
1776
+ labels.push(...runLabels);
1777
+ }
1778
+ }
1779
+ return labels;
1780
+ },
1781
+ /**
1782
+ * #getter
1783
+ * The bold refName labels drawn along the scalebar, as plain data:
1784
+ * `{key, refName, displayedRegionIndex, lastDisplayedRegionIndex,
1785
+ * sticky, transform, maxWidth, paddingLeft, text}` each. One per run of
1786
+ * same-refName regions, plus a "sticky" one pinned to the viewport's
1787
+ * left edge naming the refName under it, so panning into a chromosome
1788
+ * does not scroll its own name off the screen.
1789
+ *
1790
+ * Three rules live in here that a host drawing region names will
1791
+ * otherwise rediscover the hard way, and two of them are invisible until
1792
+ * the data is awkward:
1793
+ *
1794
+ * - the sticky label rides the rightmost run that has scrolled off the
1795
+ * left, not the region's first block — that one is gone from
1796
+ * staticBlocks entirely once you zoom into a region's interior, taking
1797
+ * the chromosome name off screen at exactly the zoom where nothing
1798
+ * else names it.
1799
+ * - adjacent regions of the same refName (collapsed introns) get one
1800
+ * label between them, not one each. That label names the whole run and
1801
+ * is fitted to the whole run, and `displayedRegionIndex` /
1802
+ * `lastDisplayedRegionIndex` bracket what it stands for.
1803
+ * - a name is drawn whole or not at all, measured against the space its
1804
+ * run leaves. Clipped to its own width, `chr16` reads as `chr1` —
1805
+ * a different chromosome rather than a shortened name, which is why
1806
+ * this is a fit test rather than an ellipsis.
1807
+ *
1808
+ * **Viewport frame, unlike its siblings.** `transform` is a screen x,
1809
+ * already net of `offsetPx`, where gridlineTicks / scalebarLabels /
1810
+ * paddingSpans are all in the staticBlocks frame. The sticky label's
1811
+ * position is a function of `offsetPx` rather than of block geometry, so
1812
+ * it has no fixed position in that frame — and for the same reason this
1813
+ * getter recomputes on every scroll frame where those three do not.
1814
+ *
1815
+ * `caption` accompanies the labels: the chip at the row's left edge,
1816
+ * saying which assembly the row is (a container view — synteny — opts
1817
+ * into that through `scalebarDisplayPrefix`) and whether it is flipped.
1818
+ * A ` [rev]` there is a fact about the ROW; the same marker on a
1819
+ * chromosome name means that one region, which only happens under mixed
1820
+ * orientation. `captionSpanPx` is the width it takes, which the
1821
+ * coordinate numbers stay out from under. The SVG export deliberately
1822
+ * calls `getScalebarRefNameLabels` itself with no prefix rather than
1823
+ * reading this, since it draws its own assembly name above the ruler.
1824
+ */
1825
+ get scalebarRefNameLabels() {
1826
+ return getScalebarRefNameLabels({
1827
+ blocks: this.staticBlocks.blocks,
1828
+ offsetPx: self.offsetPx,
1829
+ prefix: self.scalebarDisplayPrefix,
1830
+ orientation: self.displayedRegionsOrientation,
1831
+ });
1832
+ },
1833
+ /**
1834
+ * #getter
1835
+ * Every span along the row that is not track data, as plain geometry in
1836
+ * the staticBlocks frame — the same frame as gridlineTicks and
1837
+ * scalebarLabels, so one `translateX(staticBlocksTranslateX)` places all
1838
+ * three. Three kinds, and a host drawing its own chrome needs all of
1839
+ * them:
1840
+ *
1841
+ * - `seam`: the 3px bar at a region's right edge. Displayed regions are
1842
+ * laid out **contiguously** — calculateStaticBlocks emits boundary
1843
+ * padding only before the first region and after the last — so this
1844
+ * bar is the only thing separating two of them. Without it a
1845
+ * two-region view reads as a one-region view scrolled somewhere
1846
+ * strange. `isRightEndOfDisplayedRegion` is what marks it, and the
1847
+ * right edge of the blocks currently *loaded* — which is what block
1848
+ * geometry hands you — is not the same filter.
1849
+ * - `elided`: a region too narrow to draw at this zoom. Whole-genome on
1850
+ * a real assembly is mostly these — hg38 has 455 sequences and all
1851
+ * but the 24 chromosomes land sub-pixel — so a host that skips them
1852
+ * renders that tail as nothing at all.
1853
+ * - `boundary`: past the start of the first region or the end of the
1854
+ * last. Greying it is what makes the last region's seam read as the
1855
+ * edge of a filled area rather than a rule floating in the track.
1856
+ *
1857
+ * Elided blocks get no seam even though they carry the flag: at the
1858
+ * zoom where regions elide, one bar per region is a solid grey wall.
1859
+ *
1860
+ * PaddingBlocks is the in-tree consumer. The SVG export is deliberately
1861
+ * NOT one: `SVGRegionSeparators` walks dynamicBlocks itself and draws
1862
+ * only the seam, because `elided` and `boundary` are chrome for an
1863
+ * interactive row rather than information a figure carries — striped
1864
+ * grey saying "regions here are too narrow to draw" is noise in a
1865
+ * static image, and at whole-genome zoom it would be most of the row.
1866
+ * The seam is the one that must survive: regions lay out contiguously,
1867
+ * so it is all that separates two of them.
1868
+ */
1869
+ get paddingSpans() {
1870
+ const { blocks, offsetPx: firstBlockOffset } = this.staticBlocks;
1871
+ const spans = [];
1872
+ for (const block of blocks) {
1873
+ const x = block.offsetPx - firstBlockOffset;
1874
+ if (block.type === 'ContentBlock') {
1875
+ if (block.isRightEndOfDisplayedRegion) {
1876
+ spans.push({
1877
+ key: `${block.key}-sep`,
1878
+ x: x + block.widthPx - 1,
1879
+ width: 3,
1880
+ kind: 'seam',
1881
+ });
1882
+ }
1883
+ }
1884
+ else {
1885
+ spans.push({
1886
+ key: block.key,
1887
+ x,
1888
+ width: block.widthPx,
1889
+ kind: block.type === 'ElidedBlock'
1890
+ ? 'elided'
1891
+ : 'boundary',
1892
+ });
1893
+ }
1894
+ }
1895
+ return spans;
1896
+ },
1897
+ /**
1898
+ * #getter
1899
+ * Integer-rounded sum of all visible block widths. Slightly less than
1900
+ * view.width when the genome ends before the right edge; use view.width
1901
+ * for SVG clip rects (display boundary) and this for paint canvas sizing
1902
+ * (actual content width).
1903
+ */
1904
+ get totalWidthPx() {
1905
+ return Math.round(this.dynamicBlocks.totalWidthPx);
1906
+ },
1907
+ /**
1908
+ * #getter
1909
+ * Like totalWidthPx but excluding inter-region boundary blocks. Used
1910
+ * when column layout divides the canvas width by feature count.
1911
+ */
1912
+ get totalWidthPxWithoutBorders() {
1913
+ return Math.round(this.dynamicBlocks.totalWidthPxWithoutBorders);
1914
+ },
1915
+ /**
1916
+ * #getter
1917
+ */
1918
+ get visibleBp() {
1919
+ return this.dynamicBlocks.totalBp;
1920
+ },
1921
+ /**
1922
+ * #getter
1923
+ * Whether any part of a displayed region actually falls inside the
1924
+ * viewport. False both when the view holds no regions at all and when
1925
+ * it holds some but is scrolled entirely off them; either way there is
1926
+ * no visible span for the scalebar, ruler and refName labels to
1927
+ * describe, which is what the SVG export's header checks before drawing
1928
+ * one. Distinct from `hasDisplayedRegions`, which only asks whether the
1929
+ * view has been given regions, not whether any are on screen.
1930
+ */
1931
+ get hasVisibleContent() {
1932
+ return this.dynamicBlocks.contentBlocks.length > 0;
1933
+ },
1934
+ /**
1935
+ * #getter
1936
+ * What is on screen, as regions that address whole bases — what a
1937
+ * reader means by "the visible region" when they ask to fetch it,
1938
+ * bookmark it or read it back in a locString field. `visibleRegions`
1939
+ * below is the other half: pixels, and fractional by design.
1940
+ */
1941
+ get visibleWholeBaseRegions() {
1942
+ return wholeBaseRegions(this.dynamicBlocks.contentBlocks);
1943
+ },
1944
+ /**
1945
+ * #getter
1946
+ * Returns the currently visible content blocks with screen pixel
1947
+ * positions and displayedRegionIndex guaranteed.
1948
+ * Used by WebGL displays for per-region data fetching and rendering.
1949
+ */
1950
+ get visibleRegions() {
1951
+ return this.dynamicBlocks.contentBlocks.map(block => {
1952
+ const screenStartPx = block.offsetPx - self.offsetPx;
1953
+ return {
1954
+ refName: block.refName,
1955
+ start: block.start,
1956
+ end: block.end,
1957
+ assemblyName: block.assemblyName,
1958
+ reversed: block.reversed,
1959
+ displayedRegionIndex: block.displayedRegionIndex,
1960
+ screenStartPx,
1961
+ screenEndPx: screenStartPx + block.widthPx,
1962
+ };
1963
+ });
1964
+ },
1965
+ /**
1966
+ * #getter
1967
+ * visibleRegions expanded by a half-screen buffer on each side,
1968
+ * clamped to displayedRegion bounds, with integer-rounded coordinates.
1969
+ * Use this when fetching data that should extend slightly beyond the
1970
+ * viewport for smooth scrolling.
1971
+ */
1972
+ get bufferedVisibleRegions() {
1973
+ const bufferBp = Math.ceil(self.width * self.bpPerPx * 0.5);
1974
+ return this.visibleRegions.map(vr => {
1975
+ const dr = self.displayedRegions[vr.displayedRegionIndex];
1976
+ return {
1977
+ region: {
1978
+ refName: vr.refName,
1979
+ start: Math.max(dr.start, Math.floor(vr.start) - bufferBp),
1980
+ end: Math.min(dr.end, Math.ceil(vr.end) + bufferBp),
1981
+ assemblyName: vr.assemblyName,
1982
+ // orientation rides along with the fetch region, not just with
1983
+ // the render blocks: canvas records it on the rpcDataMap entry
1984
+ // (`reversedRegions`) because label overhang packs toward lower
1985
+ // bp in a flipped region. A flip mutates `displayedRegions`, so
1986
+ // DisplayedRegionsChange refetches and the recorded flag can't
1987
+ // go stale.
1988
+ reversed: vr.reversed,
1989
+ },
1990
+ displayedRegionIndex: vr.displayedRegionIndex,
1991
+ };
1992
+ });
1993
+ },
1994
+ /**
1995
+ * #getter
1996
+ * a single "combo-locstring" representing all the regions visible on
1997
+ * the screen
1998
+ */
676
1999
  get visibleLocStrings() {
677
2000
  return calculateVisibleLocStrings(this.dynamicBlocks.contentBlocks);
678
2001
  },
2002
+ /**
2003
+ * #getter
2004
+ * **What a debounced consumer clips to**: the coarse blocks once the
2005
+ * view has settled at least once, the live ones before that.
2006
+ *
2007
+ * The coarse blocks exist so a per-bp scan does not recompute on every
2008
+ * animation frame during a pan or zoom — wiggle's autoscale domain, the
2009
+ * alignments coverage scale and MAF's coverage band are the three — and
2010
+ * while they are stale
2011
+ * the answer is merely a frame or two old, which is what the debounce
2012
+ * means. **Empty is different in kind.** A scan over no blocks yields no
2013
+ * entries, and no entries is not a stale domain, it is the fallback one:
2014
+ * `[0,1]`, which draws a line plot blank and a density plot saturated.
2015
+ * That window is the 500ms between a view initializing and the coarse
2016
+ * autorun's first run, and data can now land inside it — the per-region
2017
+ * fetch used to be trailing-edge at 600ms, so it never did.
2018
+ *
2019
+ * One recompute at that transition, against N per frame, which is the
2020
+ * trade the guard was making anyway.
2021
+ */
2022
+ get settledDynamicBlocks() {
2023
+ return self.coarseDynamicBlocks.length
2024
+ ? self.coarseDynamicBlocks
2025
+ : this.dynamicBlocks.contentBlocks;
2026
+ },
2027
+ /**
2028
+ * #getter
2029
+ * same as visibleLocStrings, but only updated every 500ms
2030
+ */
679
2031
  get coarseVisibleLocStrings() {
680
2032
  return calculateVisibleLocStrings(self.coarseDynamicBlocks);
681
2033
  },
2034
+ /**
2035
+ * #getter
2036
+ */
682
2037
  get coarseTotalBpDisplayStr() {
683
2038
  return getBpDisplayStr(self.coarseTotalBp);
684
2039
  },
685
- get effectiveBpPerPx() {
686
- return self.targetBpPerPx ?? self.bpPerPx;
687
- },
2040
+ /**
2041
+ * #getter
2042
+ */
688
2043
  get effectiveTotalBp() {
689
- return this.effectiveBpPerPx * self.width;
2044
+ return self.bpPerPx * self.width;
690
2045
  },
2046
+ /**
2047
+ * #getter
2048
+ */
691
2049
  get effectiveTotalBpDisplayStr() {
692
2050
  return getBpDisplayStr(this.effectiveTotalBp);
693
2051
  },
694
2052
  };
695
2053
  })
696
2054
  .actions(self => ({
697
- setCoarseDynamicBlocks(blocks) {
698
- self.coarseDynamicBlocks = blocks.contentBlocks;
699
- self.coarseTotalBp = blocks.totalBp;
2055
+ /**
2056
+ * #action
2057
+ */
2058
+ setCoarseDynamicBlocks(blocks, bpPerPx) {
2059
+ // Consumers diff the coarse blocks by reference (the alignments and
2060
+ // wiggle fetch autoruns), so assigning an equivalent array would make
2061
+ // them refetch. `settleCoarseBlocks` flushes these on a discrete jump
2062
+ // and the debounced autorun then fires once more with the same blocks —
2063
+ // that follow-up has to cost nothing. A block's key encodes its
2064
+ // assembly/refName/start/end, so keys are enough to compare.
2065
+ const next = blocks.contentBlocks;
2066
+ const same = bpPerPx === self.coarseBpPerPx &&
2067
+ next.length === self.coarseDynamicBlocks.length &&
2068
+ next.every((b, i) => b.key === self.coarseDynamicBlocks[i].key);
2069
+ if (!same) {
2070
+ self.coarseDynamicBlocks = next;
2071
+ self.coarseTotalBp = blocks.totalBp;
2072
+ self.coarseBpPerPx = bpPerPx;
2073
+ }
2074
+ },
2075
+ }))
2076
+ .actions(self => ({
2077
+ /**
2078
+ * #action
2079
+ * Bring the coarse blocks to the viewport as it stands now, for a
2080
+ * placement that JUMPED rather than travelled.
2081
+ *
2082
+ * The coarse blocks are a 500ms throttle, and every consumer trades
2083
+ * freshness for not recomputing per animation frame — the location box,
2084
+ * canvas's on-screen feature set, and the two per-bp scans behind
2085
+ * `settledDynamicBlocks`. That trade is only sound while the answer is a
2086
+ * few frames old. **A jump makes it unrelated instead**, because there is
2087
+ * nothing to coalesce: the viewport left behind is not an approximation of
2088
+ * the new one, it is a different place. Unsettled, the location box reads
2089
+ * as a navigation that didn't happen, and wiggle's autoscale domain is
2090
+ * computed over the window the user just left — measured at `[0,200]`
2091
+ * against a correct `[0,300]` when a 40bp coarse window survived a jump to
2092
+ * 4040bp on screen. Only the per-region fetch moving to the leading edge
2093
+ * made that reachable: at 600ms the data never landed inside the hole.
2094
+ *
2095
+ * **The continuous paths deliberately do not come through here.** The
2096
+ * spring zoom writes through `zoomTo` per frame and a drag through
2097
+ * `scrollTo`, so this adds no per-frame work; the settle is idempotent
2098
+ * (`setCoarseDynamicBlocks` compares block keys), so the debounced
2099
+ * autorun's own follow-up run costs nothing.
2100
+ *
2101
+ * Every placer calls it, and `placersSettleCoarseBlocks.test.ts` is what
2102
+ * makes that true of the next one — it scans this file for the writes and
2103
+ * fails on a placer that reaches neither the settle nor its named list of
2104
+ * continuous paths. Two passes by hand missed four.
2105
+ */
2106
+ settleCoarseBlocks() {
2107
+ if (self.initialized) {
2108
+ self.setCoarseDynamicBlocks(self.dynamicBlocks, self.bpPerPx);
2109
+ }
700
2110
  },
701
2111
  }))
702
2112
  .actions(self => ({
2113
+ /**
2114
+ * #action
2115
+ * offset is the base-pair-offset in the displayed region, index is the
2116
+ * index of the displayed region in the linear genome view
2117
+ *
2118
+ * @param start - object as `{start, end, offset, index}`
2119
+ * @param end - object as `{start, end, offset, index}`
2120
+ */
703
2121
  moveTo(start, end) {
704
2122
  moveTo(self, start, end);
2123
+ self.settleCoarseBlocks();
2124
+ },
2125
+ /**
2126
+ * #action
2127
+ * Place the viewport by the pair of PIXEL quantities it used to be stored
2128
+ * as. Prefer `setWindow`: pixels mean nothing without the width they were
2129
+ * measured at, so a round trip through here is only exact while the width
2130
+ * holds still. Kept for callers that genuinely have pixels — a wheel
2131
+ * gesture, a rubberband — and for reading old snapshots.
2132
+ */
2133
+ setNewView(bpPerPx, offsetPx) {
2134
+ self.zoomTo(bpPerPx);
2135
+ self.scrollTo(offsetPx);
2136
+ self.settleCoarseBlocks();
2137
+ },
2138
+ /**
2139
+ * #action
2140
+ * Place the viewport by the pair it is actually STORED as: the window's
2141
+ * width and left edge, both in bp. `setNewView`'s bp-space twin, and the
2142
+ * right one for anything that captures a viewport and puts it back later
2143
+ * (an Undo, a saved location) — those two moments can be a window resize
2144
+ * apart, and this pair survives one where a pixel pair does not.
2145
+ *
2146
+ * Still clamped, by the same zoom and scroll limits as every other mover:
2147
+ * the regions may have changed under it, and a window the new set can't
2148
+ * hold is not restorable however it was spelled.
2149
+ */
2150
+ setWindow(windowWidthBp, windowStartBp) {
2151
+ this.setWindowFrame(windowWidthBp, windowStartBp);
2152
+ self.settleCoarseBlocks();
2153
+ },
2154
+ /**
2155
+ * #action
2156
+ * `setWindow` without the settle, for a writer that runs per animation
2157
+ * frame. Flushing the coarse blocks sixty times a second is the one thing
2158
+ * such a writer must not do: they are a 500ms throttle that a synteny
2159
+ * follow pass and two autoscale domains hang off, and settling each frame
2160
+ * turns each of those into per-frame work — `positionViewOnSpans` states
2161
+ * the same rule for the same reason.
2162
+ */
2163
+ setWindowFrame(windowWidthBp, windowStartBp) {
2164
+ // via zoomTo (not a bare assignment) for its clamp; `width` cancels, so
2165
+ // the bp width in is the bp width out at whatever width is current
2166
+ self.zoomTo(windowWidthBp / self.width);
2167
+ self.scrollToBp(windowStartBp);
2168
+ },
2169
+ /**
2170
+ * #action
2171
+ * The worst jump of them to leave unsettled: the coarse blocks are keyed
2172
+ * by `displayedRegionIndex`, so a set that outlives the region list has a
2173
+ * consumer reading one contig's data against another's blocks.
2174
+ */
2175
+ setDisplayedRegions(regions) {
2176
+ self.displayedRegions = cast(regions);
2177
+ // new regions move both bounds: zoomTo re-clamps bpPerPx into the new
2178
+ // [minBpPerPx, maxBpPerPx], scrollTo re-clamps offsetPx into the new
2179
+ // [minOffset, maxOffset]. scrollTo is called explicitly rather than
2180
+ // left to zoomTo's internal scrollTo, which is skipped when bpPerPx
2181
+ // is already in range — shrinking the region set would otherwise
2182
+ // strand the view scrolled past the end, on blank space.
2183
+ self.zoomTo(self.bpPerPx);
2184
+ self.scrollTo(self.offsetPx);
2185
+ self.settleCoarseBlocks();
2186
+ },
2187
+ /**
2188
+ * #action
2189
+ * Re-assert `maxBpPerPx` over the current zoom, for a container whose
2190
+ * shared ceiling just fell — otherwise the view is stranded above it with
2191
+ * zoom-out disabled and the slider's min past its value. Goes through
2192
+ * `zoomTo` so the clamp keeps one definition, and settles because a
2193
+ * ceiling drop moves the window in one step.
2194
+ *
2195
+ * Requires an `initialized` view; the caller checks, since an MST action
2196
+ * reads untracked and a guard here would never re-run once the row
2197
+ * arrived.
2198
+ */
2199
+ clampZoomToCeiling() {
2200
+ self.zoomTo(self.bpPerPx);
2201
+ self.settleCoarseBlocks();
705
2202
  },
706
- async navToLocString(input, optAssemblyName, grow) {
2203
+ /**
2204
+ * #action
2205
+ */
2206
+ showAllRegions() {
2207
+ self.windowWidthBp = self.maxBpPerPx * self.width;
2208
+ self.scrollTo(getCenteredOffsetPx(self.displayedRegionsTotalPx, self.width));
2209
+ self.settleCoarseBlocks();
2210
+ },
2211
+ /**
2212
+ * #action
2213
+ * Fit the displayed regions to the width exactly, edge to edge.
2214
+ *
2215
+ * Not the same as `showAllRegions`, which goes to `maxBpPerPx` — the
2216
+ * zoom-out LIMIT, where `SHOW_ALL_REGIONS_FILL` deliberately keeps a 10%
2217
+ * margin so the whole genome doesn't sit flush against both edges. That
2218
+ * margin is right for "show me everything" and wrong for a caller that
2219
+ * named the regions it wants: it draws them at 1/0.9 of fit-to-width,
2220
+ * centered, which is a silent 11% scale difference from the
2221
+ * single-region path (`moveTo`, span/width) reached through the same
2222
+ * location box.
2223
+ *
2224
+ * The scale comes from `fitAllRegionsWindow`, which is where the rule is
2225
+ * written — a snapshot builder that cannot call an action needs the same
2226
+ * answer, and the two agreeing matters more than either being local. Where
2227
+ * it clamps up to `minBpPerPx` the content is narrower than the view, and
2228
+ * the centering is what frames it.
2229
+ */
2230
+ fitAllRegions() {
2231
+ // The window IS the displayed regions, which is what fitting them means.
2232
+ // Nothing is fitted into a width of zero, where the pixel form was
2233
+ // equally meaningless.
2234
+ if (self.width) {
2235
+ self.windowWidthBp = fitAllRegionsWindow(self.totalBp, self.width, self.minBpPerPx).windowWidthBp;
2236
+ }
2237
+ // Deliberately not the helper's `windowStartBp`: this is the same
2238
+ // centering, but going through scrollTo gets the scroll clamp with it,
2239
+ // and getCenteredOffsetPx is already the one definition of it in pixels.
2240
+ self.scrollTo(getCenteredOffsetPx(self.displayedRegionsTotalPx, self.width));
2241
+ self.settleCoarseBlocks();
2242
+ },
2243
+ /**
2244
+ * #action
2245
+ */
2246
+ horizontallyFlip() {
2247
+ self.displayedRegions = cast([...self.displayedRegions]
2248
+ .reverse()
2249
+ .map(region => ({ ...region, reversed: !region.reversed })));
2250
+ self.scrollTo(self.displayedRegionsTotalPx - self.offsetPx - self.width);
2251
+ self.settleCoarseBlocks();
2252
+ },
2253
+ }))
2254
+ .actions(self => ({
2255
+ /**
2256
+ * #action
2257
+ */
2258
+ showAllRegionsInAssembly(assemblyName) {
2259
+ const session = getSession(self);
2260
+ const { assemblyManager } = session;
707
2261
  const { assemblyNames } = self;
2262
+ if (!assemblyName && assemblyNames.length > 1) {
2263
+ session.notify(`Can't perform operation with multiple assemblies currently`);
2264
+ }
2265
+ else {
2266
+ const resolvedName = assemblyName ?? assemblyNames[0];
2267
+ const regions = resolvedName
2268
+ ? assemblyManager.get(resolvedName)?.regions
2269
+ : undefined;
2270
+ if (regions) {
2271
+ self.setDisplayedRegions(regions);
2272
+ self.showAllRegions();
2273
+ }
2274
+ }
2275
+ },
2276
+ /**
2277
+ * #action
2278
+ * this "clears the view" and makes the view return to the import form
2279
+ */
2280
+ clearView() {
2281
+ self.setDisplayedRegions([]);
2282
+ self.tracks.clear();
2283
+ // it is necessary to run these after setting displayed regions empty
2284
+ // or else model.offsetPx gets set to Infinity and breaks
2285
+ // @jbrowse/mobx-state-tree snapshot
2286
+ self.scrollTo(0);
2287
+ self.zoomTo(10);
2288
+ // after them, not through setDisplayedRegions, whose own settle ran at
2289
+ // the intermediate viewport these two lines exist to leave
2290
+ self.settleCoarseBlocks();
2291
+ },
2292
+ /**
2293
+ * #action
2294
+ * Navigate to a location based on its refName and optionally start, end,
2295
+ * and assemblyName. Will not try to change displayed regions, use
2296
+ * `navToLocations` instead. Only navigates to a location if it is
2297
+ * entirely within a displayedRegion. Navigates to the first matching
2298
+ * location encountered.
2299
+ *
2300
+ * Throws an error if navigation was unsuccessful
2301
+ *
2302
+ * @param query - a proposed location to navigate to
2303
+ * @param grow - optional multiplier to expand the region by (e.g., 0.2
2304
+ * adds 20% padding on each side)
2305
+ */
2306
+ navTo(query, grow) {
2307
+ this.navToMultiple([query], grow);
2308
+ },
2309
+ /**
2310
+ * #action
2311
+ * Navigate to a location based on its refName and optionally start, end,
2312
+ * and assemblyName. Will not try to change displayed regions, use
2313
+ * navToLocations instead. Only navigates to a location if it is entirely
2314
+ * within a displayedRegion. Navigates to the first matching location
2315
+ * encountered.
2316
+ *
2317
+ * Throws an error if navigation was unsuccessful
2318
+ *
2319
+ * @param locations - proposed location to navigate to
2320
+ * @param grow - optional multiplier to expand the region by (e.g., 0.2
2321
+ * adds 20% padding on each side)
2322
+ */
2323
+ navToMultiple(locations, grow) {
2324
+ if (locations.some(l => l.start !== undefined && l.end !== undefined && l.start > l.end)) {
2325
+ throw new Error('found start greater than end');
2326
+ }
2327
+ const firstLocation = locations.at(0);
2328
+ const lastLocation = locations.at(-1);
2329
+ if (!firstLocation || !lastLocation) {
2330
+ return;
2331
+ }
2332
+ const defaultAssemblyName = self.assemblyNames[0];
708
2333
  const { assemblyManager } = getSession(self);
2334
+ const { displayedRegions } = self;
2335
+ // The range spans from the first location's left edge to the last
2336
+ // location's right edge (any locations in between are ignored).
2337
+ const common = {
2338
+ assemblyManager,
2339
+ defaultAssemblyName,
2340
+ displayedRegions,
2341
+ grow,
2342
+ };
2343
+ self.moveTo(resolveNavEndpoint({
2344
+ ...common,
2345
+ location: firstLocation,
2346
+ side: 'left',
2347
+ }), resolveNavEndpoint({
2348
+ ...common,
2349
+ location: lastLocation,
2350
+ side: 'right',
2351
+ }));
2352
+ },
2353
+ }))
2354
+ .actions(self => ({
2355
+ /**
2356
+ * #action
2357
+ * Replace the region list and place the viewport in one step. The pair is
2358
+ * the unit a coarse-block consumer can act on: called separately they
2359
+ * publish the viewport in between, and post-await in `navToLocations`
2360
+ * they are two transactions, so a per-bp scan runs over a window that was
2361
+ * never on screen.
2362
+ *
2363
+ * With no location named it fits the regions rather than going through
2364
+ * `showAllRegions`: the caller named the regions it wants, so it gets the
2365
+ * width, where showAllRegions goes to the zoom-out LIMIT and its 10%
2366
+ * margin is dead frame for a named subset.
2367
+ */
2368
+ showRegions(regions, location) {
2369
+ self.setDisplayedRegions(regions);
2370
+ if (location) {
2371
+ self.navTo(location);
2372
+ }
2373
+ else {
2374
+ self.fitAllRegions();
2375
+ }
2376
+ },
2377
+ }))
2378
+ .actions(self => ({
2379
+ /**
2380
+ * #action
2381
+ * Navigate to the given locstring, will change displayed regions if
2382
+ * needed, and wait for assemblies to be initialized
2383
+ *
2384
+ * @param input - e.g. "chr1:1-100", "chr1:1-100 chr2:1-100", "chr 1 100"
2385
+ * @param optAssemblyName - (optional) the assembly name to use when
2386
+ * navigating to the locstring
2387
+ * @param grow - optional multiplier to expand the region by (e.g., 0.2
2388
+ * adds 20% padding on each side)
2389
+ * @returns whether the view moved. A multi-hit search raises the picker
2390
+ * instead of navigating, and both resolve — see `handleSelectedRegion`.
2391
+ */
2392
+ // annotated, not inferred: the same type cycle `showTrack` names. Left to
2393
+ // infer, the `boolean` reaches this model's type through `self` and 180
2394
+ // unrelated files lose their parameter types to it.
2395
+ async navToLocString(input, optAssemblyName, grow) {
2396
+ const { assemblyNames } = self;
2397
+ const session = getSession(self);
2398
+ const { assemblyManager } = session;
709
2399
  const assemblyName = optAssemblyName || assemblyNames[0];
710
2400
  if (assemblyName) {
711
2401
  await assemblyManager.waitForAssembly(assemblyName);
712
2402
  }
713
- return this.navToLocations(parseLocStrings(input, assemblyName, (ref, asm) => assemblyManager.isValidRefName(ref, asm)), assemblyName, grow);
714
- },
715
- async navToSearchString({ input, assembly, }) {
716
- await handleSelectedRegion({
2403
+ if (!isAlive(self)) {
2404
+ return false;
2405
+ }
2406
+ return handleSelectedRegion({
717
2407
  input,
718
- assembly,
2408
+ assemblyName,
2409
+ grow,
719
2410
  model: self,
720
2411
  });
721
2412
  },
722
- async navToLocation(parsedLocString, assemblyName, grow) {
723
- return this.navToLocations([parsedLocString], assemblyName, grow);
724
- },
2413
+ /**
2414
+ * #action
2415
+ * Similar to `navToLocString`, but accepts a list of parsed location
2416
+ * objects instead of a locstring. Will try to perform
2417
+ * `setDisplayedRegions` if changing regions
2418
+ *
2419
+ * @param regions - array of parsed location objects
2420
+ * @param assemblyName - optional assembly name
2421
+ * @param grow - optional multiplier to expand the region by (e.g., 0.2
2422
+ * adds 20% padding on each side)
2423
+ */
725
2424
  async navToLocations(regions, assemblyName, grow) {
2425
+ // an empty parse (e.g. blank/whitespace locstring) must not fall
2426
+ // through to the multi-region branch below, which would call
2427
+ // setDisplayedRegions([]) and blank the view
2428
+ if (regions.length === 0) {
2429
+ return;
2430
+ }
726
2431
  const { assemblyManager } = getSession(self);
727
2432
  await when(() => self.volatileWidth !== undefined);
2433
+ // Generate locations from the parsed regions
728
2434
  const locations = await generateLocations({
729
2435
  regions,
730
2436
  assemblyManager,
731
2437
  assemblyName,
732
2438
  grow,
733
2439
  });
2440
+ // the view may have been closed/detached while the assembly loaded
2441
+ if (!isAlive(self)) {
2442
+ return;
2443
+ }
2444
+ if (locations.length === 0) {
2445
+ return;
2446
+ }
2447
+ // Handle single location case
734
2448
  if (locations.length === 1) {
735
2449
  const location = locations[0];
736
2450
  const { reversed, parentRegion, start, end } = location;
737
- self.setDisplayedRegions([
738
- {
739
- reversed,
740
- ...parentRegion,
741
- },
742
- ]);
743
- this.navTo({
2451
+ // Clamped into the parentRegion bounds, whose lower bound is
2452
+ // parentRegion.start and not 0: the region being displayed IS
2453
+ // parentRegion, so a start below it fails navTo's containment check
2454
+ // wherever the parentRegion doesn't begin at 0.
2455
+ self.showRegions([{ ...parentRegion, reversed }], {
744
2456
  ...location,
745
- start: clamp(start ?? 0, 0, parentRegion.end),
746
- end: clamp(end ?? parentRegion.end, 0, parentRegion.end),
2457
+ start: clamp(start ?? parentRegion.start, parentRegion.start, parentRegion.end),
2458
+ end: clamp(end ?? parentRegion.end, parentRegion.start, parentRegion.end),
747
2459
  });
748
2460
  }
2461
+ // Handle multiple locations case. Each region is built from
2462
+ // parentRegion (not the parsed location) so it carries the canonical
2463
+ // refName rather than whatever alias/casing the user typed, doesn't
2464
+ // drag the whole nested parentRegion object into the persisted
2465
+ // displayedRegions, and stays clamped to the chromosome bounds that
2466
+ // `grow` may have pushed it past.
749
2467
  else {
750
- self.setDisplayedRegions(locations.map(location => {
751
- const { start, end } = location;
752
- return start === undefined || end === undefined
753
- ? location.parentRegion
754
- : {
755
- ...location,
756
- start,
757
- end,
758
- };
759
- }));
760
- self.showAllRegions();
2468
+ self.showRegions(locations.map(({ start, end, reversed, parentRegion }) => start === undefined || end === undefined
2469
+ ? { ...parentRegion, reversed }
2470
+ : {
2471
+ ...parentRegion,
2472
+ reversed,
2473
+ start: clamp(start, parentRegion.start, parentRegion.end),
2474
+ end: clamp(end, parentRegion.start, parentRegion.end),
2475
+ }));
761
2476
  }
762
2477
  },
763
- navTo(query, grow) {
764
- this.navToMultiple([query], grow);
765
- },
766
- navToMultiple(locations, grow) {
767
- if (locations.some(l => l.start !== undefined && l.end !== undefined && l.start > l.end)) {
768
- throw new Error('found start greater than end');
769
- }
770
- const firstLocation = locations.at(0);
771
- const lastLocation = locations.at(-1);
772
- if (!firstLocation || !lastLocation) {
773
- return;
774
- }
775
- const defaultAssemblyName = self.assemblyNames[0];
776
- const { assemblyManager } = getSession(self);
777
- const firstAssembly = assemblyManager.get(firstLocation.assemblyName || defaultAssemblyName);
778
- const firstRefName = firstAssembly?.getCanonicalRefName(firstLocation.refName) ||
779
- firstLocation.refName;
780
- const firstRegion = self.displayedRegions.find(r => r.refName === firstRefName);
781
- const lastAssembly = assemblyManager.get(lastLocation.assemblyName || defaultAssemblyName);
782
- const lastRefName = lastAssembly?.getCanonicalRefName(lastLocation.refName) ||
783
- lastLocation.refName;
784
- const lastRegion = findLast(self.displayedRegions, r => r.refName === lastRefName);
785
- if (!firstRegion) {
786
- throw new Error(`could not find a region with refName "${firstRefName}"`);
787
- }
788
- if (!lastRegion) {
789
- throw new Error(`could not find a region with refName "${lastRefName}"`);
790
- }
791
- let firstStart = firstLocation.start === undefined
792
- ? firstRegion.start
793
- : firstLocation.start;
794
- let firstEnd = firstLocation.end === undefined ? firstRegion.end : firstLocation.end;
795
- let lastStart = lastLocation.start === undefined
796
- ? lastRegion.start
797
- : lastLocation.start;
798
- let lastEnd = lastLocation.end === undefined ? lastRegion.end : lastLocation.end;
799
- if (grow) {
800
- const expanded = expandRegion(firstStart, lastEnd, grow, firstRegion.start, lastRegion.end);
801
- firstStart = expanded.start;
802
- firstEnd = expanded.end;
803
- lastStart = expanded.start;
804
- lastEnd = expanded.end;
805
- }
806
- const firstIndex = self.displayedRegions.findIndex(r => firstRefName === r.refName &&
807
- firstStart >= r.start &&
808
- firstStart <= r.end &&
809
- firstEnd <= r.end &&
810
- firstEnd >= r.start);
811
- const lastIndex = self.displayedRegions.findIndex(r => lastRefName === r.refName &&
812
- lastStart >= r.start &&
813
- lastStart <= r.end &&
814
- lastEnd <= r.end &&
815
- lastEnd >= r.start);
816
- if (firstIndex === -1 || lastIndex === -1) {
817
- throw new Error(`could not find a region that contained "${locations.map(l => assembleLocString(l))}"`);
818
- }
819
- const startDisplayedRegion = self.displayedRegions[firstIndex];
820
- const endDisplayedRegion = self.displayedRegions[lastIndex];
821
- const startOffset = startDisplayedRegion.reversed
822
- ? startDisplayedRegion.end - firstEnd
823
- : firstStart - startDisplayedRegion.start;
824
- const endOffset = endDisplayedRegion.reversed
825
- ? endDisplayedRegion.end - lastStart
826
- : lastEnd - endDisplayedRegion.start;
827
- this.moveTo({
828
- index: firstIndex,
829
- offset: startOffset,
830
- }, {
831
- index: lastIndex,
832
- offset: endOffset,
833
- });
2478
+ }))
2479
+ .actions(self => ({
2480
+ /**
2481
+ * #action
2482
+ * Similar to `navToLocString`, but accepts a parsed location object
2483
+ * instead of a locstring. Will try to perform `setDisplayedRegions` if
2484
+ * changing regions
2485
+ *
2486
+ * @param parsedLocString - a parsed location object with refName, start,
2487
+ * end, etc.
2488
+ * @param assemblyName - optional assembly name
2489
+ * @param grow - optional multiplier to expand the region by (e.g., 0.2
2490
+ * adds 20% padding on each side)
2491
+ */
2492
+ async navToLocation(parsedLocString, assemblyName, grow) {
2493
+ return self.navToLocations([parsedLocString], assemblyName, grow);
834
2494
  },
835
2495
  }))
836
2496
  .views(self => ({
2497
+ /**
2498
+ * #method
2499
+ */
837
2500
  rubberBandMenuItems() {
838
- return buildRubberBandMenuItems(self);
839
- },
840
- bpToPx({ refName, coord, regionNumber, }) {
841
- return bpToPx({ refName, coord, regionNumber, self });
842
- },
2501
+ return buildRubberBandMenuItems(self, self.rubberBandLaunchMenuItems());
2502
+ },
2503
+ /**
2504
+ * #method
2505
+ */
2506
+ bpToPx({ refName, coord, displayedRegionIndex, }) {
2507
+ return bpToPx({ refName, coord, displayedRegionIndex, self });
2508
+ },
2509
+ /**
2510
+ * #method
2511
+ * Map a highlight or bookmark region to its pixel position+width inside
2512
+ * the tracks container. Falls back to the raw refName if the region's
2513
+ * assemblyName is missing or unknown so highlights authored without an
2514
+ * assembly still render in single-assembly views.
2515
+ */
843
2516
  getHighlightCoords(region) {
844
- const { assemblyManager } = getSession(self);
845
- const asm = region.assemblyName
846
- ? assemblyManager.get(region.assemblyName)
847
- : undefined;
848
- const refName = asm?.getCanonicalRefName(region.refName) ?? region.refName;
849
- const s = this.bpToPx({ refName, coord: region.start });
850
- const e = this.bpToPx({ refName, coord: region.end });
851
- return s && e
852
- ? {
853
- width: Math.max(Math.abs(e.offsetPx - s.offsetPx), 3),
854
- left: Math.min(s.offsetPx, e.offsetPx) - self.offsetPx,
855
- }
2517
+ const refName = resolveCanonicalRefName(self, region);
2518
+ return getLayoutHighlightCoords(self, { ...region, refName });
2519
+ },
2520
+ /**
2521
+ * #method
2522
+ * like getHighlightCoords but laid out against the overview scalebar and
2523
+ * shifted by the cytoband offset
2524
+ */
2525
+ getOverviewHighlightCoords(region) {
2526
+ const refName = resolveCanonicalRefName(self, region);
2527
+ const coords = getLayoutHighlightCoords(self.overviewLayout, {
2528
+ ...region,
2529
+ refName,
2530
+ });
2531
+ return coords
2532
+ ? { ...coords, left: coords.left + self.cytobandOffset }
856
2533
  : undefined;
857
2534
  },
858
- centerAt(coord, refName, regionNumber) {
2535
+ /**
2536
+ * #method
2537
+ * scrolls the view to center on the given bp. if that is not in any of
2538
+ * the displayed regions, does nothing
2539
+ *
2540
+ * @param coord - basepair at which you want to center the view
2541
+ * @param refName - refName of the displayedRegion you are centering at
2542
+ * @param displayedRegionIndex - index of the displayedRegion
2543
+ */
2544
+ centerAt(coord, refName, displayedRegionIndex) {
859
2545
  const centerPx = this.bpToPx({
860
2546
  refName,
861
2547
  coord,
862
- regionNumber,
2548
+ displayedRegionIndex,
863
2549
  });
864
2550
  if (centerPx !== undefined) {
865
2551
  self.scrollTo(Math.round(centerPx.offsetPx - self.width / 2));
2552
+ self.settleCoarseBlocks();
866
2553
  }
867
2554
  },
2555
+ /**
2556
+ * #method
2557
+ */
868
2558
  pxToBp(px) {
869
2559
  return pxToBp(self, px);
870
2560
  },
2561
+ /**
2562
+ * #getter
2563
+ */
871
2564
  get centerLineInfo() {
872
2565
  return self.displayedRegions.length > 0
873
2566
  ? this.pxToBp(self.width / 2)
874
2567
  : undefined;
875
2568
  },
876
- get visibleRegions() {
877
- return self.dynamicBlocks.contentBlocks;
878
- },
879
2569
  }))
880
2570
  .views(self => ({
2571
+ /**
2572
+ * #method
2573
+ */
881
2574
  rubberbandClickMenuItems(clickOffset) {
882
2575
  return buildRubberbandClickMenuItems(self, clickOffset);
883
2576
  },
2577
+ /**
2578
+ * #method
2579
+ * returns menu items for a highlight context menu. plugins can extend
2580
+ * this via Core-extendPluggableElement to add their own items
2581
+ */
2582
+ highlightMenuItems(_highlight) {
2583
+ return [];
2584
+ },
884
2585
  }))
2586
+ .actions(self => {
2587
+ let cancelLastFlight = () => { };
2588
+ // Where a flight in progress is going, and nothing while none is. A
2589
+ // flight's frames are presentation: the viewport the view has COMMITTED
2590
+ // to is the destination, and a reader that wants "the zoom this view is
2591
+ // on" wants that rather than whatever the arc is passing through. Without
2592
+ // it a second click landing mid-flight framed its mate at the apex's
2593
+ // zoom, which is a whole chromosome wide for no reason the reader can see.
2594
+ let heading;
2595
+ /**
2596
+ * #action
2597
+ * Travel to a window rather than appear in it: the Van Wijk arc from
2598
+ * where the view is to where it is going, played over its own duration.
2599
+ *
2600
+ * WHAT LANDS IS WHAT `setWindow` WOULD HAVE LANDED — only the path in
2601
+ * between is new. That is what lets a caller offer an Undo, a snackbar or
2602
+ * a follow anchor around this exactly as it did around the instant move.
2603
+ *
2604
+ * It YIELDS to anything else that moves the view, by reading back what it
2605
+ * wrote and stopping the moment the view holds something else: a wheel
2606
+ * zoom, a drag, a locstring nav, or the Undo on the very snackbar the
2607
+ * flight was launched with. Compared against what was WRITTEN rather than
2608
+ * what was asked for, because the write clamps — an arc that pulls back
2609
+ * past `maxBpPerPx` reads its own clamped result back, and treating that
2610
+ * as interference would end the flight one frame in. `springAnimate`
2611
+ * defends the same value the same way.
2612
+ */
2613
+ function flyTo(centerBp, windowWidthBp) {
2614
+ cancelLastFlight();
2615
+ cancelLastFlight = () => { };
2616
+ const flight = planFlight({
2617
+ centerBp: self.windowStartBp + self.windowWidthBp / 2,
2618
+ windowWidthBp: self.windowWidthBp,
2619
+ }, { centerBp, windowWidthBp });
2620
+ if (flight.durationMs > 0 && self.width > 0) {
2621
+ heading = { centerBp, windowWidthBp };
2622
+ const startedAt = performance.now();
2623
+ let frameId;
2624
+ let written;
2625
+ cancelLastFlight = () => {
2626
+ if (frameId !== undefined) {
2627
+ cancelAnimationFrame(frameId);
2628
+ }
2629
+ };
2630
+ function place(t) {
2631
+ const at = flight.at(t);
2632
+ self.setWindowFrame(at.windowWidthBp, at.centerBp - at.windowWidthBp / 2);
2633
+ written = {
2634
+ widthBp: self.windowWidthBp,
2635
+ startBp: self.windowStartBp,
2636
+ };
2637
+ }
2638
+ function frame() {
2639
+ // Liveness before any read: a row can be removed mid-flight — the
2640
+ // level holding it closed, the track detached — and every read
2641
+ // below throws on a dead node, in a callback with nobody to catch
2642
+ // it.
2643
+ if (isAlive(self)) {
2644
+ const moved = written !== undefined &&
2645
+ (self.windowWidthBp !== written.widthBp ||
2646
+ self.windowStartBp !== written.startBp);
2647
+ if (!moved) {
2648
+ const t = (performance.now() - startedAt) / flight.durationMs;
2649
+ if (t < 1) {
2650
+ place(t);
2651
+ frameId = requestAnimationFrame(frame);
2652
+ }
2653
+ else {
2654
+ place(1);
2655
+ self.settleCoarseBlocks();
2656
+ heading = undefined;
2657
+ }
2658
+ }
2659
+ else {
2660
+ heading = undefined;
2661
+ }
2662
+ }
2663
+ }
2664
+ frame();
2665
+ }
2666
+ else {
2667
+ heading = undefined;
2668
+ self.setWindow(windowWidthBp, centerBp - windowWidthBp / 2);
2669
+ }
2670
+ }
2671
+ return {
2672
+ flyTo,
2673
+ /**
2674
+ * #action
2675
+ * `centerAt`'s animated twin: the same destination, reached along the
2676
+ * arc instead of jumped to, at the zoom the view is already on.
2677
+ */
2678
+ flyToCenter(coord, refName) {
2679
+ // `bpToLinearBp` rather than `bpToPx`, which rounds to a whole pixel
2680
+ // at the current zoom: mid-flight that zoom is a point on somebody
2681
+ // else's arc, and a destination quantized against it moves with it.
2682
+ const centerBp = bpToLinearBp({
2683
+ refName,
2684
+ coord,
2685
+ displayedRegions: self.displayedRegions,
2686
+ });
2687
+ if (centerBp !== undefined) {
2688
+ flyTo(centerBp, heading ? heading.windowWidthBp : self.windowWidthBp);
2689
+ }
2690
+ },
2691
+ };
2692
+ })
885
2693
  .actions(self => ({
886
2694
  afterCreate() {
887
2695
  setupKeyboardHandler(self);
@@ -890,45 +2698,70 @@ export function stateModelFactory(pluginManager) {
890
2698
  doAfterAttach(self);
891
2699
  },
892
2700
  }))
893
- .preProcessSnapshot(snap => {
2701
+ .preProcessSnapshot((snap) => {
894
2702
  if (!snap) {
895
2703
  return snap;
896
2704
  }
897
- const { highlight, ...rest } = snap;
2705
+ // The cytobands setting has been `showCytobandsSetting` and (briefly)
2706
+ // `cytobandsVisible`; both now persist as the bare `showCytobands` prop
2707
+ // (the capability-gated getter is `effectiveShowCytobands`).
2708
+ const { highlight, showCytobandsSetting, cytobandsVisible, offsetPx, bpPerPx, ...rest } = snap;
2709
+ const legacyShowCytobands = showCytobandsSetting ?? cytobandsVisible;
898
2710
  return {
899
2711
  highlight: Array.isArray(highlight) || highlight === undefined
900
2712
  ? highlight
901
2713
  : [highlight],
2714
+ ...(legacyShowCytobands !== undefined
2715
+ ? { showCytobands: legacyShowCytobands }
2716
+ : {}),
2717
+ // The viewport used to persist as pixels. Half of that converts
2718
+ // exactly and needs no width — the left edge in bp is offsetPx *
2719
+ // bpPerPx — and half of it cannot convert at all, because the width
2720
+ // those pixels were measured at was never written down. So bpPerPx
2721
+ // rides to the first measure as `legacyBpPerPx` and is adopted at
2722
+ // whatever width arrives, which is precisely what the old code did.
2723
+ // An old link therefore keeps its old behavior instead of being
2724
+ // reinterpreted, and anything authored since restores its window.
2725
+ //
2726
+ // Also the path for the several places that BUILD a view from a
2727
+ // snapshot naming bpPerPx/offsetPx (a synteny row, a split view) —
2728
+ // they get the same treatment as a saved session, so none of them had
2729
+ // to change.
2730
+ ...(typeof bpPerPx === 'number' &&
2731
+ bpPerPx > 0 &&
2732
+ rest.windowWidthBp === undefined
2733
+ ? {
2734
+ legacyBpPerPx: bpPerPx,
2735
+ windowStartBp: (typeof offsetPx === 'number' ? offsetPx : 0) * bpPerPx,
2736
+ }
2737
+ : {}),
902
2738
  ...rest,
903
2739
  };
904
2740
  })
905
2741
  .postProcessSnapshot(snap => {
906
- if (!snap) {
907
- return snap;
908
- }
909
- const { init, offsetPx, bpPerPx, hideHeader, hideHeaderOverview, hideNoTracksActive, showGridlines, trackSelectorType, displayedRegions, highlight, showCenterLine, showCytobandsSetting, trackLabels, colorByCDS, showTrackOutlines, highlightsVisible, labelsVisible, ...rest } = snap;
2742
+ // init is transient launch state, never persisted. showCenterLine is
2743
+ // purely a localStorage-backed preference (see setupLocalStorageAutorun
2744
+ // in afterAttach.ts) and is never persisted into session snapshots. The
2745
+ // remaining fields are also localStorage-backed, but still persist when
2746
+ // non-default: their strip baseline is the universal default (hardcoded
2747
+ // here), not the localStorage-derived creation default, so a
2748
+ // localStorage-set value stays portable across browsers.
2749
+ const { init, showCenterLine, showCytobands, trackLabels, colorByCDS, showAminoAcids, showTrackOutlines, ...rest } = snap;
910
2750
  return {
911
2751
  ...rest,
912
- ...(offsetPx ? { offsetPx } : {}),
913
- ...(bpPerPx !== 1 ? { bpPerPx } : {}),
914
- ...(hideHeader ? { hideHeader } : {}),
915
- ...(hideHeaderOverview ? { hideHeaderOverview } : {}),
916
- ...(hideNoTracksActive ? { hideNoTracksActive } : {}),
917
- ...(!showGridlines ? { showGridlines } : {}),
918
- ...(trackSelectorType !== 'hierarchical' ? { trackSelectorType } : {}),
919
- ...(displayedRegions.length ? { displayedRegions } : {}),
920
- ...(highlight.length ? { highlight } : {}),
921
- ...(showCenterLine ? { showCenterLine } : {}),
922
- ...(!showCytobandsSetting ? { showCytobandsSetting } : {}),
2752
+ // keep init until displayedRegions exist, so a snapshot taken before the
2753
+ // launch autorun navigates (e.g. autosave firing mid-load) can still
2754
+ // rebuild the view instead of dropping to the import form.
2755
+ // displayedRegions is stripDefault, so it's absent (not []) when empty —
2756
+ // the optional chain is runtime-necessary despite the non-nullish type.
2757
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
2758
+ ...(init && !snap.displayedRegions?.length ? { init } : {}),
2759
+ ...(!showCytobands ? { showCytobands } : {}),
923
2760
  ...(trackLabels ? { trackLabels } : {}),
924
2761
  ...(colorByCDS ? { colorByCDS } : {}),
2762
+ ...(!showAminoAcids ? { showAminoAcids } : {}),
925
2763
  ...(!showTrackOutlines ? { showTrackOutlines } : {}),
926
- ...(!highlightsVisible ? { highlightsVisible } : {}),
927
- ...(!labelsVisible ? { labelsVisible } : {}),
928
2764
  };
929
2765
  });
930
2766
  }
931
- export { default as LinearGenomeView, default as ReactComponent, } from "./components/LinearGenomeView.js";
932
- export { default as RefNameAutocomplete } from "./components/RefNameAutocomplete/index.js";
933
- export { default as SearchBox } from "./components/SearchBox.js";
934
- export { renderToSvg } from "./svgcomponents/SVGLinearGenomeView.js";
2767
+ // #endregion