react-msaview 6.4.2 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +6 -5
  2. package/bundle/index.js +100 -101
  3. package/bundle/index.js.map +4 -4
  4. package/dist/components/Loading.js +2 -2
  5. package/dist/components/Loading.js.map +1 -1
  6. package/dist/components/MSAViewer.d.ts +10 -2
  7. package/dist/components/MSAViewer.js +90 -2
  8. package/dist/components/MSAViewer.js.map +1 -1
  9. package/dist/components/ResizeHandles.d.ts +3 -3
  10. package/dist/components/ResizeHandles.js +28 -21
  11. package/dist/components/ResizeHandles.js.map +1 -1
  12. package/dist/components/dialogs/{InterProScanDialog.d.ts → AnnotationFileDialog.d.ts} +2 -2
  13. package/dist/components/dialogs/AnnotationFileDialog.js +92 -0
  14. package/dist/components/dialogs/AnnotationFileDialog.js.map +1 -0
  15. package/dist/components/dialogs/FeatureDialog.js +13 -9
  16. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  17. package/dist/components/header/Header.js +4 -0
  18. package/dist/components/header/Header.js.map +1 -1
  19. package/dist/components/header/HeaderStatusArea.js +0 -2
  20. package/dist/components/header/HeaderStatusArea.js.map +1 -1
  21. package/dist/components/header/LoadWarnings.d.ts +6 -0
  22. package/dist/components/header/LoadWarnings.js +36 -0
  23. package/dist/components/header/LoadWarnings.js.map +1 -0
  24. package/dist/components/header/MultiAlignmentSelector.js +0 -0
  25. package/dist/components/header/MultiAlignmentSelector.js.map +1 -1
  26. package/dist/components/header/TreeSettingsMenu.js +9 -1
  27. package/dist/components/header/TreeSettingsMenu.js.map +1 -1
  28. package/dist/components/header/UnshareableDataWarning.d.ts +6 -0
  29. package/dist/components/header/UnshareableDataWarning.js +42 -0
  30. package/dist/components/header/UnshareableDataWarning.js.map +1 -0
  31. package/dist/components/header/getAnnotationsMenu.js +14 -14
  32. package/dist/components/header/getAnnotationsMenu.js.map +1 -1
  33. package/dist/components/import/ImportForm.js +1 -1
  34. package/dist/components/import/ImportForm.js.map +1 -1
  35. package/dist/components/import/ImportFormExamples.js +2 -6
  36. package/dist/components/import/ImportFormExamples.js.map +1 -1
  37. package/dist/components/import/util.js +3 -0
  38. package/dist/components/import/util.js.map +1 -1
  39. package/dist/components/msa/MSACanvasBlock.js +4 -5
  40. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  41. package/dist/components/msa/msaRaster.d.ts +13 -3
  42. package/dist/components/msa/msaRaster.js +173 -61
  43. package/dist/components/msa/msaRaster.js.map +1 -1
  44. package/dist/components/msa/renderHighlights.d.ts +39 -1
  45. package/dist/components/msa/renderHighlights.js +71 -6
  46. package/dist/components/msa/renderHighlights.js.map +1 -1
  47. package/dist/components/msa/renderMSABlock.d.ts +1 -2
  48. package/dist/components/msa/renderMSABlock.js +24 -28
  49. package/dist/components/msa/renderMSABlock.js.map +1 -1
  50. package/dist/components/msa/renderMSAMouseover.js +9 -30
  51. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  52. package/dist/components/renderCtx.d.ts +1 -1
  53. package/dist/components/tracks/TrackBlocks.js +10 -11
  54. package/dist/components/tracks/TrackBlocks.js.map +1 -1
  55. package/dist/components/tracks/drawTracks.d.ts +26 -8
  56. package/dist/components/tracks/drawTracks.js +123 -13
  57. package/dist/components/tracks/drawTracks.js.map +1 -1
  58. package/dist/components/tree/TreeCanvas.js +2 -3
  59. package/dist/components/tree/TreeCanvas.js.map +1 -1
  60. package/dist/components/tree/TreeCanvasBlock.js +4 -2
  61. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  62. package/dist/components/tree/TreeRuler.js +13 -3
  63. package/dist/components/tree/TreeRuler.js.map +1 -1
  64. package/dist/components/tree/renderTreeCanvas.d.ts +0 -1
  65. package/dist/components/tree/renderTreeCanvas.js +70 -30
  66. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  67. package/dist/components/tree/scaleBar.d.ts +10 -0
  68. package/dist/components/tree/scaleBar.js +24 -0
  69. package/dist/components/tree/scaleBar.js.map +1 -0
  70. package/dist/constants.d.ts +3 -1
  71. package/dist/constants.js +18 -1
  72. package/dist/constants.js.map +1 -1
  73. package/dist/fetchUtils.d.ts +7 -7
  74. package/dist/fetchUtils.js +4 -32
  75. package/dist/fetchUtils.js.map +1 -1
  76. package/dist/hierarchy.d.ts +2 -1
  77. package/dist/hierarchy.js +16 -10
  78. package/dist/hierarchy.js.map +1 -1
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.js +8 -2
  81. package/dist/index.js.map +1 -1
  82. package/dist/model/DataModel.js +5 -2
  83. package/dist/model/DataModel.js.map +1 -1
  84. package/dist/model.d.ts +454 -177
  85. package/dist/model.js +1180 -511
  86. package/dist/model.js.map +1 -1
  87. package/dist/renderToStaticMarkup.d.ts +11 -0
  88. package/dist/renderToStaticMarkup.js +30 -7
  89. package/dist/renderToStaticMarkup.js.map +1 -1
  90. package/dist/renderToSvg.js +25 -19
  91. package/dist/renderToSvg.js.map +1 -1
  92. package/dist/rowCoordinateCalculations.d.ts +25 -25
  93. package/dist/rowCoordinateCalculations.js +12 -36
  94. package/dist/rowCoordinateCalculations.js.map +1 -1
  95. package/dist/seqPosToGlobalCol.js +3 -1
  96. package/dist/seqPosToGlobalCol.js.map +1 -1
  97. package/dist/svgTestUtil.d.ts +144 -82
  98. package/dist/types.d.ts +97 -3
  99. package/dist/util.d.ts +11 -4
  100. package/dist/util.js +30 -10
  101. package/dist/util.js.map +1 -1
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/dist/wuss.d.ts +14 -0
  105. package/dist/wuss.js +55 -0
  106. package/dist/wuss.js.map +1 -0
  107. package/package.json +10 -8
  108. package/src/components/Loading.tsx +12 -2
  109. package/src/components/MSAViewer.tsx +106 -2
  110. package/src/components/ResizeHandles.tsx +31 -31
  111. package/src/components/dialogs/AnnotationFileDialog.tsx +162 -0
  112. package/src/components/dialogs/FeatureDialog.tsx +13 -9
  113. package/src/components/header/Header.tsx +4 -0
  114. package/src/components/header/HeaderStatusArea.tsx +0 -5
  115. package/src/components/header/LoadWarnings.tsx +49 -0
  116. package/src/components/header/MultiAlignmentSelector.tsx +0 -0
  117. package/src/components/header/TreeSettingsMenu.tsx +9 -1
  118. package/src/components/header/UnshareableDataWarning.tsx +58 -0
  119. package/src/components/header/getAnnotationsMenu.ts +16 -17
  120. package/src/components/import/ImportForm.tsx +2 -2
  121. package/src/components/import/ImportFormExamples.tsx +2 -6
  122. package/src/components/import/util.ts +3 -0
  123. package/src/components/msa/MSACanvasBlock.tsx +4 -5
  124. package/src/components/msa/msaRaster.ts +216 -59
  125. package/src/components/msa/renderHighlights.ts +103 -5
  126. package/src/components/msa/renderMSABlock.ts +25 -36
  127. package/src/components/msa/renderMSAMouseover.ts +9 -39
  128. package/src/components/renderCtx.ts +1 -0
  129. package/src/components/tracks/TrackBlocks.tsx +10 -13
  130. package/src/components/tracks/drawTracks.ts +165 -17
  131. package/src/components/tree/TreeCanvas.tsx +2 -3
  132. package/src/components/tree/TreeCanvasBlock.tsx +4 -2
  133. package/src/components/tree/TreeRuler.tsx +38 -3
  134. package/src/components/tree/renderTreeCanvas.ts +79 -35
  135. package/src/components/tree/scaleBar.ts +23 -0
  136. package/src/constants.ts +20 -1
  137. package/src/fetchUtils.ts +7 -40
  138. package/src/hierarchy.ts +20 -11
  139. package/src/index.ts +18 -2
  140. package/src/model/DataModel.ts +6 -2
  141. package/src/model.ts +1352 -616
  142. package/src/rawImports.d.ts +9 -0
  143. package/src/renderToStaticMarkup.ts +34 -7
  144. package/src/renderToSvg.tsx +33 -27
  145. package/src/rowCoordinateCalculations.ts +15 -59
  146. package/src/seqPosToGlobalCol.ts +3 -1
  147. package/src/types.ts +131 -4
  148. package/src/util.ts +31 -14
  149. package/src/version.ts +1 -1
  150. package/src/wuss.ts +62 -0
  151. package/dist/components/dialogs/InterProScanDialog.js +0 -137
  152. package/dist/components/dialogs/InterProScanDialog.js.map +0 -1
  153. package/dist/components/dialogs/InterProScanFileDialog.d.ts +0 -7
  154. package/dist/components/dialogs/InterProScanFileDialog.js +0 -64
  155. package/dist/components/dialogs/InterProScanFileDialog.js.map +0 -1
  156. package/dist/launchInterProScan.d.ts +0 -13
  157. package/dist/launchInterProScan.js +0 -110
  158. package/dist/launchInterProScan.js.map +0 -1
  159. package/dist/useColorContrast.d.ts +0 -10
  160. package/dist/useColorContrast.js +0 -13
  161. package/dist/useColorContrast.js.map +0 -1
  162. package/src/clustalX.test.ts +0 -74
  163. package/src/colSpace.test.ts +0 -67
  164. package/src/collapseLogic.test.ts +0 -115
  165. package/src/colorSchemes.docs.test.ts +0 -57
  166. package/src/columnCounts.test.ts +0 -73
  167. package/src/columnStats.test.ts +0 -94
  168. package/src/columnTracks.test.ts +0 -97
  169. package/src/components/dialogs/InterProScanDialog.tsx +0 -214
  170. package/src/components/dialogs/InterProScanFileDialog.tsx +0 -138
  171. package/src/components/minimap/minimapLayout.test.ts +0 -71
  172. package/src/components/msa/msaRaster.test.ts +0 -145
  173. package/src/components/msa/renderBoxFeatureCanvasBlock.test.ts +0 -51
  174. package/src/components/tree/renderTreeCanvas.test.ts +0 -296
  175. package/src/dataInitialized.test.ts +0 -42
  176. package/src/domainBands.test.ts +0 -122
  177. package/src/domainLetterColor.test.ts +0 -80
  178. package/src/exportFileName.test.ts +0 -40
  179. package/src/fetchUtils.test.ts +0 -83
  180. package/src/flatToTree.test.ts +0 -51
  181. package/src/hierarchy.test.ts +0 -228
  182. package/src/highlights.test.ts +0 -70
  183. package/src/impgRender.test.tsx +0 -68
  184. package/src/largeTree.test.ts +0 -67
  185. package/src/launchInterProScan.ts +0 -167
  186. package/src/loadErrorDisplay.test.tsx +0 -102
  187. package/src/modelFilehandleLoaders.test.ts +0 -137
  188. package/src/modelReset.test.ts +0 -120
  189. package/src/neighborJoining.test.ts +0 -164
  190. package/src/parseAsn1.test.ts +0 -36
  191. package/src/propertyConservation.test.ts +0 -36
  192. package/src/renderColumnTracksSvg.test.tsx +0 -108
  193. package/src/renderDomainsSvg.test.tsx +0 -118
  194. package/src/renderHighlightsSvg.test.tsx +0 -85
  195. package/src/renderMinimapSvg.test.tsx +0 -62
  196. package/src/renderRasterSvg.test.tsx +0 -144
  197. package/src/renderSequenceLogoSvg.test.tsx +0 -165
  198. package/src/renderToSvg.test.tsx +0 -65
  199. package/src/reset.test.ts +0 -61
  200. package/src/rowCoordinateCalculations.test.ts +0 -211
  201. package/src/rowSpace.test.ts +0 -46
  202. package/src/segmentOverlay.test.ts +0 -70
  203. package/src/seqPosToGlobalCol.test.ts +0 -59
  204. package/src/sequenceLogo.test.ts +0 -88
  205. package/src/setFontSize.test.ts +0 -33
  206. package/src/stripDefault.test.ts +0 -48
  207. package/src/stripDefaultSnapshot.test.ts +0 -87
  208. package/src/treeMetadata.test.ts +0 -39
  209. package/src/useCanvasAutorun.test.tsx +0 -98
  210. package/src/useColorContrast.ts +0 -18
  211. package/src/viewChrome.test.tsx +0 -134
  212. package/src/wheelZoom.test.ts +0 -46
package/src/model.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  applySnapshot,
7
7
  cast,
8
8
  getSnapshot,
9
+ isAlive,
9
10
  types,
10
11
  } from '@jbrowse/mobx-state-tree'
11
12
  import { colord } from 'colord'
@@ -40,8 +41,11 @@ import {
40
41
  defaultShowDomainLegend,
41
42
  defaultShowDomains,
42
43
  defaultSubFeatureRows,
44
+ defaultTreeWidth,
43
45
  labelReferenceFontSize,
44
46
  maxCellSize,
47
+ maxInlineSnapshotBytes,
48
+ maxNeighborJoiningRows,
45
49
  minColWidth,
46
50
  minLetterColWidth,
47
51
  minLetterRowHeight,
@@ -59,11 +63,9 @@ import {
59
63
  collapse,
60
64
  collapsedSubtreeMaxLength,
61
65
  find,
62
- findMaxBranchLen,
63
66
  forEachDescendant,
64
67
  hierarchy,
65
68
  leaves,
66
- links,
67
69
  maxLength,
68
70
  setBrLength,
69
71
  sort,
@@ -79,8 +81,8 @@ import { parseAsn1 } from './parseAsn1.ts'
79
81
  import { calculatePropertyConservation } from './propertyConservation.ts'
80
82
  import {
81
83
  globalColToVisibleCol,
84
+ seqPosOfGlobalCol,
82
85
  visibleColToGlobalCol,
83
- visibleColToSeqPosForRow,
84
86
  visibleColsBefore,
85
87
  } from './rowCoordinateCalculations.ts'
86
88
  import { buildSeqPosIndex } from './seqPosToGlobalCol.ts'
@@ -94,18 +96,26 @@ import {
94
96
  transform,
95
97
  } from './util.ts'
96
98
  import { saveAs } from './vendor/fileSaver.ts'
99
+ import { parseWuss } from './wuss.ts'
97
100
 
98
101
  import type { HierarchyNode } from './hierarchy.ts'
99
102
  import type { ExportSvgOptions } from './renderToSvg.tsx'
100
103
  import type {
101
104
  Annotation,
105
+ Arc,
102
106
  BasicTrack,
107
+ ResidueMappingProblem,
103
108
  ColumnTrackSpec,
104
109
  DomainBand,
105
110
  Highlight,
106
111
  NodeWithIds,
107
112
  NodeWithIdsAndLength,
113
+ ResidueMapping,
114
+ ResidueSegment,
108
115
  ResolvedHighlight,
116
+ RowResidue,
117
+ StructureResidue,
118
+ UnshareableData,
109
119
  } from './types.ts'
110
120
  import type { FileLocation as FileLocationType } from '@jbrowse/core/util/types'
111
121
  import type { Instance } from '@jbrowse/mobx-state-tree'
@@ -121,16 +131,22 @@ function parseTreeText(text: string) {
121
131
  // Tracks that start hidden. The sequence logo answers a narrower question than
122
132
  // conservation does and costs three times the vertical space, so it waits to be
123
133
  // asked for.
124
- const defaultOffTracks = new Set(['sequence-logo'])
134
+ const defaultOffTracks = new Set(['sequence-logo', 'position-ruler'])
135
+
136
+ // base-pair arcs: one color for the nested helices, one for a pseudoknot, whose
137
+ // whole point is that it crosses them
138
+ const HELIX_ARC = '#4e79a7'
139
+ const PSEUDOKNOT_ARC = '#e15759'
125
140
 
126
141
  // a data track over this size stays in the live model but leaves the snapshot,
127
142
  // the same rule DataModel applies to an inline document
128
- const maxColumnTrackSnapshotBytes = 50_000
143
+ function columnTrackSizes(tracks?: readonly ColumnTrackSpec[]) {
144
+ return (tracks ?? []).map(t => JSON.stringify(t).length)
145
+ }
129
146
 
130
147
  function smallColumnTracks(tracks?: ColumnTrackSpec[]) {
131
- const kept = tracks?.filter(
132
- t => JSON.stringify(t).length <= maxColumnTrackSnapshotBytes,
133
- )
148
+ const sizes = columnTrackSizes(tracks)
149
+ const kept = tracks?.filter((_t, i) => sizes[i]! <= maxInlineSnapshotBytes)
134
150
  return kept?.length ? { columnTracks: kept } : {}
135
151
  }
136
152
 
@@ -178,11 +194,49 @@ export const preservedOnReset = new Set([
178
194
  // until they touch that track, and then its value is whether the track is OFF.
179
195
  // Reading the default through this is what lets a track ship hidden without
180
196
  // writing an entry into every snapshot and shared URL.
197
+ // A track the file itself supplies says whether it starts hidden, since only
198
+ // the file knows how many of them there are: a Pfam seed carries a couple of
199
+ // #=GR lines, an Rfam family one per row.
181
200
  function trackIsOff(
182
201
  turnedOffTracks: { get: (id: string) => boolean | undefined },
183
202
  id: string,
203
+ defaultOff?: boolean,
184
204
  ) {
185
- return turnedOffTracks.get(id) ?? defaultOffTracks.has(id)
205
+ return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id))
206
+ }
207
+
208
+ // one array for every "nothing under the pointer", since a fresh [] is a fresh
209
+ // value to every observer of it
210
+ const noDomains: Annotation[] = []
211
+
212
+ // seqPos -> column indexes, per row, hung off the parse the rows came from so
213
+ // they are collected with it. A computed would rebuild every row's index on
214
+ // each miss, and rebuild all of them again whenever it was read outside a
215
+ // reactive context.
216
+ const seqPosIndexCache = new WeakMap<object, Map<string, Int32Array>>()
217
+
218
+ // A segment asserts a 1:1 run, so its two sides have to be the same length.
219
+ // One that is not is malformed data, and the arithmetic below would answer
220
+ // anyway -- with a residue that is off by however much the sides disagree. Skip
221
+ // it, the same refusal an uncovered position gets.
222
+ function sameLength(segment: ResidueSegment) {
223
+ return (
224
+ segment.rowEnd - segment.rowStart ===
225
+ segment.structEnd - segment.structStart
226
+ )
227
+ }
228
+
229
+ // Does the content need a scrollbar? fit() divides the viewport by the row or
230
+ // column count and multiplies it back, so an exact fit lands a fraction of a
231
+ // pixel over -- enough for a `>` to answer yes and hand the reader a minimap or
232
+ // a scrollbar for half a pixel of nothing, which then shrinks the viewport and
233
+ // leaves a gap.
234
+ function overflows(content: number, viewport: number) {
235
+ return content - viewport > 0.5
236
+ }
237
+
238
+ function inRanges(ranges: [number, number][] | undefined, position: number) {
239
+ return !!ranges?.some(([start, end]) => position >= start && position <= end)
186
240
  }
187
241
 
188
242
  /**
@@ -352,6 +406,19 @@ function stateModelFactory() {
352
406
  [],
353
407
  ),
354
408
 
409
+ /**
410
+ * #property
411
+ * which residue of which structure each row's residues are, as data.
412
+ * The viewer cannot infer this -- matching a row to a structure by
413
+ * sequence equality fails for a tagged construct, a truncation or a
414
+ * subsequence row, and fails in the direction that looks like it
415
+ * worked -- so it arrives computed. See docs/layers.md
416
+ */
417
+ residueMappings: stripDefault(
418
+ types.array(types.frozen<ResidueMapping>()),
419
+ [],
420
+ ),
421
+
355
422
  /**
356
423
  * #property
357
424
  * data from the loaded tree/msa/treeMetadata, generally loaded by
@@ -365,8 +432,13 @@ function stateModelFactory() {
365
432
 
366
433
  /**
367
434
  * #property
435
+ * the user's explicit hide choices per annotation accession, keyed by
436
+ * accession with the value meaning "off", the same shape as
437
+ * `turnedOffTracks`. An accession the user has never touched is absent
438
+ * and drawn, so a file of two hundred domain types adds nothing to the
439
+ * shared URL until someone filters one out
368
440
  */
369
- featureFilters: stripDefault(types.map(types.boolean), {}),
441
+ turnedOffFeatures: stripDefault(types.map(types.boolean), {}),
370
442
  /**
371
443
  * #property
372
444
  */
@@ -398,9 +470,7 @@ function stateModelFactory() {
398
470
  /**
399
471
  * #volatile
400
472
  */
401
- status: undefined as
402
- | { msg: string; url?: string; onCancel?: () => void }
403
- | undefined,
473
+ status: undefined as { msg: string; onCancel?: () => void } | undefined,
404
474
  /**
405
475
  * #volatile
406
476
  * high resolution scale factor, helps make canvas look better on hi-dpi
@@ -472,6 +542,16 @@ function stateModelFactory() {
472
542
  */
473
543
  highlightedColumns: undefined as number[] | undefined,
474
544
 
545
+ /**
546
+ * #volatile
547
+ * transient highlights keyed by who asked for them. One slot cannot hold
548
+ * two sources -- a structure viewer's hover and a genome view's hover
549
+ * both want to point at a column, and with one slot whoever clears last
550
+ * erases the other's. Keyed by owner, each source adds and removes only
551
+ * its own. Not persisted: a hover is not part of the document.
552
+ */
553
+ transientHighlights: {} as Record<string, Highlight[]>,
554
+
475
555
  /**
476
556
  * #volatile
477
557
  */
@@ -482,6 +562,15 @@ function stateModelFactory() {
482
562
  */
483
563
  conservationTrackHeight: 40,
484
564
 
565
+ /**
566
+ * #volatile
567
+ * heights of individual `columnTracks`, by track id. A data track is
568
+ * resized on its own: the shared per-kind heights below belong to the
569
+ * tracks the viewer computes, and dragging a data track's handle used to
570
+ * resize those instead.
571
+ */
572
+ columnTrackHeights: {} as Record<string, number>,
573
+
485
574
  /**
486
575
  * #volatile
487
576
  * taller than the conservation track by default: the logo spends its
@@ -490,6 +579,11 @@ function stateModelFactory() {
490
579
  */
491
580
  sequenceLogoTrackHeight: 80,
492
581
 
582
+ /**
583
+ * #volatile
584
+ */
585
+ arcTrackHeight: 50,
586
+
493
587
  /**
494
588
  * #volatile
495
589
  */
@@ -501,6 +595,32 @@ function stateModelFactory() {
501
595
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
502
596
  error: undefined as unknown,
503
597
 
598
+ /**
599
+ * #volatile
600
+ * load problems the view carried on through: an optional layer that did
601
+ * not arrive, an overlay that did not parse. `error` is the other kind --
602
+ * it replaces the view, which is right for the alignment and wrong for a
603
+ * decorative file
604
+ */
605
+ warnings: [] as string[],
606
+
607
+ /**
608
+ * #volatile
609
+ * bumped by reset(). The React error boundary above the view keeps its
610
+ * caught error until it is remounted, so "Return to import form" did
611
+ * nothing after a render error until this became its key
612
+ */
613
+ resetCount: 0,
614
+
615
+ /**
616
+ * #volatile
617
+ * set by a host that restores the loaded documents by its own means --
618
+ * a jbrowse session that holds them, a page that refetches them on load.
619
+ * `unshareableData` then reports nothing, since what it warns about is a
620
+ * link that opens empty, and under such a host the link does not
621
+ */
622
+ hostCarriesData: false,
623
+
504
624
  /**
505
625
  * #volatile
506
626
  * overlay annotations drawn on the alignment, whatever their source.
@@ -574,13 +694,38 @@ function stateModelFactory() {
574
694
  self.error = error
575
695
  },
576
696
 
697
+ /**
698
+ * #action
699
+ * report something the view survived: a layer that failed to load, a
700
+ * file that failed to parse
701
+ */
702
+ addWarning(warning: string) {
703
+ self.warnings = [...self.warnings, warning]
704
+ },
705
+
706
+ /**
707
+ * #action
708
+ */
709
+ clearWarnings() {
710
+ self.warnings = []
711
+ },
712
+
713
+ /**
714
+ * #action
715
+ * declare that this host restores the loaded documents itself, which
716
+ * takes down the "Not in the link" warning. See `hostCarriesData`
717
+ */
718
+ setHostCarriesData(arg: boolean) {
719
+ self.hostCarriesData = arg
720
+ },
721
+
577
722
  /**
578
723
  * #action
579
724
  * set mouse position (row, column) in the MSA
580
725
  *
581
- * CROSS-REPO CONTRACT: jbrowse-plugin-protein3d calls this (and reads the
582
- * `mouseCol` volatile) to sync MSA<->3D-structure hover. Keep the name and
583
- * signature stable; see that repo's ProteinToMsaHoverSync.tsx.
726
+ * PUBLIC API: a host drives this (and reads the `mouseCol` volatile) to
727
+ * sync the alignment's hover with a view of its own -- a genome view, a
728
+ * 3D structure. Keep the name and signature stable.
584
729
  */
585
730
  setMousePos(col?: number, row?: number) {
586
731
  self.mouseCol = col
@@ -591,9 +736,9 @@ function stateModelFactory() {
591
736
  * #action
592
737
  * set highlighted columns
593
738
  *
594
- * CROSS-REPO CONTRACT: called by jbrowse-plugin-msaview
595
- * (afterCreateAutoruns.ts) to highlight alignment columns. It has no
596
- * in-repo caller, so do not flag it as dead code — it is public API.
739
+ * PUBLIC API: jbrowse-plugin-msaview calls this from its
740
+ * afterCreateAutoruns to highlight alignment columns, and MSAViewer
741
+ * passes its `highlightColumns` prop through it. Not dead code.
597
742
  */
598
743
  setHighlightedColumns(columns?: number[]) {
599
744
  self.highlightedColumns = columns
@@ -604,6 +749,29 @@ function stateModelFactory() {
604
749
  setHighlights(highlights: Highlight[]) {
605
750
  self.highlights.replace(highlights)
606
751
  },
752
+ /**
753
+ * #action
754
+ * show `highlights` on behalf of `owner`, replacing whatever that owner
755
+ * showed before and leaving every other owner's alone. The object is
756
+ * replaced rather than mutated so one assignment is the observable
757
+ * change.
758
+ */
759
+ applyHighlight(owner: string, highlights: Highlight[]) {
760
+ self.transientHighlights = {
761
+ ...self.transientHighlights,
762
+ [owner]: highlights,
763
+ }
764
+ },
765
+ /**
766
+ * #action
767
+ * drop what `owner` was showing, leaving every other owner's in place
768
+ */
769
+ clearHighlight(owner: string) {
770
+ if (owner in self.transientHighlights) {
771
+ const { [owner]: _dropped, ...rest } = self.transientHighlights
772
+ self.transientHighlights = rest
773
+ }
774
+ },
607
775
  /**
608
776
  * #action
609
777
  * toggle the annotation overlay on the alignment
@@ -651,10 +819,23 @@ function stateModelFactory() {
651
819
 
652
820
  /**
653
821
  * #action
654
- *
822
+ * switch to another alignment of a multi-alignment file (Stockholm). The
823
+ * new alignment has its own rows and its own tree, so everything naming
824
+ * the old one's -- the collapsed node ids, the subtree in focus, the
825
+ * reference row, the scroll position -- goes with it
655
826
  */
656
827
  setCurrentAlignment(n: number) {
657
- self.currentAlignment = n
828
+ if (n === self.currentAlignment) {
829
+ return
830
+ }
831
+ transaction(() => {
832
+ self.currentAlignment = n
833
+ self.collapsed.clear()
834
+ this.setShowOnly(undefined)
835
+ this.drawRelativeTo(undefined)
836
+ self.scrollX = 0
837
+ self.scrollY = 0
838
+ })
658
839
  },
659
840
 
660
841
  /**
@@ -740,6 +921,18 @@ function stateModelFactory() {
740
921
  setTreeMetadata(result: string) {
741
922
  self.data.setTreeMetadata(result)
742
923
  },
924
+
925
+ /**
926
+ * #action
927
+ * keep the GFF text the way the alignment and the tree are kept, rather
928
+ * than only its parsed annotations. The annotations are volatile, so a
929
+ * file opened from disk used to leave no trace in the snapshot at all --
930
+ * not the text, and not the filehandle, which is cleared once a blob is
931
+ * read. An autorun parses this back into annotations.
932
+ */
933
+ setGFF(result: string) {
934
+ self.data.setGFF(result)
935
+ },
743
936
  }))
744
937
 
745
938
  .views(self => ({
@@ -765,6 +958,22 @@ function stateModelFactory() {
765
958
  get actuallyShowDomains() {
766
959
  return self.showDomains && self.annotations.length > 0
767
960
  },
961
+ /**
962
+ * #getter
963
+ * whether this host brings the loaded documents back by means the
964
+ * snapshot cannot see, which is what decides whether `unshareableData`
965
+ * has anything to warn about.
966
+ *
967
+ * A simple host flips the `hostCarriesData` volatile. A host whose
968
+ * answer depends on how the view was opened overrides this getter in a
969
+ * `.views` block of its own composed model -- jbrowse-plugin-msaview's
970
+ * indexed-location views refetch from a URL the session holds, while its
971
+ * data-store views really are absent from a link someone pastes
972
+ * elsewhere. `unshareableData` reads it off `self`, so an override wins.
973
+ */
974
+ get hostRestoresData() {
975
+ return self.hostCarriesData
976
+ },
768
977
  get viewInitialized() {
769
978
  return self.volatileWidth !== undefined
770
979
  },
@@ -779,13 +988,6 @@ function stateModelFactory() {
779
988
  },
780
989
  }))
781
990
  .views(self => ({
782
- /**
783
- * #method
784
- * unused here, but can be used by derived classes to add extra items
785
- */
786
- extraViewMenuItems() {
787
- return []
788
- },
789
991
  /**
790
992
  * #getter
791
993
  */
@@ -820,6 +1022,53 @@ function stateModelFactory() {
820
1022
  get noDomains() {
821
1023
  return self.annotations.length === 0
822
1024
  },
1025
+
1026
+ /**
1027
+ * #getter
1028
+ * the loaded documents this view's own snapshot cannot carry, largest
1029
+ * first. A file opened from disk or pasted in becomes inline text, and
1030
+ * DataModel drops an inline document past `maxInlineSnapshotBytes`
1031
+ * rather than put megabytes of sequence into a session or a URL.
1032
+ *
1033
+ * Dropping it is right. Dropping it silently is what makes a copied link
1034
+ * open an empty viewer, so the header says so and the standalone app
1035
+ * stops rewriting the address bar while this is non-empty. A document
1036
+ * fetched from a URL never appears here whatever its size: the snapshot
1037
+ * keeps the filehandle and refetches through it.
1038
+ *
1039
+ * Nothing is unshareable when the host restores the data by its own
1040
+ * means (see `hostRestoresData`) -- inside a session that reloads these
1041
+ * documents from somewhere the snapshot does not show, the warning is
1042
+ * simply wrong.
1043
+ */
1044
+ get unshareableData(): UnshareableData[] {
1045
+ if (self.hostRestoresData) {
1046
+ return []
1047
+ }
1048
+ const { data } = self
1049
+ // a data track past the limit leaves the snapshot the same way an
1050
+ // inline document does, and left unreported the same way too
1051
+ const trackBytes = columnTrackSizes(self.columnTracks)
1052
+ .filter(bytes => bytes > maxInlineSnapshotBytes)
1053
+ .reduce((a, b) => a + b, 0)
1054
+ return (
1055
+ [
1056
+ ['alignment', data.msa, self.msaFilehandle],
1057
+ ['tree', data.tree, self.treeFilehandle],
1058
+ ['annotations', data.gff, self.gffFilehandle],
1059
+ ['row metadata', data.treeMetadata, self.treeMetadataFilehandle],
1060
+ ] as const
1061
+ )
1062
+ .flatMap<UnshareableData>(([what, text, filehandle]) =>
1063
+ !filehandle && text && text.length > maxInlineSnapshotBytes
1064
+ ? [{ what, bytes: text.length }]
1065
+ : [],
1066
+ )
1067
+ .concat(
1068
+ trackBytes ? [{ what: 'data tracks', bytes: trackBytes }] : [],
1069
+ )
1070
+ .sort((a, b) => b.bytes - a.bytes)
1071
+ },
823
1072
  menuItems() {
824
1073
  return []
825
1074
  },
@@ -1047,24 +1296,51 @@ function stateModelFactory() {
1047
1296
  },
1048
1297
 
1049
1298
  /**
1050
- * #getter
1051
- * per-row index of the global column holding each ungapped sequence
1052
- * position, so seqPos -> column is a lookup rather than a scan of the row.
1053
- * The domain overlay resolves thousands of these per redraw.
1299
+ * #method
1300
+ * index of the global column holding each ungapped sequence position of a
1301
+ * row, so seqPos -> column is a lookup rather than a scan. The domain
1302
+ * overlay resolves thousands of these per redraw.
1303
+ *
1304
+ * Built per row, on the row asked for: the first lookup used to index
1305
+ * every row in the alignment. The cache is keyed on the parse the rows
1306
+ * came from, so a new alignment brings a new one.
1054
1307
  */
1055
- get seqPosGlobalColIndex() {
1056
- return new Map(
1057
- this.rows.map(
1058
- ([name, seq]) => [name, buildSeqPosIndex(seq)] as const,
1059
- ),
1060
- )
1308
+ seqPosIndex(rowName: string): Int32Array | undefined {
1309
+ const MSA = this.MSA
1310
+ if (!MSA) {
1311
+ return undefined
1312
+ }
1313
+ let cache = seqPosIndexCache.get(MSA)
1314
+ if (!cache) {
1315
+ cache = new Map()
1316
+ seqPosIndexCache.set(MSA, cache)
1317
+ }
1318
+ let index = cache.get(rowName)
1319
+ if (!index) {
1320
+ const seq = MSA.getRow(rowName)
1321
+ if (!seq) {
1322
+ return undefined
1323
+ }
1324
+ index = buildSeqPosIndex(seq)
1325
+ cache.set(rowName, index)
1326
+ }
1327
+ return index
1061
1328
  },
1062
1329
 
1063
1330
  /**
1064
1331
  * #getter
1332
+ * every sequence the alignment holds, keyed by row name, whatever the
1333
+ * tree currently shows. `rows` is the rows on screen; this is the rows
1334
+ * that exist, and every lookup about a named row goes through it --
1335
+ * collapsing a clade hides rows, it does not delete their sequence
1065
1336
  */
1066
1337
  get rowMap() {
1067
- return new Map(this.rows)
1338
+ const MSA = this.MSA
1339
+ return new Map(
1340
+ MSA?.getNames()
1341
+ .map(name => [name, MSA.getRow(name)] as const)
1342
+ .filter(([, seq]) => !!seq),
1343
+ )
1068
1344
  },
1069
1345
  /**
1070
1346
  * #getter
@@ -1201,10 +1477,13 @@ function stateModelFactory() {
1201
1477
  get hierarchy(): HierarchyNode<NodeWithIdsAndLength> {
1202
1478
  const r = this.root
1203
1479
  clusterLayout(r, this.totalHeight, self.treeWidth)
1204
- r.data.length = 0
1205
- const max = maxLength(r)
1480
+ const max = this.rootToTipLength
1206
1481
  const k = max ? self.treeWidth / max : 0
1207
- setBrLength(r, 0, k)
1482
+ // the displayed root starts at x=0 whatever branch length it carries,
1483
+ // so its own length is subtracted here rather than zeroed on the parsed
1484
+ // node -- `root` hands out the cached parse, and writing to it made
1485
+ // showOnly shorten that branch for good
1486
+ setBrLength(r, -Math.max(r.data.length || 0, 0), k)
1208
1487
  // for each collapsed clade, record the pixel x-position of its farthest
1209
1488
  // tip so the renderer can draw a triangle spanning the branch-length
1210
1489
  // extent of the hidden subtree
@@ -1233,10 +1512,34 @@ function stateModelFactory() {
1233
1512
 
1234
1513
  /**
1235
1514
  * #getter
1236
- * max branch length across the tree, used to scale phylogram x-positions
1515
+ * branch-length extent of the displayed tree, root to farthest tip, in
1516
+ * the tree's own units
1517
+ */
1518
+ get rootToTipLength() {
1519
+ const r = this.root
1520
+ return maxLength(r) - Math.max(r.data.length || 0, 0)
1521
+ },
1522
+
1523
+ /**
1524
+ * #getter
1525
+ * x-position of the farthest tip in a phylogram, px. The layout scales
1526
+ * the longest root-to-tip path onto treeWidth, so that is where it lands
1527
+ * -- and 0 for a tree carrying no lengths at all, which draws as a
1528
+ * cladogram instead
1237
1529
  */
1238
1530
  get maxBranchLength() {
1239
- return findMaxBranchLen(this.hierarchy)
1531
+ return this.rootToTipLength ? self.treeWidth : 0
1532
+ },
1533
+
1534
+ /**
1535
+ * #getter
1536
+ * pixels per unit of branch length in the current phylogram layout, and 0
1537
+ * in cladogram mode, where the x-positions carry no length at all. The
1538
+ * scale bar over the tree is drawn from it.
1539
+ */
1540
+ get pxPerBranchLength() {
1541
+ const max = maxLength(this.root)
1542
+ return this.showBranchLenEffective && max ? self.treeWidth / max : 0
1240
1543
  },
1241
1544
 
1242
1545
  /**
@@ -1251,7 +1554,7 @@ function stateModelFactory() {
1251
1554
  * #getter
1252
1555
  */
1253
1556
  get allBranchesLength0() {
1254
- return links(this.hierarchy).every(s => !s.source.data.length)
1557
+ return this.rootToTipLength === 0
1255
1558
  },
1256
1559
 
1257
1560
  /**
@@ -1275,673 +1578,1018 @@ function stateModelFactory() {
1275
1578
  /**
1276
1579
  * #getter
1277
1580
  */
1278
- get dataInitialized() {
1279
- // truthiness, not `!== ''`: these are types.maybe, and DataModel's
1280
- // postProcessSnapshot drops a document over 50kb, so a restored session
1281
- // that inlined a large alignment comes back `undefined` here -- which
1282
- // `!== ''` reads as initialized and renders an empty view instead of
1283
- // the import form
1284
- return !!(self.data.msa || self.data.tree) && !self.error
1285
- },
1286
- /**
1287
- * #getter
1288
- */
1289
- get blocksX() {
1290
- return calculateBlocks({
1291
- viewportSize: self.msaAreaWidth,
1292
- viewportPos: -self.scrollX,
1293
- blockSize: self.blockSize,
1294
- mapSize: self.totalWidth,
1295
- })
1296
- },
1297
- /**
1298
- * #getter
1299
- */
1300
- get blocksY() {
1301
- return calculateBlocks({
1302
- viewportSize: this.visibleMsaHeight,
1303
- viewportPos: -self.scrollY,
1304
- blockSize: self.blockSize,
1305
- mapSize: self.totalHeight,
1306
- })
1581
+ get showMsaLetters() {
1582
+ return (
1583
+ self.drawMsaLetters &&
1584
+ self.rowHeight >= minLetterRowHeight &&
1585
+ self.colWidth >= minLetterColWidth &&
1586
+ self.colWidth > self.rowHeight / 2
1587
+ )
1307
1588
  },
1308
1589
  /**
1309
1590
  * #getter
1310
- * height of the alignment viewport, px. The same subtraction as
1311
- * msaAreaHeight, which is defined later in the views chain
1312
1591
  */
1313
- get visibleMsaHeight() {
1314
- return (
1315
- self.height -
1316
- self.headerHeight -
1317
- (self.msaAreaWidth < self.totalWidth ? self.minimapHeight : 0)
1318
- )
1592
+ get showTreeText() {
1593
+ return self.drawLabels && self.rowHeight >= minLetterRowHeight
1319
1594
  },
1320
1595
  }))
1321
1596
  .views(self => ({
1322
1597
  /**
1323
1598
  * #getter
1324
1599
  */
1325
- get blocks2d() {
1326
- return self.blocksY.flatMap(by =>
1327
- self.blocksX.map(bx => [bx, by] as const),
1328
- )
1600
+ get labelWidthMap() {
1601
+ const { showTreeText, leaves, treeMetadata } = self
1602
+ // gated on the same condition the renderer draws labels under, so the
1603
+ // gutter labelsWidth reserves and the labels actually drawn cannot
1604
+ // disagree -- and so turning labels off hands their space to the tree.
1605
+ // Measured at a fixed reference size and scaled by labelWidthScale:
1606
+ // re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
1607
+ // 50k-leaf tree
1608
+ return showTreeText
1609
+ ? new Map(
1610
+ leaves.map(node => {
1611
+ const { name } = node.data
1612
+ // `||`, matching renderTreeLabels: an empty genome falls back
1613
+ // to the row name, and measuring '' would size the gutter (and
1614
+ // the label's click target) to nothing
1615
+ const displayName = treeMetadata[name]?.genome || name
1616
+ return [
1617
+ name,
1618
+ measureTextCanvas(displayName, labelReferenceFontSize),
1619
+ ] as const
1620
+ }),
1621
+ )
1622
+ : new Map<string, number>()
1329
1623
  },
1330
1624
 
1331
1625
  /**
1332
1626
  * #getter
1627
+ * factor turning a labelWidthMap entry into its width at the current
1628
+ * font size
1333
1629
  */
1334
- get isLoading() {
1335
- return self.loadingMSA || self.loadingTree
1630
+ get labelWidthScale() {
1631
+ return self.fontSize / labelReferenceFontSize
1632
+ },
1633
+
1634
+ get labelsWidth() {
1635
+ // a loop, not Math.max(...widths.values()): spreading a map of every
1636
+ // leaf passes one argument per row, and the argument limit is somewhere
1637
+ // around 125k -- so the bundled 230k-tip COVID tree threw
1638
+ // "RangeError: Maximum call stack size exceeded" out of a getter the
1639
+ // treeWidth autorun reads on load
1640
+ let max = 0
1641
+ for (const width of this.labelWidthMap.values()) {
1642
+ if (width > max) {
1643
+ max = width
1644
+ }
1645
+ }
1646
+ return max * this.labelWidthScale
1336
1647
  },
1648
+
1337
1649
  /**
1338
1650
  * #getter
1339
1651
  */
1340
- get maxScrollX() {
1341
- return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0)
1652
+ get secondaryStructureConsensus() {
1653
+ return self.MSA?.secondaryStructureConsensus
1342
1654
  },
1655
+
1343
1656
  /**
1344
1657
  * #getter
1345
- * most-negative allowed scrollY, keeping the last row in view rather than
1346
- * letting the whole alignment scroll off the top.
1347
1658
  */
1348
- get maxScrollY() {
1349
- return Math.min(-self.totalHeight + self.visibleMsaHeight, 0)
1659
+ get seqConsensus() {
1660
+ return self.MSA?.seqConsensus
1350
1661
  },
1662
+
1351
1663
  /**
1352
1664
  * #getter
1665
+ * the base pairs of the consensus secondary structure, as arcs. The WUSS
1666
+ * string is collapsed through the hidden columns before it is parsed, so
1667
+ * the pairs land in the same visible column space the text track does
1353
1668
  */
1354
- get showMsaLetters() {
1355
- return (
1356
- self.drawMsaLetters &&
1357
- self.rowHeight >= minLetterRowHeight &&
1358
- self.colWidth >= minLetterColWidth &&
1359
- self.colWidth > self.rowHeight / 2
1669
+ get secondaryStructureArcs(): Arc[] | undefined {
1670
+ const { blanks, hideGapsEffective } = self
1671
+ const ss = this.secondaryStructureConsensus
1672
+ if (!ss) {
1673
+ return undefined
1674
+ }
1675
+ return parseWuss(hideGapsEffective ? skipBlanks(blanks, ss) : ss).map(
1676
+ ({ start, end, pseudoknot }) => ({
1677
+ start,
1678
+ end,
1679
+ color: pseudoknot ? PSEUDOKNOT_ARC : HELIX_ARC,
1680
+ }),
1360
1681
  )
1361
1682
  },
1683
+
1362
1684
  /**
1363
1685
  * #getter
1364
1686
  */
1365
- get showTreeText() {
1366
- return self.drawLabels && self.rowHeight >= minLetterRowHeight
1367
- },
1368
- }))
1369
- .actions(self => ({
1370
- /**
1371
- * #action
1372
- */
1373
- setDrawMsaLetters(arg: boolean) {
1374
- self.drawMsaLetters = arg
1687
+ get adapterTrackModels(): BasicTrack[] {
1688
+ const { MSA, hideGapsEffective, blanks } = self
1689
+ const tracks = (MSA?.tracks ?? []).filter(t => !!t.data)
1690
+ if (tracks.length === 0) {
1691
+ // reading rowHeight up front made every zoom frame rebuild the track
1692
+ // list, and the canvases redraw on the track object they are handed
1693
+ // changing
1694
+ return []
1695
+ }
1696
+ const { rowHeight } = self
1697
+ return tracks.map(t => ({
1698
+ model: {
1699
+ ...t,
1700
+ kind: 'text' as const,
1701
+ data: hideGapsEffective ? skipBlanks(blanks, t.data!) : t.data,
1702
+ height: rowHeight,
1703
+ },
1704
+ ReactComponent: TrackBlocks,
1705
+ }))
1375
1706
  },
1376
1707
 
1377
1708
  /**
1378
- * #action
1379
- */
1380
- setScrollZoom(arg: boolean) {
1381
- self.scrollZoom = arg
1382
- },
1383
-
1384
- /**
1385
- * #action
1386
- * set hovered tree node and its descendants
1709
+ * #getter
1710
+ * a data track's values or string, projected from its row's residues
1711
+ * onto alignment columns when it names a row
1387
1712
  */
1388
- setHoveredTreeNode(nodeId?: string) {
1389
- // the tree's mousemove handler calls this on every event, and both the
1390
- // lookup and the write are expensive: `find` walks the whole hierarchy,
1391
- // and a fresh object here invalidates hoveredRowIndices and redraws the
1392
- // tree and MSA overlays. Re-hovering the same node is the common case
1393
- if (nodeId === self.hoveredTreeNode?.nodeId) {
1394
- return
1713
+ get columnTrackContent() {
1714
+ const { MSA, blanks, hideGapsEffective } = self
1715
+ const width = MSA?.getWidth() ?? 0
1716
+ const project = <T>(track: ColumnTrackSpec, items: T[], fill: T) => {
1717
+ if (!track.row) {
1718
+ return items
1719
+ }
1720
+ const out = Array.from({ length: width }, () => fill)
1721
+ const index = self.seqPosIndex(track.row)
1722
+ items.forEach((item, seqPos) => {
1723
+ const col = index?.[seqPos]
1724
+ if (col !== undefined) {
1725
+ out[col] = item
1726
+ }
1727
+ })
1728
+ return out
1395
1729
  }
1396
- if (!nodeId) {
1397
- self.hoveredTreeNode = undefined
1398
- return
1730
+ const skip = <T>(items: T[]) =>
1731
+ hideGapsEffective ? dropBlanks(blanks, items) : items
1732
+ // an arc names two positions rather than one per column, so it takes
1733
+ // the same two steps the arrays take -- a row's residues onto columns,
1734
+ // then columns onto the visible ones -- as a lookup. visibleColsBefore,
1735
+ // not globalColToVisibleCol: an endpoint in a hidden column collapses
1736
+ // to where that column went instead of taking the whole arc with it
1737
+ const resolve = (track: ColumnTrackSpec, pos: number) => {
1738
+ const col = track.row
1739
+ ? self.seqPosIndex(track.row)?.[pos - 1]
1740
+ : pos - 1
1741
+ if (col === undefined || col < 0 || col >= width) {
1742
+ return undefined
1743
+ }
1744
+ return hideGapsEffective ? visibleColsBefore(blanks, col) : col
1399
1745
  }
1400
- const node = find(self.hierarchy, n => n.data.id === nodeId)
1401
- self.hoveredTreeNode = node
1402
- ? {
1403
- nodeId,
1404
- descendantNames: leaves(node).map(leaf => leaf.data.name),
1746
+ return new Map<
1747
+ string,
1748
+ { values?: number[]; data?: string; arcs?: Arc[] }
1749
+ >(
1750
+ self.columnTracks.map(track => {
1751
+ if (track.kind === 'arc') {
1752
+ const arcs = (track.arcs ?? [])
1753
+ .map(arc => {
1754
+ const start = resolve(track, Math.min(arc.start, arc.end))
1755
+ const end = resolve(track, Math.max(arc.start, arc.end))
1756
+ return start !== undefined && end !== undefined && start < end
1757
+ ? { start, end, color: arc.color }
1758
+ : undefined
1759
+ })
1760
+ .filter(notEmpty)
1761
+ return [track.id, { arcs }] as const
1405
1762
  }
1406
- : undefined
1763
+ if (track.kind === 'bar') {
1764
+ const max = track.max ?? 1
1765
+ const values = skip(project(track, track.values ?? [], 0)).map(
1766
+ v => Math.min(1, Math.max(0, v / max)),
1767
+ )
1768
+ return [track.id, { values }] as const
1769
+ }
1770
+ const data = skip(project(track, (track.data ?? '').split(''), ' '))
1771
+ return [track.id, { data: data.join('') }] as const
1772
+ }),
1773
+ )
1407
1774
  },
1408
-
1409
1775
  /**
1410
- * #action
1411
- * Calculate a neighbor joining tree from the current MSA using BLOSUM62 distances
1776
+ * #getter
1412
1777
  */
1413
- calculateNeighborJoiningTreeFromMSA() {
1414
- if (self.rows.length < 2) {
1415
- throw new Error('Need at least 2 sequences to build a tree')
1416
- }
1417
- const newickTree = calculateNeighborJoiningTree(self.rows)
1418
- self.setTree(newickTree)
1778
+ get columnTrackModels(): BasicTrack[] {
1779
+ // read per kind, not up front: a text track is the only kind sized by
1780
+ // the row height, and reading it here rebuilt every data track on every
1781
+ // vertical zoom step
1782
+ const defaultHeight = (kind: ColumnTrackSpec['kind']) =>
1783
+ kind === 'bar'
1784
+ ? self.conservationTrackHeight
1785
+ : kind === 'arc'
1786
+ ? self.arcTrackHeight
1787
+ : self.rowHeight
1788
+ return self.columnTracks.map(track => ({
1789
+ model: {
1790
+ id: track.id,
1791
+ name: track.name,
1792
+ kind: track.kind,
1793
+ height:
1794
+ self.columnTrackHeights[track.id] ??
1795
+ track.height ??
1796
+ defaultHeight(track.kind),
1797
+ // the spec has one `color`; bar and arc are separate track models
1798
+ // that read it under their own name
1799
+ barColor: track.color,
1800
+ arcColor: track.color,
1801
+ customColorScheme: track.colors,
1802
+ data: this.columnTrackContent.get(track.id)?.data,
1803
+ arcs: this.columnTrackContent.get(track.id)?.arcs,
1804
+ },
1805
+ ReactComponent: TrackBlocks,
1806
+ }))
1807
+ },
1808
+ /**
1809
+ * #getter
1810
+ */
1811
+ /**
1812
+ * #getter
1813
+ * the consensus secondary structure as a track, when there is one. Its
1814
+ * own getter so the object keeps its identity across a zoom: the canvas
1815
+ * redraws on the track it is handed changing, and rebuilding these
1816
+ * alongside everything else made every zoom frame redraw every track
1817
+ */
1818
+ get basePairTrackModels(): BasicTrack[] {
1819
+ const arcs = this.secondaryStructureArcs
1820
+ return arcs?.length
1821
+ ? [
1822
+ {
1823
+ model: {
1824
+ id: 'base-pairs',
1825
+ name: 'Base pairs',
1826
+ kind: 'arc' as const,
1827
+ height: self.arcTrackHeight,
1828
+ arcs,
1829
+ },
1830
+ ReactComponent: TrackBlocks,
1831
+ },
1832
+ ]
1833
+ : []
1419
1834
  },
1420
1835
 
1421
1836
  /**
1422
- * #action
1423
- * restore the default column width and row height
1837
+ * #getter
1838
+ * the tracks computed from the alignment itself, which depend on their
1839
+ * own heights and on the alphabet -- and on nothing zoom changes
1424
1840
  */
1425
- resetZoom() {
1426
- self.setColWidth(defaultColWidth)
1427
- self.setRowHeight(defaultRowHeight)
1841
+ get computedTrackModels(): BasicTrack[] {
1842
+ return [
1843
+ {
1844
+ id: 'conservation',
1845
+ name: 'Conservation',
1846
+ kind: 'bar' as const,
1847
+ height: self.conservationTrackHeight,
1848
+ barColor: 'gray',
1849
+ },
1850
+ ...(self.sequenceType === 'amino'
1851
+ ? [
1852
+ {
1853
+ id: 'property-conservation',
1854
+ name: 'Property conservation',
1855
+ kind: 'bar' as const,
1856
+ height: self.conservationTrackHeight,
1857
+ barColor: '#6a51a3',
1858
+ },
1859
+ ]
1860
+ : []),
1861
+ {
1862
+ id: 'sequence-logo',
1863
+ name: 'Sequence logo',
1864
+ kind: 'logo' as const,
1865
+ height: self.sequenceLogoTrackHeight,
1866
+ },
1867
+ // last, so it sits against the alignment it numbers
1868
+ {
1869
+ id: 'position-ruler',
1870
+ name: 'Position',
1871
+ kind: 'ruler' as const,
1872
+ height: 20,
1873
+ },
1874
+ ].map(model => ({ model, ReactComponent: TrackBlocks }))
1428
1875
  },
1876
+
1877
+ get tracks(): BasicTrack[] {
1878
+ return [
1879
+ ...this.adapterTrackModels,
1880
+ ...this.basePairTrackModels,
1881
+ ...this.columnTrackModels,
1882
+ ...this.computedTrackModels,
1883
+ ]
1884
+ },
1885
+
1429
1886
  /**
1430
- * #action
1887
+ * #getter
1431
1888
  */
1432
- zoomOutHorizontal() {
1433
- self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75))
1434
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1889
+ get turnedOnTracks() {
1890
+ return this.tracks.filter(
1891
+ f =>
1892
+ !trackIsOff(self.turnedOffTracks, f.model.id, f.model.defaultOff),
1893
+ )
1435
1894
  },
1895
+
1436
1896
  /**
1437
- * #action
1897
+ * #getter
1438
1898
  */
1439
- zoomInHorizontal() {
1440
- self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
1441
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1899
+ get showHorizontalScrollbar() {
1900
+ return overflows(self.totalWidth, self.msaAreaWidth)
1442
1901
  },
1902
+
1443
1903
  /**
1444
- * #action
1904
+ * #method
1905
+ * Return a row-specific letter at a visible column, or undefined if gap.
1906
+ *
1907
+ * @param rowName - The name of the row
1908
+ * @param visibleCol - The visible column index (what the user sees on screen)
1909
+ * @returns The letter at that position, or undefined if it's a gap
1445
1910
  */
1446
- zoomInVertical() {
1447
- self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5))
1911
+ visibleColToRowLetter(rowName: string, visibleCol: number) {
1912
+ return self.rowMap.get(rowName)?.[
1913
+ this.visibleColToGlobalCol(visibleCol)
1914
+ ]
1448
1915
  },
1916
+
1449
1917
  /**
1450
- * #action
1918
+ * #method
1919
+ * Convert a visible column to a row-specific sequence position (0-based).
1920
+ * Returns undefined if the position is a gap in the sequence.
1921
+ *
1922
+ * PUBLIC API: this and the sibling coordinate converters
1923
+ * (visibleColToGlobalCol, seqPosToVisibleCol, globalColToVisibleCol,
1924
+ * seqPosToGlobalCol) are how a host translates between alignment columns
1925
+ * and a row's residue positions across gaps. Keep them stable.
1926
+ *
1927
+ * @param rowName - The name of the row
1928
+ * @param visibleCol - The visible column index
1929
+ * @returns The sequence position (0-based), or undefined if it's a gap
1451
1930
  */
1452
- zoomOutVertical() {
1453
- self.rowHeight = Math.max(
1454
- minRowHeight,
1455
- Math.floor(self.rowHeight * 0.75),
1931
+ visibleColToSeqPos(rowName: string, visibleCol: number) {
1932
+ // a binary search of the row's index, not a scan of the row: this
1933
+ // answers on every mouse move, and a 30k-column row scanned per event
1934
+ // is the whole frame
1935
+ return seqPosOfGlobalCol(
1936
+ self.seqPosIndex(rowName),
1937
+ this.visibleColToGlobalCol(visibleCol),
1456
1938
  )
1457
1939
  },
1940
+
1458
1941
  /**
1459
- * #action
1942
+ * #method
1943
+ * Convert a visible column to a row-specific sequence position (1-based).
1944
+ * Returns undefined if the position is a gap in the sequence.
1945
+ *
1946
+ * @param rowName - The name of the row
1947
+ * @param visibleCol - The visible column index
1948
+ * @returns The sequence position (1-based), or undefined if it's a gap
1460
1949
  */
1461
- zoomIn() {
1462
- transaction(() => {
1463
- this.zoomInHorizontal()
1464
- this.zoomInVertical()
1465
- })
1950
+ visibleColToSeqPosOneBased(rowName: string, visibleCol: number) {
1951
+ const val = this.visibleColToSeqPos(rowName, visibleCol)
1952
+ return val !== undefined ? val + 1 : undefined
1466
1953
  },
1954
+
1467
1955
  /**
1468
- * #action
1956
+ * #method
1957
+ * Convert a global column index to a visible column index.
1958
+ * Returns undefined if the column is hidden (in blanks).
1959
+ * This is the inverse of visibleColToGlobalCol.
1960
+ *
1961
+ * @param globalCol - The global column index in the full MSA
1962
+ * @returns The visible column index, or undefined if the column is hidden
1469
1963
  */
1470
- zoomOut() {
1471
- transaction(() => {
1472
- this.zoomOutHorizontal()
1473
- this.zoomOutVertical()
1474
- })
1964
+ globalColToVisibleCol(globalCol: number) {
1965
+ const { blanks, hideGapsEffective } = self
1966
+ if (!hideGapsEffective) {
1967
+ return globalCol
1968
+ }
1969
+ return globalColToVisibleCol(blanks, globalCol)
1475
1970
  },
1971
+
1476
1972
  /**
1477
- * #action
1478
- * Smoothly zoom by a continuous scaleFactor. The column under the cursor
1479
- * (offsetX/offsetY, px relative to the MSA area) stays anchored
1480
- * horizontally. Vertically the anchor is biased toward the top: when the
1481
- * alignment nearly fits the viewport, snap to y=0 rather than pinning a
1482
- * random row under the cursor, with the bias fading out as the alignment
1483
- * grows taller than the viewport (where cursor-anchoring is useful).
1484
- * Drives wheel/trackpad-pinch zoom.
1973
+ * #method
1974
+ * Convert a visible column index (what a mouse handler reports) back to a
1975
+ * column of the full alignment. Hidden columns shift everything to their
1976
+ * right, so a host that holds per-column data of its own has to make this
1977
+ * hop before indexing it.
1978
+ *
1979
+ * @param visibleCol - The visible column index
1980
+ * @returns The global column index in the full MSA
1485
1981
  */
1486
- zoomToPos(scaleFactor: number, offsetX: number, offsetY: number) {
1487
- transaction(() => {
1488
- const colInView = (-self.scrollX + offsetX) / self.colWidth
1489
- const rowInView = (-self.scrollY + offsetY) / self.rowHeight
1490
- self.colWidth = clamp(
1491
- self.colWidth * scaleFactor,
1492
- minColWidth,
1493
- maxCellSize,
1494
- )
1495
- self.rowHeight = clamp(
1496
- self.rowHeight * scaleFactor,
1497
- minRowHeight,
1498
- maxCellSize,
1982
+ visibleColToGlobalCol(visibleCol: number) {
1983
+ const { blanks, hideGapsEffective } = self
1984
+ return hideGapsEffective
1985
+ ? visibleColToGlobalCol(blanks, visibleCol)
1986
+ : visibleCol
1987
+ },
1988
+
1989
+ /**
1990
+ * #method
1991
+ * Convert a sequence position (ungapped) to a global column index.
1992
+ * Returns undefined for a row the alignment does not have -- answering
1993
+ * anyway is how a mistyped or stale row name came to highlight column 0.
1994
+ *
1995
+ * @param rowName - The name of the row
1996
+ * @param seqPos - The sequence position (0-based, ungapped)
1997
+ * @returns The global column index in the full MSA, or undefined
1998
+ */
1999
+ seqPosToGlobalCol(rowName: string, seqPos: number) {
2000
+ const seq = self.rowMap.get(rowName)
2001
+ if (seq === undefined) {
2002
+ return undefined
2003
+ }
2004
+ const col = self.seqPosIndex(rowName)?.[seqPos]
2005
+ // past the end of the ungapped sequence: 0 for the degenerate all-gap
2006
+ // row, otherwise one past the last column
2007
+ return col ?? (seqPos === 0 ? 0 : seq.length)
2008
+ },
2009
+
2010
+ /**
2011
+ * #method
2012
+ * Convert a sequence position (ungapped) directly to a visible column index.
2013
+ * This combines seqPosToGlobalCol and globalColToVisibleCol.
2014
+ *
2015
+ * @param rowName - The name of the row
2016
+ * @param seqPos - The sequence position (0-based, ungapped)
2017
+ * @returns The visible column index, or undefined if the column is hidden
2018
+ */
2019
+ seqPosToVisibleCol(rowName: string, seqPos: number) {
2020
+ const globalCol = this.seqPosToGlobalCol(rowName, seqPos)
2021
+ return globalCol === undefined
2022
+ ? undefined
2023
+ : this.globalColToVisibleCol(globalCol)
2024
+ },
2025
+
2026
+ /**
2027
+ * #getter
2028
+ * every reason a mapping is being ignored, so a host can say which. A
2029
+ * mapping outlives the alignment it was computed for; when the two no
2030
+ * longer agree the lookups have to refuse, and refusing invisibly is how
2031
+ * "there is no structure here" gets confused with "this data is stale".
2032
+ */
2033
+ get residueMappingProblems(): ResidueMappingProblem[] {
2034
+ const problems: ResidueMappingProblem[] = []
2035
+ for (const mapping of self.residueMappings) {
2036
+ const where = { row: mapping.row, structureId: mapping.structure.id }
2037
+ const residues = self.seqPosIndex(mapping.row)?.length
2038
+ if (residues === undefined) {
2039
+ problems.push({
2040
+ ...where,
2041
+ scope: 'mapping',
2042
+ reason: 'no such row in the alignment',
2043
+ })
2044
+ continue
2045
+ }
2046
+ if (
2047
+ mapping.rowLength !== undefined &&
2048
+ mapping.rowLength !== residues
2049
+ ) {
2050
+ problems.push({
2051
+ ...where,
2052
+ scope: 'mapping',
2053
+ reason: `computed against a ${mapping.rowLength}-residue row; this one has ${residues}`,
2054
+ })
2055
+ continue
2056
+ }
2057
+ const overrun = mapping.segments.find(
2058
+ segment => segment.rowEnd > residues || segment.rowStart < 1,
1499
2059
  )
1500
- self.scrollX = clamp(
1501
- offsetX - colInView * self.colWidth,
1502
- self.maxScrollX,
1503
- 0,
2060
+ if (overrun) {
2061
+ problems.push({
2062
+ ...where,
2063
+ scope: 'mapping',
2064
+ reason: `segment ${overrun.rowStart}-${overrun.rowEnd} does not fit a ${residues}-residue row`,
2065
+ })
2066
+ continue
2067
+ }
2068
+ for (const segment of mapping.segments) {
2069
+ if (!sameLength(segment)) {
2070
+ problems.push({
2071
+ ...where,
2072
+ scope: 'segment',
2073
+ reason: `segment ${segment.rowStart}-${segment.rowEnd} maps to ${segment.structStart}-${segment.structEnd}, which is a different length`,
2074
+ })
2075
+ }
2076
+ }
2077
+ }
2078
+ return problems
2079
+ },
2080
+
2081
+ /**
2082
+ * #getter
2083
+ * the mappings that still fit the loaded alignment. A row-level problem
2084
+ * takes the whole mapping out; a single malformed segment takes only
2085
+ * itself, since the rest of the mapping is still a claim about residues
2086
+ * that exist.
2087
+ */
2088
+ get usableResidueMappings(): ResidueMapping[] {
2089
+ const unusable = new Set(
2090
+ this.residueMappingProblems
2091
+ .filter(problem => problem.scope === 'mapping')
2092
+ .map(problem => `${problem.row}\u0000${problem.structureId}`),
2093
+ )
2094
+ return self.residueMappings
2095
+ .filter(m => !unusable.has(`${m.row}\u0000${m.structure.id}`))
2096
+ .map(m => ({
2097
+ ...m,
2098
+ segments: m.segments.filter(sameLength),
2099
+ }))
2100
+ },
2101
+
2102
+ /**
2103
+ * #getter
2104
+ * the structures the loaded alignment has usable mappings onto. A row can
2105
+ * have several -- an experimental entry and a predicted model, say -- so
2106
+ * a host that means a particular one has to name it.
2107
+ */
2108
+ get mappedStructures() {
2109
+ return this.usableResidueMappings.map(m => ({
2110
+ row: m.row,
2111
+ structure: m.structure,
2112
+ }))
2113
+ },
2114
+
2115
+ /**
2116
+ * #method
2117
+ * The structure residue a row residue is, or undefined. Refusing is the
2118
+ * point: the guess this replaces answered every query, with a wrong
2119
+ * residue when it did not know.
2120
+ *
2121
+ * It also refuses when the answer is not unique. A row commonly maps onto
2122
+ * several structures -- an experimental entry and two predicted models --
2123
+ * and returning whichever came first would be the same class of wrong,
2124
+ * quieter. Name one with `structureId`, or use `mappedStructures` to see
2125
+ * what there is.
2126
+ *
2127
+ * Positions are 1-based, as `residueMappings` and `highlights` are --
2128
+ * note that the column helpers above take 0-based ones.
2129
+ *
2130
+ * @param rowName - The alignment row
2131
+ * @param seqPos - Residue of that row, 1-based
2132
+ * @param structureId - Which structure, when the row maps onto several
2133
+ */
2134
+ structureResidue(
2135
+ rowName: string,
2136
+ seqPos: number,
2137
+ structureId?: string,
2138
+ ): StructureResidue | undefined {
2139
+ const hits: StructureResidue[] = []
2140
+ for (const mapping of this.usableResidueMappings) {
2141
+ if (mapping.row !== rowName) {
2142
+ continue
2143
+ }
2144
+ if (
2145
+ structureId !== undefined &&
2146
+ mapping.structure.id !== structureId
2147
+ ) {
2148
+ continue
2149
+ }
2150
+ const segment = mapping.segments.find(
2151
+ seg => seqPos >= seg.rowStart && seqPos <= seg.rowEnd,
1504
2152
  )
2153
+ if (segment) {
2154
+ const position = segment.structStart + (seqPos - segment.rowStart)
2155
+ hits.push({
2156
+ structure: mapping.structure,
2157
+ position,
2158
+ observed: !inRanges(mapping.unobserved, position),
2159
+ })
2160
+ }
2161
+ }
2162
+ return hits.length === 1 ? hits[0] : undefined
2163
+ },
1505
2164
 
1506
- const anchoredScrollY = offsetY - rowInView * self.rowHeight
1507
- // maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
1508
- // overflows, so -maxScrollY is exactly that overflow past the
1509
- // scrollable MSA viewport (0 when it fits)
1510
- const overflow = Math.max(0, -self.maxScrollY)
1511
- const visibleHeight = self.totalHeight - overflow
1512
- const topBias =
1513
- visibleHeight > 0 ? clamp(1 - overflow / visibleHeight, 0, 1) : 1
1514
- self.scrollY = clamp(
1515
- anchoredScrollY * (1 - topBias),
1516
- self.maxScrollY,
1517
- 0,
2165
+ /**
2166
+ * #method
2167
+ * The row residue a structure residue is, the same lookup backwards, and
2168
+ * refusing on the same terms. `asymId` picks between mappings onto the
2169
+ * same entry, which a homodimer -- two rows, two chains, one id -- always
2170
+ * needs; without it such a lookup is ambiguous and gets nothing.
2171
+ *
2172
+ * @param structureId - The structure's id, as the mapping names it
2173
+ * @param position - Residue of that structure, 1-based label_seq_id
2174
+ * @param asymId - Which chain, when the id alone is ambiguous
2175
+ */
2176
+ rowResidue(
2177
+ structureId: string,
2178
+ position: number,
2179
+ asymId?: string,
2180
+ ): RowResidue | undefined {
2181
+ const hits: RowResidue[] = []
2182
+ for (const mapping of this.usableResidueMappings) {
2183
+ if (mapping.structure.id !== structureId) {
2184
+ continue
2185
+ }
2186
+ if (asymId !== undefined && mapping.structure.asymId !== asymId) {
2187
+ continue
2188
+ }
2189
+ const segment = mapping.segments.find(
2190
+ seg => position >= seg.structStart && position <= seg.structEnd,
1518
2191
  )
1519
- })
2192
+ if (segment) {
2193
+ hits.push({
2194
+ rowName: mapping.row,
2195
+ seqPos: segment.rowStart + (position - segment.structStart),
2196
+ })
2197
+ }
2198
+ }
2199
+ return hits.length === 1 ? hits[0] : undefined
2200
+ },
2201
+ }))
2202
+
2203
+ .views(self => ({
2204
+ /**
2205
+ * #getter
2206
+ * the vertical space the alignment rows actually get: the widget height
2207
+ * less everything stacked above and below them -- the header, the tracks,
2208
+ * and the minimap when the columns overflow. Every consumer wants this
2209
+ * same subtraction, so there is one of it: blocksY, maxScrollY, the
2210
+ * vertical scrollbar and fitVertically all read it, and a second getter
2211
+ * that forgot the tracks is what put the last rows out of reach.
2212
+ */
2213
+ get msaAreaHeight() {
2214
+ return (
2215
+ self.height -
2216
+ (self.showHorizontalScrollbar ? self.minimapHeight : 0) -
2217
+ self.headerHeight -
2218
+ this.totalTrackAreaHeight
2219
+ )
2220
+ },
2221
+ /**
2222
+ * #getter
2223
+ * total height of track area (px)
2224
+ */
2225
+ get totalTrackAreaHeight() {
2226
+ return sum(self.turnedOnTracks.map(r => r.model.height))
2227
+ },
2228
+ /**
2229
+ * one representative annotation per accession, which is what the legend,
2230
+ * the filter dialog and the palettes key off
2231
+ */
2232
+ get annotationTypes() {
2233
+ // first occurrence wins. The representative supplies only the name,
2234
+ // description and -- for ordinal segments -- the start that orders
2235
+ // them, and those agree across an accession's instances
2236
+ const types = new Map<string, Annotation>()
2237
+ for (const annot of self.annotations) {
2238
+ if (!types.has(annot.accession)) {
2239
+ types.set(annot.accession, annot)
2240
+ }
2241
+ }
2242
+ return types
2243
+ },
2244
+ get filteredAnnotations() {
2245
+ return self.annotations.filter(
2246
+ r => !self.turnedOffFeatures.get(r.accession),
2247
+ )
2248
+ },
2249
+ get annotationsByRow() {
2250
+ return groupBy(this.filteredAnnotations, r => r.id)
1520
2251
  },
2252
+ }))
2253
+ .views(self => ({
1521
2254
  /**
1522
- * #action
2255
+ * #getter
1523
2256
  */
1524
- doScrollY(deltaY: number) {
1525
- this.setScrollY(self.scrollY + deltaY)
2257
+ get showVerticalScrollbar() {
2258
+ return overflows(self.totalHeight, self.msaAreaHeight)
1526
2259
  },
1527
-
2260
+ }))
2261
+ .views(self => ({
1528
2262
  /**
1529
- * #action
1530
- * set scroll Y-offset (px), clamped to keep the alignment in view
2263
+ * #getter
1531
2264
  */
1532
- setScrollY(n: number) {
1533
- self.scrollY = clamp(n, self.maxScrollY, 0)
2265
+ get dataInitialized() {
2266
+ // truthiness, not `!== ''`: these are types.maybe, and DataModel's
2267
+ // postProcessSnapshot drops a document over 50kb, so a restored session
2268
+ // that inlined a large alignment comes back `undefined` here -- which
2269
+ // `!== ''` reads as initialized and renders an empty view instead of
2270
+ // the import form
2271
+ return !!(self.data.msa || self.data.tree) && !self.error
1534
2272
  },
1535
-
1536
2273
  /**
1537
- * #action
1538
- * Set the overlay annotations and reveal the overlay in a single step (an
1539
- * empty list clears both). Every source funnels through here after its
1540
- * own adapter has flattened it: InterProScan, GFF, user uploads, NCBI CDD.
2274
+ * #getter
1541
2275
  */
1542
- setAnnotations(annotations: Annotation[]) {
1543
- self.annotations = annotations
1544
- self.setShowDomains(annotations.length > 0)
2276
+ get blocksX() {
2277
+ return calculateBlocks({
2278
+ viewportSize: self.msaAreaWidth,
2279
+ viewportPos: -self.scrollX,
2280
+ blockSize: self.blockSize,
2281
+ mapSize: self.totalWidth,
2282
+ })
1545
2283
  },
1546
-
1547
2284
  /**
1548
- * #action
1549
- * set the overlay from raw InterProScan results keyed by row name. Kept
1550
- * for downstream plugins that hold the EBI wire format; new code should
1551
- * adapt to Annotation[] and call setAnnotations.
2285
+ * #getter
1552
2286
  */
1553
- setDomains(data?: Record<string, InterProScanResults>) {
1554
- this.setAnnotations(data ? interProScanToAnnotations(data) : [])
1555
- },
1556
-
1557
- applyGFFText(gffText: string) {
1558
- this.setAnnotations(gffToAnnotations(parseGFF(gffText)))
2287
+ get blocksY() {
2288
+ return calculateBlocks({
2289
+ viewportSize: self.msaAreaHeight,
2290
+ viewportPos: -self.scrollY,
2291
+ blockSize: self.blockSize,
2292
+ mapSize: self.totalHeight,
2293
+ })
1559
2294
  },
1560
-
2295
+ }))
2296
+ .views(self => ({
1561
2297
  /**
1562
- * #action
2298
+ * #getter
1563
2299
  */
1564
- doScrollX(deltaX: number) {
1565
- this.setScrollX(self.scrollX + deltaX)
2300
+ get blocks2d() {
2301
+ return self.blocksY.flatMap(by =>
2302
+ self.blocksX.map(bx => [bx, by] as const),
2303
+ )
1566
2304
  },
1567
2305
 
1568
2306
  /**
1569
- * #action
2307
+ * #getter
1570
2308
  */
1571
- setScrollX(n: number) {
1572
- self.scrollX = clamp(n, self.maxScrollX, 0)
2309
+ get isLoading() {
2310
+ return self.loadingMSA || self.loadingTree
1573
2311
  },
1574
-
1575
2312
  /**
1576
- * #action
2313
+ * #getter
1577
2314
  */
1578
- setColumnTracks(tracks: ColumnTrackSpec[]) {
1579
- self.columnTracks.replace(tracks)
2315
+ get maxScrollX() {
2316
+ return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0)
1580
2317
  },
1581
2318
  /**
1582
- * #action
2319
+ * #getter
2320
+ * most-negative allowed scrollY, keeping the last row in view rather than
2321
+ * letting the whole alignment scroll off the top.
1583
2322
  */
1584
- toggleTrack(id: string) {
1585
- // the stored value is "is off", so the current shown state is exactly
1586
- // what the flipped entry should hold
1587
- self.turnedOffTracks.set(id, !trackIsOff(self.turnedOffTracks, id))
2323
+ get maxScrollY() {
2324
+ return Math.min(-self.totalHeight + self.msaAreaHeight, 0)
1588
2325
  },
2326
+ }))
2327
+ .actions(self => ({
1589
2328
  /**
1590
2329
  * #action
1591
2330
  */
1592
- setStatus(status?: { msg: string; url?: string; onCancel?: () => void }) {
1593
- self.status = status
2331
+ setDrawMsaLetters(arg: boolean) {
2332
+ self.drawMsaLetters = arg
1594
2333
  },
1595
- }))
1596
- .views(self => ({
2334
+
1597
2335
  /**
1598
- * #getter
2336
+ * #action
1599
2337
  */
1600
- get labelWidthMap() {
1601
- const { showTreeText, leaves, treeMetadata } = self
1602
- // gated on the same condition the renderer draws labels under, so the
1603
- // gutter labelsWidth reserves and the labels actually drawn cannot
1604
- // disagree -- and so turning labels off hands their space to the tree.
1605
- // Measured at a fixed reference size and scaled by labelWidthScale:
1606
- // re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
1607
- // 50k-leaf tree
1608
- return showTreeText
1609
- ? new Map(
1610
- leaves.map(node => {
1611
- const { name } = node.data
1612
- // `||`, matching renderTreeLabels: an empty genome falls back
1613
- // to the row name, and measuring '' would size the gutter (and
1614
- // the label's click target) to nothing
1615
- const displayName = treeMetadata[name]?.genome || name
1616
- return [
1617
- name,
1618
- measureTextCanvas(displayName, labelReferenceFontSize),
1619
- ] as const
1620
- }),
1621
- )
1622
- : new Map<string, number>()
2338
+ setScrollZoom(arg: boolean) {
2339
+ self.scrollZoom = arg
1623
2340
  },
1624
2341
 
1625
2342
  /**
1626
- * #getter
1627
- * factor turning a labelWidthMap entry into its width at the current
1628
- * font size
2343
+ * #action
2344
+ * set hovered tree node and its descendants
1629
2345
  */
1630
- get labelWidthScale() {
1631
- return self.fontSize / labelReferenceFontSize
2346
+ setHoveredTreeNode(nodeId?: string) {
2347
+ // the tree's mousemove handler calls this on every event, and both the
2348
+ // lookup and the write are expensive: `find` walks the whole hierarchy,
2349
+ // and a fresh object here invalidates hoveredRowIndices and redraws the
2350
+ // tree and MSA overlays. Re-hovering the same node is the common case
2351
+ if (nodeId === self.hoveredTreeNode?.nodeId) {
2352
+ return
2353
+ }
2354
+ if (!nodeId) {
2355
+ self.hoveredTreeNode = undefined
2356
+ return
2357
+ }
2358
+ const node = find(self.hierarchy, n => n.data.id === nodeId)
2359
+ self.hoveredTreeNode = node
2360
+ ? {
2361
+ nodeId,
2362
+ descendantNames: leaves(node).map(leaf => leaf.data.name),
2363
+ }
2364
+ : undefined
1632
2365
  },
1633
2366
 
1634
- get labelsWidth() {
1635
- // a loop, not Math.max(...widths.values()): spreading a map of every
1636
- // leaf passes one argument per row, and the argument limit is somewhere
1637
- // around 125k -- so the bundled 230k-tip COVID tree threw
1638
- // "RangeError: Maximum call stack size exceeded" out of a getter the
1639
- // treeWidth autorun reads on load
1640
- let max = 0
1641
- for (const width of this.labelWidthMap.values()) {
1642
- if (width > max) {
1643
- max = width
1644
- }
2367
+ /**
2368
+ * #action
2369
+ * Calculate a neighbor joining tree from the current MSA using BLOSUM62
2370
+ * distances. Refuses above `maxNeighborJoiningRows`: the join loop is
2371
+ * cubic and runs on the main thread, so 800 rows is a ten-second freeze
2372
+ * with no progress and no cancel, and a tree that size wants a tool built
2373
+ * for it anyway.
2374
+ */
2375
+ calculateNeighborJoiningTreeFromMSA() {
2376
+ // every sequence in the alignment, not the rows on screen: a collapsed
2377
+ // clade is a display state, and building the tree from what it leaves
2378
+ // showing drops the sequences it hides out of the result
2379
+ const rows = [...self.rowMap]
2380
+ if (rows.length < 2) {
2381
+ throw new Error('Need at least 2 sequences to build a tree')
1645
2382
  }
1646
- return max * this.labelWidthScale
2383
+ if (rows.length > maxNeighborJoiningRows) {
2384
+ throw new Error(
2385
+ `Neighbor joining here is capped at ${maxNeighborJoiningRows} sequences and this alignment has ${rows.length}. Build the tree with FastTree or IQ-TREE and open it alongside the alignment: https://gmod.org/JBrowseMSA/tutorials/protein_family`,
2386
+ )
2387
+ }
2388
+ this.replaceTree(calculateNeighborJoiningTree(rows))
1647
2389
  },
1648
2390
 
1649
2391
  /**
1650
- * #getter
2392
+ * #action
2393
+ * swap in a different tree over the same alignment. Node ids are derived
2394
+ * from the path (node-0-0-1), so a `collapsed` or `showOnly` id held over
2395
+ * from the old tree matches a real node in the new one and folds whatever
2396
+ * happens to sit there -- the ids go with the tree they name.
1651
2397
  */
1652
- get secondaryStructureConsensus() {
1653
- return self.MSA?.secondaryStructureConsensus
2398
+ replaceTree(newick: string) {
2399
+ transaction(() => {
2400
+ self.collapsed.clear()
2401
+ self.setShowOnly(undefined)
2402
+ self.setTree(newick)
2403
+ })
1654
2404
  },
1655
2405
 
1656
2406
  /**
1657
- * #getter
2407
+ * #action
2408
+ * restore the default column width and row height
1658
2409
  */
1659
- get seqConsensus() {
1660
- return self.MSA?.seqConsensus
2410
+ resetZoom() {
2411
+ self.setColWidth(defaultColWidth)
2412
+ self.setRowHeight(defaultRowHeight)
1661
2413
  },
1662
-
1663
2414
  /**
1664
- * #getter
2415
+ * #action
1665
2416
  */
1666
- get adapterTrackModels(): BasicTrack[] {
1667
- const { rowHeight, MSA, hideGapsEffective, blanks } = self
1668
- const tracks = MSA?.tracks ?? []
1669
- return tracks
1670
- .filter(t => !!t.data)
1671
- .map(t => ({
1672
- model: {
1673
- ...t,
1674
- kind: 'text' as const,
1675
- data: hideGapsEffective ? skipBlanks(blanks, t.data!) : t.data,
1676
- height: rowHeight,
1677
- },
1678
- ReactComponent: TrackBlocks,
1679
- }))
2417
+ zoomOutHorizontal() {
2418
+ self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75))
2419
+ self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1680
2420
  },
1681
-
1682
2421
  /**
1683
- * #getter
1684
- * a data track's values or string, projected from its row's residues
1685
- * onto alignment columns when it names a row
2422
+ * #action
1686
2423
  */
1687
- get columnTrackContent() {
1688
- const { MSA, blanks, hideGapsEffective } = self
1689
- const width = MSA?.getWidth() ?? 0
1690
- const project = <T>(track: ColumnTrackSpec, items: T[], fill: T) => {
1691
- if (!track.row) {
1692
- return items
1693
- }
1694
- const out = Array.from({ length: width }, () => fill)
1695
- const index = self.seqPosGlobalColIndex.get(track.row)
1696
- items.forEach((item, seqPos) => {
1697
- const col = index?.[seqPos]
1698
- if (col !== undefined) {
1699
- out[col] = item
1700
- }
1701
- })
1702
- return out
1703
- }
1704
- const skip = <T>(items: T[]) =>
1705
- hideGapsEffective ? dropBlanks(blanks, items) : items
1706
- return new Map<string, { values?: number[]; data?: string }>(
1707
- self.columnTracks.map(track => {
1708
- if (track.kind === 'bar') {
1709
- const max = track.max ?? 1
1710
- const values = skip(project(track, track.values ?? [], 0)).map(
1711
- v => Math.min(1, Math.max(0, v / max)),
1712
- )
1713
- return [track.id, { values }] as const
1714
- }
1715
- const data = skip(project(track, (track.data ?? '').split(''), ' '))
1716
- return [track.id, { data: data.join('') }] as const
1717
- }),
1718
- )
2424
+ zoomInHorizontal() {
2425
+ self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
2426
+ self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1719
2427
  },
1720
2428
  /**
1721
- * #getter
2429
+ * #action
1722
2430
  */
1723
- get columnTrackModels(): BasicTrack[] {
1724
- return self.columnTracks.map(track => ({
1725
- model: {
1726
- id: track.id,
1727
- name: track.name,
1728
- kind: track.kind,
1729
- height:
1730
- track.height ??
1731
- (track.kind === 'bar'
1732
- ? self.conservationTrackHeight
1733
- : self.rowHeight),
1734
- barColor: track.color,
1735
- customColorScheme: track.colors,
1736
- data: this.columnTrackContent.get(track.id)?.data,
1737
- },
1738
- ReactComponent: TrackBlocks,
1739
- }))
2431
+ zoomInVertical() {
2432
+ self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5))
2433
+ },
2434
+ /**
2435
+ * #action
2436
+ */
2437
+ zoomOutVertical() {
2438
+ self.rowHeight = Math.max(
2439
+ minRowHeight,
2440
+ Math.floor(self.rowHeight * 0.75),
2441
+ )
1740
2442
  },
1741
2443
  /**
1742
- * #getter
2444
+ * #action
1743
2445
  */
1744
- get tracks(): BasicTrack[] {
1745
- const conservationTrack = {
1746
- id: 'conservation',
1747
- name: 'Conservation',
1748
- kind: 'bar' as const,
1749
- height: self.conservationTrackHeight,
1750
- barColor: 'gray',
1751
- }
1752
- const propertyConservationTrack = {
1753
- id: 'property-conservation',
1754
- name: 'Property conservation',
1755
- kind: 'bar' as const,
1756
- height: self.conservationTrackHeight,
1757
- barColor: '#6a51a3',
1758
- }
1759
- const sequenceLogoTrack = {
1760
- id: 'sequence-logo',
1761
- name: 'Sequence logo',
1762
- kind: 'logo' as const,
1763
- height: self.sequenceLogoTrackHeight,
1764
- }
1765
- return [
1766
- ...this.adapterTrackModels,
1767
- ...this.columnTrackModels,
1768
- ...[
1769
- conservationTrack,
1770
- ...(self.sequenceType === 'amino'
1771
- ? [propertyConservationTrack]
1772
- : []),
1773
- sequenceLogoTrack,
1774
- ].map(model => ({ model, ReactComponent: TrackBlocks })),
1775
- ]
2446
+ zoomIn() {
2447
+ transaction(() => {
2448
+ this.zoomInHorizontal()
2449
+ this.zoomInVertical()
2450
+ })
1776
2451
  },
1777
-
1778
2452
  /**
1779
- * #getter
2453
+ * #action
1780
2454
  */
1781
- get turnedOnTracks() {
1782
- return this.tracks.filter(
1783
- f => !trackIsOff(self.turnedOffTracks, f.model.id),
1784
- )
2455
+ zoomOut() {
2456
+ transaction(() => {
2457
+ this.zoomOutHorizontal()
2458
+ this.zoomOutVertical()
2459
+ })
1785
2460
  },
1786
-
1787
2461
  /**
1788
- * #getter
2462
+ * #action
2463
+ * Smoothly zoom by a continuous scaleFactor. The column under the cursor
2464
+ * (offsetX/offsetY, px relative to the MSA area) stays anchored
2465
+ * horizontally. Vertically the anchor is biased toward the top: when the
2466
+ * alignment nearly fits the viewport, snap to y=0 rather than pinning a
2467
+ * random row under the cursor, with the bias fading out as the alignment
2468
+ * grows taller than the viewport (where cursor-anchoring is useful).
2469
+ * Drives wheel/trackpad-pinch zoom.
1789
2470
  */
1790
- get showHorizontalScrollbar() {
1791
- return self.msaAreaWidth < self.totalWidth
1792
- },
2471
+ zoomToPos(scaleFactor: number, offsetX: number, offsetY: number) {
2472
+ transaction(() => {
2473
+ const colInView = (-self.scrollX + offsetX) / self.colWidth
2474
+ const rowInView = (-self.scrollY + offsetY) / self.rowHeight
2475
+ self.colWidth = clamp(
2476
+ self.colWidth * scaleFactor,
2477
+ minColWidth,
2478
+ maxCellSize,
2479
+ )
2480
+ self.rowHeight = clamp(
2481
+ self.rowHeight * scaleFactor,
2482
+ minRowHeight,
2483
+ maxCellSize,
2484
+ )
2485
+ self.scrollX = clamp(
2486
+ offsetX - colInView * self.colWidth,
2487
+ self.maxScrollX,
2488
+ 0,
2489
+ )
1793
2490
 
2491
+ const anchoredScrollY = offsetY - rowInView * self.rowHeight
2492
+ // maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
2493
+ // overflows, so -maxScrollY is exactly that overflow past the
2494
+ // scrollable MSA viewport (0 when it fits)
2495
+ const overflow = Math.max(0, -self.maxScrollY)
2496
+ const visibleHeight = self.totalHeight - overflow
2497
+ const topBias =
2498
+ visibleHeight > 0 ? clamp(1 - overflow / visibleHeight, 0, 1) : 1
2499
+ self.scrollY = clamp(
2500
+ anchoredScrollY * (1 - topBias),
2501
+ self.maxScrollY,
2502
+ 0,
2503
+ )
2504
+ })
2505
+ },
1794
2506
  /**
1795
- * #method
1796
- * Return a row-specific letter at a visible column, or undefined if gap.
1797
- *
1798
- * @param rowName - The name of the row
1799
- * @param visibleCol - The visible column index (what the user sees on screen)
1800
- * @returns The letter at that position, or undefined if it's a gap
2507
+ * #action
1801
2508
  */
1802
- visibleColToRowLetter(rowName: string, visibleCol: number) {
1803
- const { rowMap, blanks } = self
1804
- return rowMap.get(rowName)?.[visibleColToGlobalCol(blanks, visibleCol)]
2509
+ doScrollY(deltaY: number) {
2510
+ this.setScrollY(self.scrollY + deltaY)
1805
2511
  },
1806
2512
 
1807
2513
  /**
1808
- * #method
1809
- * Convert a visible column to a row-specific sequence position (0-based).
1810
- * Returns undefined if the position is a gap in the sequence.
1811
- *
1812
- * CROSS-REPO CONTRACT: this and the sibling coordinate converters
1813
- * (seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) are used
1814
- * by jbrowse-plugin-protein3d to translate between alignment columns and
1815
- * structure/sequence residue positions across gaps. Keep them stable.
1816
- *
1817
- * @param rowName - The name of the row
1818
- * @param visibleCol - The visible column index
1819
- * @returns The sequence position (0-based), or undefined if it's a gap
2514
+ * #action
2515
+ * set scroll Y-offset (px), clamped to keep the alignment in view
1820
2516
  */
1821
- visibleColToSeqPos(rowName: string, visibleCol: number) {
1822
- return visibleColToSeqPosForRow({
1823
- rowName,
1824
- visibleCol,
1825
- rowMap: self.rowMap,
1826
- blanks: self.blanks,
1827
- })
2517
+ setScrollY(n: number) {
2518
+ self.scrollY = clamp(n, self.maxScrollY, 0)
1828
2519
  },
1829
2520
 
1830
2521
  /**
1831
- * #method
1832
- * Convert a visible column to a row-specific sequence position (1-based).
1833
- * Returns undefined if the position is a gap in the sequence.
2522
+ * #action
2523
+ * Set the overlay annotations (an empty list clears them). Every source
2524
+ * funnels through here after its own adapter has flattened it:
2525
+ * InterProScan, GFF, user uploads, NCBI CDD.
1834
2526
  *
1835
- * @param rowName - The name of the row
1836
- * @param visibleCol - The visible column index
1837
- * @returns The sequence position (1-based), or undefined if it's a gap
2527
+ * It does not touch `showDomains`. Loading used to force the overlay on,
2528
+ * and since a restored snapshot loads its GFF again on the way in, a link
2529
+ * shared with the overlay hidden reopened with it drawn.
1838
2530
  */
1839
- visibleColToSeqPosOneBased(rowName: string, visibleCol: number) {
1840
- const val = this.visibleColToSeqPos(rowName, visibleCol)
1841
- return val !== undefined ? val + 1 : undefined
2531
+ setAnnotations(annotations: Annotation[]) {
2532
+ self.annotations = annotations
1842
2533
  },
1843
2534
 
1844
2535
  /**
1845
- * #method
1846
- * Convert a global column index to a visible column index.
1847
- * Returns undefined if the column is hidden (in blanks).
1848
- * This is the inverse of visibleColToGlobalCol.
1849
- *
1850
- * @param globalCol - The global column index in the full MSA
1851
- * @returns The visible column index, or undefined if the column is hidden
2536
+ * #action
2537
+ * set the overlay from raw InterProScan results keyed by row name. Kept
2538
+ * for downstream plugins that hold the EBI wire format; new code should
2539
+ * adapt to Annotation[] and call setAnnotations.
1852
2540
  */
1853
- globalColToVisibleCol(globalCol: number) {
1854
- const { blanks, hideGapsEffective } = self
1855
- if (!hideGapsEffective) {
1856
- return globalCol
1857
- }
1858
- return globalColToVisibleCol(blanks, globalCol)
2541
+ setDomains(data?: Record<string, InterProScanResults>) {
2542
+ this.setAnnotations(data ? interProScanToAnnotations(data) : [])
2543
+ },
2544
+
2545
+ applyGFFText(gffText: string) {
2546
+ this.setAnnotations(gffToAnnotations(parseGFF(gffText)))
1859
2547
  },
1860
2548
 
1861
2549
  /**
1862
- * #method
1863
- * Convert a sequence position (ungapped) to a global column index.
1864
- *
1865
- * @param rowName - The name of the row
1866
- * @param seqPos - The sequence position (0-based, ungapped)
1867
- * @returns The global column index in the full MSA
2550
+ * #action
1868
2551
  */
1869
- seqPosToGlobalCol(rowName: string, seqPos: number) {
1870
- const seq = self.rowMap.get(rowName)
1871
- if (seq === undefined) {
1872
- return 0
1873
- }
1874
- const col = self.seqPosGlobalColIndex.get(rowName)?.[seqPos]
1875
- // past the end of the ungapped sequence: 0 for the degenerate all-gap
1876
- // row, otherwise one past the last column
1877
- return col ?? (seqPos === 0 ? 0 : seq.length)
2552
+ doScrollX(deltaX: number) {
2553
+ this.setScrollX(self.scrollX + deltaX)
1878
2554
  },
1879
2555
 
1880
2556
  /**
1881
- * #method
1882
- * Convert a sequence position (ungapped) directly to a visible column index.
1883
- * This combines seqPosToGlobalCol and globalColToVisibleCol.
1884
- *
1885
- * @param rowName - The name of the row
1886
- * @param seqPos - The sequence position (0-based, ungapped)
1887
- * @returns The visible column index, or undefined if the column is hidden
2557
+ * #action
1888
2558
  */
1889
- seqPosToVisibleCol(rowName: string, seqPos: number) {
1890
- const globalCol = this.seqPosToGlobalCol(rowName, seqPos)
1891
- return this.globalColToVisibleCol(globalCol)
2559
+ setScrollX(n: number) {
2560
+ self.scrollX = clamp(n, self.maxScrollX, 0)
1892
2561
  },
1893
- }))
1894
2562
 
1895
- .views(self => ({
1896
2563
  /**
1897
- * #getter
1898
- * widget width minus the tree area gives the space for the MSA
2564
+ * #action
1899
2565
  */
1900
- get msaAreaHeight() {
1901
- return (
1902
- self.height -
1903
- (self.showHorizontalScrollbar ? self.minimapHeight : 0) -
1904
- self.headerHeight
1905
- )
2566
+ setColumnTracks(tracks: ColumnTrackSpec[]) {
2567
+ self.columnTracks.replace(tracks)
1906
2568
  },
1907
2569
  /**
1908
- * #getter
1909
- * total height of track area (px)
2570
+ * #action
2571
+ * replace the alignment<->structure correspondence (see docs/layers.md)
1910
2572
  */
1911
- get totalTrackAreaHeight() {
1912
- return sum(self.turnedOnTracks.map(r => r.model.height))
2573
+ setResidueMappings(mappings: ResidueMapping[]) {
2574
+ self.residueMappings.replace(mappings)
1913
2575
  },
1914
2576
  /**
1915
- * one representative annotation per accession, which is what the legend,
1916
- * the filter dialog and the palettes key off
2577
+ * #action
1917
2578
  */
1918
- get annotationTypes() {
1919
- // first occurrence wins. The representative supplies only the name,
1920
- // description and -- for ordinal segments -- the start that orders
1921
- // them, and those agree across an accession's instances
1922
- const types = new Map<string, Annotation>()
1923
- for (const annot of self.annotations) {
1924
- if (!types.has(annot.accession)) {
1925
- types.set(annot.accession, annot)
1926
- }
1927
- }
1928
- return types
1929
- },
1930
- get filteredAnnotations() {
1931
- return self.annotations.filter(r =>
1932
- self.featureFilters.get(r.accession),
2579
+ toggleTrack(id: string) {
2580
+ // the stored value is "is off", so the current shown state is exactly
2581
+ // what the flipped entry should hold
2582
+ const defaultOff = self.MSA?.tracks.find(t => t.id === id)?.defaultOff
2583
+ self.turnedOffTracks.set(
2584
+ id,
2585
+ !trackIsOff(self.turnedOffTracks, id, defaultOff),
1933
2586
  )
1934
2587
  },
1935
- get annotationsByRow() {
1936
- return groupBy(this.filteredAnnotations, r => r.id)
1937
- },
1938
- }))
1939
- .views(self => ({
1940
2588
  /**
1941
- * #getter
2589
+ * #action
1942
2590
  */
1943
- get showVerticalScrollbar() {
1944
- return self.msaAreaHeight < self.totalHeight
2591
+ setStatus(status?: { msg: string; onCancel?: () => void }) {
2592
+ self.status = status
1945
2593
  },
1946
2594
  }))
1947
2595
  .views(self => ({
@@ -2023,7 +2671,7 @@ function stateModelFactory() {
2023
2671
  */
2024
2672
  get visibleDomainTypes() {
2025
2673
  return this.categoricalDomainTypes
2026
- .filter(d => self.featureFilters.get(d.accession))
2674
+ .filter(d => !self.turnedOffFeatures.get(d.accession))
2027
2675
  .toSorted((a, b) => a.start - b.start)
2028
2676
  },
2029
2677
 
@@ -2053,15 +2701,14 @@ function stateModelFactory() {
2053
2701
  // gap run -- and a residue whose column is itself hidden
2054
2702
  // collapses onto the neighbouring boundary instead of dropping
2055
2703
  // the band. A band whose every column is hidden spans nothing
2056
- // and is left out.
2057
- const startCol = visibleColsBefore(
2058
- blanks,
2059
- self.seqPosToGlobalCol(name, annotation.start - 1),
2060
- )
2061
- const endCol = visibleColsBefore(
2062
- blanks,
2063
- self.seqPosToGlobalCol(name, annotation.end - 1) + 1,
2064
- )
2704
+ // and is left out, as is one naming a row the alignment lacks.
2705
+ const start = self.seqPosToGlobalCol(name, annotation.start - 1)
2706
+ const end = self.seqPosToGlobalCol(name, annotation.end - 1)
2707
+ if (start === undefined || end === undefined) {
2708
+ return undefined
2709
+ }
2710
+ const startCol = visibleColsBefore(blanks, start)
2711
+ const endCol = visibleColsBefore(blanks, end + 1)
2065
2712
  return endCol > startCol
2066
2713
  ? { annotation, startCol, endCol }
2067
2714
  : undefined
@@ -2099,14 +2746,19 @@ function stateModelFactory() {
2099
2746
  * domain annotations under the mouse, hit-tested against the exact visible
2100
2747
  * column span each box is drawn at (so it matches the overlay across gaps)
2101
2748
  */
2102
- get mouseOverDomains() {
2749
+ get mouseOverDomains(): Annotation[] {
2103
2750
  const { mouseCol } = self
2104
2751
  const name = self.mouseOverRowName
2105
- return name !== undefined && mouseCol !== undefined
2106
- ? (this.domainBands.get(name) ?? [])
2107
- .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
2108
- .map(b => b.annotation)
2109
- : []
2752
+ if (name === undefined || mouseCol === undefined) {
2753
+ return noDomains
2754
+ }
2755
+ const hits = (this.domainBands.get(name) ?? [])
2756
+ .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
2757
+ .map(b => b.annotation)
2758
+ // the shared empty array, so moving the mouse across an alignment with
2759
+ // no annotations does not hand every canvas block a new value to
2760
+ // re-render on
2761
+ return hits.length > 0 ? hits : noDomains
2110
2762
  },
2111
2763
 
2112
2764
  /**
@@ -2166,41 +2818,46 @@ function stateModelFactory() {
2166
2818
  * is dropped. Row names that match no row are ignored.
2167
2819
  */
2168
2820
  get resolvedHighlights(): ResolvedHighlight[] {
2169
- const { blanks, rowNamesSet, rowMap } = self
2821
+ const { blanks, rowNamesSet, transientHighlights } = self
2170
2822
  const toVisible = (globalCol: number) => {
2171
2823
  const visible = self.globalColToVisibleCol(globalCol)
2172
2824
  return visible ?? visibleColsBefore(blanks, globalCol)
2173
2825
  }
2174
- return self.highlights.flatMap(
2175
- ({ row, rows, start, end, label, color }) => {
2176
- const base = { label, color }
2177
- if (rows) {
2178
- const rowIndices = rows
2179
- .map(name => rowNamesSet.get(name))
2180
- .filter(notEmpty)
2181
- return rowIndices.length ? [{ ...base, rowIndices }] : []
2182
- }
2183
- if (start === undefined || end === undefined) {
2826
+ // the document's own highlights first, then what each owner is showing
2827
+ // right now, so a hover draws over a persisted band rather than under it
2828
+ const all = [
2829
+ ...self.highlights,
2830
+ ...Object.values(transientHighlights).flat(),
2831
+ ]
2832
+ return all.flatMap(({ row, rows, start, end, label, color }) => {
2833
+ const base = { label, color }
2834
+ if (rows) {
2835
+ const rowIndices = rows
2836
+ .map(name => rowNamesSet.get(name))
2837
+ .filter(notEmpty)
2838
+ return rowIndices.length ? [{ ...base, rowIndices }] : []
2839
+ }
2840
+ if (start === undefined || end === undefined) {
2841
+ return []
2842
+ }
2843
+ let startGlobal = start - 1
2844
+ let endGlobal = end - 1
2845
+ if (row !== undefined) {
2846
+ const rowStart = self.seqPosToGlobalCol(row, start - 1)
2847
+ const rowEnd = self.seqPosToGlobalCol(row, end - 1)
2848
+ if (rowStart === undefined || rowEnd === undefined) {
2184
2849
  return []
2185
2850
  }
2186
- let startGlobal = start - 1
2187
- let endGlobal = end - 1
2188
- if (row !== undefined) {
2189
- if (!rowMap.has(row)) {
2190
- return []
2191
- }
2192
- startGlobal = self.seqPosToGlobalCol(row, start - 1)
2193
- endGlobal = self.seqPosToGlobalCol(row, end - 1)
2194
- }
2195
- const startCol = toVisible(startGlobal)
2196
- const endVisible = self.globalColToVisibleCol(endGlobal)
2197
- const endCol =
2198
- endVisible ?? visibleColsBefore(blanks, endGlobal) - 1
2199
- return startCol <= endCol
2200
- ? [{ ...base, startCol, endCol, rowIndices: [] }]
2201
- : []
2202
- },
2203
- )
2851
+ startGlobal = rowStart
2852
+ endGlobal = rowEnd
2853
+ }
2854
+ const startCol = toVisible(startGlobal)
2855
+ const endVisible = self.globalColToVisibleCol(endGlobal)
2856
+ const endCol = endVisible ?? visibleColsBefore(blanks, endGlobal) - 1
2857
+ return startCol <= endCol
2858
+ ? [{ ...base, startCol, endCol, rowIndices: [] }]
2859
+ : []
2860
+ })
2204
2861
  },
2205
2862
 
2206
2863
  /**
@@ -2264,12 +2921,24 @@ function stateModelFactory() {
2264
2921
  setConservationTrackHeight(arg: number) {
2265
2922
  self.conservationTrackHeight = arg
2266
2923
  },
2924
+ /**
2925
+ * #action
2926
+ */
2927
+ setColumnTrackHeight(id: string, height: number) {
2928
+ self.columnTrackHeights = { ...self.columnTrackHeights, [id]: height }
2929
+ },
2267
2930
  /**
2268
2931
  * #action
2269
2932
  */
2270
2933
  setSequenceLogoTrackHeight(arg: number) {
2271
2934
  self.sequenceLogoTrackHeight = arg
2272
2935
  },
2936
+ /**
2937
+ * #action
2938
+ */
2939
+ setArcTrackHeight(arg: number) {
2940
+ self.arcTrackHeight = arg
2941
+ },
2273
2942
  /**
2274
2943
  * #action
2275
2944
  * Return to the import form: every property off `preservedOnReset`
@@ -2278,6 +2947,9 @@ function stateModelFactory() {
2278
2947
  * reach are cleared by hand.
2279
2948
  */
2280
2949
  reset() {
2950
+ self.resetCount++
2951
+ self.clearWarnings()
2952
+ self.setStatus(undefined)
2281
2953
  applySnapshot(
2282
2954
  self,
2283
2955
  Object.fromEntries(
@@ -2289,6 +2961,7 @@ function stateModelFactory() {
2289
2961
  self.setError(undefined)
2290
2962
  self.setAnnotations([])
2291
2963
  self.setHighlightedColumns(undefined)
2964
+ self.transientHighlights = {}
2292
2965
  self.setMousePos(undefined, undefined)
2293
2966
  self.setMouseClickPos(undefined, undefined)
2294
2967
  self.setHoveredTreeNode(undefined)
@@ -2302,21 +2975,35 @@ function stateModelFactory() {
2302
2975
  const blob = new Blob([html], { type: 'image/svg+xml' })
2303
2976
  saveAs(blob, exportFileName(self.msaFilehandle, 'svg'))
2304
2977
  },
2305
- initFilter(arg: string) {
2306
- if (!self.featureFilters.has(arg)) {
2307
- self.featureFilters.set(arg, true)
2978
+ /**
2979
+ * #action
2980
+ * draw this annotation type, or stop drawing it. Only the "stop" is
2981
+ * recorded -- see `turnedOffFeatures`
2982
+ */
2983
+ setFilter(accession: string, shown: boolean) {
2984
+ if (shown) {
2985
+ self.turnedOffFeatures.delete(accession)
2986
+ } else {
2987
+ self.turnedOffFeatures.set(accession, true)
2308
2988
  }
2309
2989
  },
2310
- setFilter(arg: string, flag: boolean) {
2311
- self.featureFilters.set(arg, flag)
2312
- },
2313
2990
 
2314
2991
  /**
2315
2992
  * #action
2316
2993
  */
2317
2994
  fit() {
2318
- this.fitVertically()
2319
- this.fitHorizontally()
2995
+ // Each direction's viewport depends on the other's result: fitting the
2996
+ // rows while the columns still overflow measures against a height the
2997
+ // minimap is taking, and fitting the columns while the rows still
2998
+ // overflow measures against a width the vertical scrollbar is taking.
2999
+ // A second pass measures against the geometry the first pass produced,
3000
+ // which is the one the reader ends up looking at.
3001
+ transaction(() => {
3002
+ for (let pass = 0; pass < 2; pass++) {
3003
+ this.fitHorizontally()
3004
+ this.fitVertically()
3005
+ }
3006
+ })
2320
3007
  },
2321
3008
  /**
2322
3009
  * #action
@@ -2356,15 +3043,6 @@ function stateModelFactory() {
2356
3043
  self.setHighlightedColumns(self.highlightColumns)
2357
3044
  }
2358
3045
 
2359
- addDisposer(
2360
- self,
2361
- autorun(() => {
2362
- for (const key of self.annotationTypes.keys()) {
2363
- this.initFilter(key)
2364
- }
2365
- }),
2366
- )
2367
-
2368
3046
  // track the live device pixel ratio so canvas backing stores re-scale
2369
3047
  // when the window moves between monitors or the browser zooms. The
2370
3048
  // matchMedia query is pinned to the current ratio, so each change
@@ -2395,41 +3073,62 @@ function stateModelFactory() {
2395
3073
  * Every loader carries a generation guard: the filehandle can change
2396
3074
  * mid-fetch (a second file picked while the first is still in flight),
2397
3075
  * and the slower earlier request must not clobber the data, status, or
2398
- * loading flag belonging to the newer one.
3076
+ * loading flag belonging to the newer one. A superseded or cleared
3077
+ * request is also aborted, rather than left downloading a file nothing
3078
+ * is waiting for, and it gives back the status line it was writing --
3079
+ * a reset() mid-download used to leave "Downloading file" and a Cancel
3080
+ * button behind on the import form.
2399
3081
  *
2400
3082
  * `clearFilehandle` serves two purposes for the loaders that pass it.
2401
3083
  * A local file has no URL to refetch from, so the handle is dropped
2402
3084
  * once its bytes are in the model; and a fetch the user cancels drops
2403
3085
  * it too, returning the view to the import form rather than leaving a
2404
3086
  * stuck spinner.
3087
+ *
3088
+ * `what` names the layer in a failure message. An optional layer
3089
+ * (annotations, row metadata) that fails to load is a warning: it is
3090
+ * not worth replacing an alignment the reader is looking at with an
3091
+ * error screen over a decorative file.
2405
3092
  */
2406
3093
  const loadOnFilehandleChange = ({
3094
+ what,
2407
3095
  getFilehandle,
2408
3096
  onLoad,
2409
3097
  setLoading,
2410
3098
  clearFilehandle,
3099
+ optional = false,
2411
3100
  }: {
3101
+ what: string
2412
3102
  getFilehandle: () => FileLocationType | undefined
2413
3103
  onLoad: (text: string) => void
2414
3104
  setLoading?: (arg: boolean) => void
2415
3105
  clearFilehandle?: () => void
3106
+ optional?: boolean
2416
3107
  }) => {
2417
3108
  let generation = 0
3109
+ let cancelInFlight: (() => void) | undefined
3110
+ addDisposer(self, () => {
3111
+ cancelInFlight?.()
3112
+ })
2418
3113
  addDisposer(
2419
3114
  self,
2420
3115
  autorun(async () => {
2421
3116
  const filehandle = getFilehandle()
2422
- // a cleared filehandle bumps the generation too, so a reset()
2423
- // mid-download invalidates the in-flight fetch instead of letting
2424
- // its onLoad land on the emptied model. That also orphans the
2425
- // invalidated run's `finally`, so the loading flag is cleared
2426
- // here on its behalf
2427
3117
  const current = ++generation
3118
+ cancelInFlight?.()
3119
+ cancelInFlight = undefined
2428
3120
  if (!filehandle) {
2429
3121
  setLoading?.(false)
2430
3122
  return
2431
3123
  }
2432
- const isCurrent = () => current === generation
3124
+ const isCurrent = () => current === generation && isAlive(self)
3125
+ const controller = new AbortController()
3126
+ cancelInFlight = () => {
3127
+ controller.abort()
3128
+ if (isAlive(self)) {
3129
+ self.setStatus(undefined)
3130
+ }
3131
+ }
2433
3132
  try {
2434
3133
  setLoading?.(true)
2435
3134
  self.setError(undefined)
@@ -2440,6 +3139,7 @@ function stateModelFactory() {
2440
3139
  self.setStatus(status)
2441
3140
  }
2442
3141
  },
3142
+ { controller },
2443
3143
  )
2444
3144
  if (isCurrent()) {
2445
3145
  transaction(() => {
@@ -2455,11 +3155,16 @@ function stateModelFactory() {
2455
3155
  clearFilehandle?.()
2456
3156
  } else {
2457
3157
  console.error(e)
2458
- self.setError(e)
3158
+ if (optional) {
3159
+ self.addWarning(`The ${what} did not load: ${e}`)
3160
+ } else {
3161
+ self.setError(e)
3162
+ }
2459
3163
  }
2460
3164
  }
2461
3165
  } finally {
2462
3166
  if (isCurrent()) {
3167
+ cancelInFlight = undefined
2463
3168
  setLoading?.(false)
2464
3169
  }
2465
3170
  }
@@ -2468,6 +3173,7 @@ function stateModelFactory() {
2468
3173
  }
2469
3174
 
2470
3175
  loadOnFilehandleChange({
3176
+ what: 'tree',
2471
3177
  getFilehandle: () => self.treeFilehandle,
2472
3178
  onLoad: text => {
2473
3179
  self.setTree(text)
@@ -2483,13 +3189,20 @@ function stateModelFactory() {
2483
3189
  // treeMetadata is decorative and has no import-form step of its own, so
2484
3190
  // it keeps no loading flag and nothing to return to on cancel
2485
3191
  loadOnFilehandleChange({
3192
+ what: 'row metadata',
3193
+ optional: true,
2486
3194
  getFilehandle: () => self.treeMetadataFilehandle,
2487
3195
  onLoad: text => {
2488
3196
  self.setTreeMetadata(text)
2489
3197
  },
2490
3198
  })
2491
3199
 
2492
- // autorun parses inline gff text from data.gff
3200
+ // autorun parses inline gff text from data.gff. Text that goes away
3201
+ // takes its annotations with it -- setData with a new alignment and no
3202
+ // gff used to leave the previous file's annotations drawn over it --
3203
+ // while annotations a host set directly are left alone, which is why
3204
+ // this tracks what it applied instead of reading the current list
3205
+ let appliedGFF = false
2493
3206
  addDisposer(
2494
3207
  self,
2495
3208
  autorun(() => {
@@ -2497,19 +3210,29 @@ function stateModelFactory() {
2497
3210
  if (gffText) {
2498
3211
  try {
2499
3212
  self.applyGFFText(gffText)
3213
+ appliedGFF = true
2500
3214
  } catch (e) {
3215
+ // a malformed overlay is not worth replacing the alignment
3216
+ // with an error screen
2501
3217
  console.error(e)
2502
- self.setError(e)
3218
+ self.addWarning(`The annotations did not parse: ${e}`)
2503
3219
  }
3220
+ } else if (appliedGFF) {
3221
+ appliedGFF = false
3222
+ self.setAnnotations([])
2504
3223
  }
2505
3224
  }),
2506
3225
  )
2507
3226
 
2508
- // gffFilehandle carries overlay annotations
3227
+ // gffFilehandle carries overlay annotations. It loads into data.gff and
3228
+ // the autorun above parses it, so there is one parse path whether the
3229
+ // text arrived from a file or from a snapshot
2509
3230
  loadOnFilehandleChange({
3231
+ what: 'annotations',
3232
+ optional: true,
2510
3233
  getFilehandle: () => self.gffFilehandle,
2511
3234
  onLoad: text => {
2512
- self.applyGFFText(text)
3235
+ self.setGFF(text)
2513
3236
  },
2514
3237
  clearFilehandle: () => {
2515
3238
  self.setGFFFilehandle(undefined)
@@ -2517,6 +3240,7 @@ function stateModelFactory() {
2517
3240
  })
2518
3241
 
2519
3242
  loadOnFilehandleChange({
3243
+ what: 'alignment',
2520
3244
  getFilehandle: () => self.msaFilehandle,
2521
3245
  onLoad: text => {
2522
3246
  self.setMSA(text)
@@ -2580,15 +3304,27 @@ function stateModelFactory() {
2580
3304
  }),
2581
3305
  )
2582
3306
 
2583
- // autorun synchronizes treeWidth with treeAreaWidth
3307
+ // treeWidth trails the tree area, less whatever the labels take out of
3308
+ // it. A width that arrived with the snapshot is left alone until the
3309
+ // tree area itself moves: a host that opens a view with a narrow tree
3310
+ // beside wide labels -- jbrowse-plugin-msaview does, at treeWidth 100
3311
+ // in a 200px area -- had its value overwritten on the first frame, and
3312
+ // a restored session came back re-derived rather than as it was left.
3313
+ // A getter cannot do this: labelsWidth is measured off the leaves,
3314
+ // which are laid out against treeWidth.
3315
+ let pinnedAreaWidth =
3316
+ self.treeWidth === defaultTreeWidth ? undefined : self.treeAreaWidth
2584
3317
  addDisposer(
2585
3318
  self,
2586
3319
  autorun(() => {
3320
+ const areaWidth = self.treeAreaWidth
3321
+ const labelsWidth = self.labelsWidth
3322
+ if (pinnedAreaWidth === areaWidth) {
3323
+ return
3324
+ }
3325
+ pinnedAreaWidth = undefined
2587
3326
  self.setTreeWidth(
2588
- Math.max(
2589
- 50,
2590
- self.treeAreaWidth - self.labelsWidth - 10 - self.marginLeft,
2591
- ),
3327
+ Math.max(50, areaWidth - labelsWidth - 10 - self.marginLeft),
2592
3328
  )
2593
3329
  }),
2594
3330
  )