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/dist/model.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { clamp, groupBy, notEmpty, sum } from '@jbrowse/core/util';
2
2
  import { openLocation } from '@jbrowse/core/util/io';
3
3
  import { ElementId, FileLocation } from '@jbrowse/core/util/types/mst';
4
- import { addDisposer, applySnapshot, cast, getSnapshot, types, } from '@jbrowse/mobx-state-tree';
4
+ import { addDisposer, applySnapshot, cast, getSnapshot, isAlive, types, } from '@jbrowse/mobx-state-tree';
5
5
  import { colord } from 'colord';
6
6
  import { autorun, transaction } from 'mobx';
7
7
  import { generateNodeIds, gffToAnnotations, interProScanToAnnotations, parseEmfTree, parseGFF, parseMSA, parseNewick, } from 'msa-parsers';
@@ -10,12 +10,12 @@ import { clustalXColumnColors } from './clustalX.js';
10
10
  import colorSchemes from './colorSchemes.js';
11
11
  import { columnCountsFromRows, letterOfResidueSlot } from './columnCounts.js';
12
12
  import TrackBlocks from './components/tracks/TrackBlocks.js';
13
- import { defaultAllowedGappyness, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawMsaLetters, defaultHeight, defaultHideGaps, defaultRowHeight, defaultScrollX, defaultScrollY, defaultScrollZoom, defaultShowDomainLegend, defaultShowDomains, defaultSubFeatureRows, labelReferenceFontSize, maxCellSize, minColWidth, minLetterColWidth, minLetterRowHeight, minRowHeight, segmentFeatureTypes, segmentShades, } from './constants.js';
13
+ import { defaultAllowedGappyness, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawMsaLetters, defaultHeight, defaultHideGaps, defaultRowHeight, defaultScrollX, defaultScrollY, defaultScrollZoom, defaultShowDomainLegend, defaultShowDomains, defaultSubFeatureRows, defaultTreeWidth, labelReferenceFontSize, maxCellSize, maxInlineSnapshotBytes, maxNeighborJoiningRows, minColWidth, minLetterColWidth, minLetterRowHeight, minRowHeight, segmentFeatureTypes, segmentShades, } from './constants.js';
14
14
  import { createPaletteMap } from './createPaletteMap.js';
15
15
  import { exportFileName } from './exportFileName.js';
16
16
  import { fetchTextWithProgress, isAbortError } from './fetchUtils.js';
17
17
  import { flatToTree } from './flatToTree.js';
18
- import { calcDepthToLeaf, clusterLayout, collapse, collapsedSubtreeMaxLength, find, findMaxBranchLen, forEachDescendant, hierarchy, leaves, links, maxLength, setBrLength, sort, sum as hierarchySum, } from './hierarchy.js';
18
+ import { calcDepthToLeaf, clusterLayout, collapse, collapsedSubtreeMaxLength, find, forEachDescendant, hierarchy, leaves, maxLength, setBrLength, sort, sum as hierarchySum, } from './hierarchy.js';
19
19
  import { measureTextCanvas } from './measureTextCanvas.js';
20
20
  import { DataModelF } from './model/DataModel.js';
21
21
  import { DialogQueueSessionMixin } from './model/DialogQueue.js';
@@ -24,12 +24,13 @@ import { TreeModelF } from './model/treeModel.js';
24
24
  import { calculateNeighborJoiningTree } from './neighborJoining.js';
25
25
  import { parseAsn1 } from './parseAsn1.js';
26
26
  import { calculatePropertyConservation } from './propertyConservation.js';
27
- import { globalColToVisibleCol, visibleColToGlobalCol, visibleColToSeqPosForRow, visibleColsBefore, } from './rowCoordinateCalculations.js';
27
+ import { globalColToVisibleCol, seqPosOfGlobalCol, visibleColToGlobalCol, visibleColsBefore, } from './rowCoordinateCalculations.js';
28
28
  import { buildSeqPosIndex } from './seqPosToGlobalCol.js';
29
29
  import { maxBitsFor } from './sequenceLogo.js';
30
30
  import { stripDefault } from './stripDefault.js';
31
31
  import { computeRowInsertions, dropBlanks, len, skipBlanks, transform, } from './util.js';
32
32
  import { saveAs } from './vendor/fileSaver.js';
33
+ import { parseWuss } from './wuss.js';
33
34
  function parseTreeText(text) {
34
35
  if (text.startsWith('BioTreeContainer')) {
35
36
  return flatToTree(parseAsn1(text));
@@ -39,12 +40,19 @@ function parseTreeText(text) {
39
40
  // Tracks that start hidden. The sequence logo answers a narrower question than
40
41
  // conservation does and costs three times the vertical space, so it waits to be
41
42
  // asked for.
42
- const defaultOffTracks = new Set(['sequence-logo']);
43
+ const defaultOffTracks = new Set(['sequence-logo', 'position-ruler']);
44
+ // base-pair arcs: one color for the nested helices, one for a pseudoknot, whose
45
+ // whole point is that it crosses them
46
+ const HELIX_ARC = '#4e79a7';
47
+ const PSEUDOKNOT_ARC = '#e15759';
43
48
  // a data track over this size stays in the live model but leaves the snapshot,
44
49
  // the same rule DataModel applies to an inline document
45
- const maxColumnTrackSnapshotBytes = 50_000;
50
+ function columnTrackSizes(tracks) {
51
+ return (tracks ?? []).map(t => JSON.stringify(t).length);
52
+ }
46
53
  function smallColumnTracks(tracks) {
47
- const kept = tracks?.filter(t => JSON.stringify(t).length <= maxColumnTrackSnapshotBytes);
54
+ const sizes = columnTrackSizes(tracks);
55
+ const kept = tracks?.filter((_t, i) => sizes[i] <= maxInlineSnapshotBytes);
48
56
  return kept?.length ? { columnTracks: kept } : {};
49
57
  }
50
58
  /**
@@ -90,8 +98,38 @@ export const preservedOnReset = new Set([
90
98
  // until they touch that track, and then its value is whether the track is OFF.
91
99
  // Reading the default through this is what lets a track ship hidden without
92
100
  // writing an entry into every snapshot and shared URL.
93
- function trackIsOff(turnedOffTracks, id) {
94
- return turnedOffTracks.get(id) ?? defaultOffTracks.has(id);
101
+ // A track the file itself supplies says whether it starts hidden, since only
102
+ // the file knows how many of them there are: a Pfam seed carries a couple of
103
+ // #=GR lines, an Rfam family one per row.
104
+ function trackIsOff(turnedOffTracks, id, defaultOff) {
105
+ return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id));
106
+ }
107
+ // one array for every "nothing under the pointer", since a fresh [] is a fresh
108
+ // value to every observer of it
109
+ const noDomains = [];
110
+ // seqPos -> column indexes, per row, hung off the parse the rows came from so
111
+ // they are collected with it. A computed would rebuild every row's index on
112
+ // each miss, and rebuild all of them again whenever it was read outside a
113
+ // reactive context.
114
+ const seqPosIndexCache = new WeakMap();
115
+ // A segment asserts a 1:1 run, so its two sides have to be the same length.
116
+ // One that is not is malformed data, and the arithmetic below would answer
117
+ // anyway -- with a residue that is off by however much the sides disagree. Skip
118
+ // it, the same refusal an uncovered position gets.
119
+ function sameLength(segment) {
120
+ return (segment.rowEnd - segment.rowStart ===
121
+ segment.structEnd - segment.structStart);
122
+ }
123
+ // Does the content need a scrollbar? fit() divides the viewport by the row or
124
+ // column count and multiplies it back, so an exact fit lands a fraction of a
125
+ // pixel over -- enough for a `>` to answer yes and hand the reader a minimap or
126
+ // a scrollbar for half a pixel of nothing, which then shrinks the viewport and
127
+ // leaves a gap.
128
+ function overflows(content, viewport) {
129
+ return content - viewport > 0.5;
130
+ }
131
+ function inRanges(ranges, position) {
132
+ return !!ranges?.some(([start, end]) => position >= start && position <= end);
95
133
  }
96
134
  /**
97
135
  * #stateModel MsaView
@@ -236,6 +274,15 @@ function stateModelFactory() {
236
274
  * text track. See docs/layers.md
237
275
  */
238
276
  columnTracks: stripDefault(types.array(types.frozen()), []),
277
+ /**
278
+ * #property
279
+ * which residue of which structure each row's residues are, as data.
280
+ * The viewer cannot infer this -- matching a row to a structure by
281
+ * sequence equality fails for a tagged construct, a truncation or a
282
+ * subsequence row, and fails in the direction that looks like it
283
+ * worked -- so it arrives computed. See docs/layers.md
284
+ */
285
+ residueMappings: stripDefault(types.array(types.frozen()), []),
239
286
  /**
240
287
  * #property
241
288
  * data from the loaded tree/msa/treeMetadata, generally loaded by
@@ -248,8 +295,13 @@ function stateModelFactory() {
248
295
  }),
249
296
  /**
250
297
  * #property
298
+ * the user's explicit hide choices per annotation accession, keyed by
299
+ * accession with the value meaning "off", the same shape as
300
+ * `turnedOffTracks`. An accession the user has never touched is absent
301
+ * and drawn, so a file of two hundred domain types adds nothing to the
302
+ * shared URL until someone filters one out
251
303
  */
252
- featureFilters: stripDefault(types.map(types.boolean), {}),
304
+ turnedOffFeatures: stripDefault(types.map(types.boolean), {}),
253
305
  /**
254
306
  * #property
255
307
  */
@@ -340,6 +392,15 @@ function stateModelFactory() {
340
392
  * array of column indices to highlight
341
393
  */
342
394
  highlightedColumns: undefined,
395
+ /**
396
+ * #volatile
397
+ * transient highlights keyed by who asked for them. One slot cannot hold
398
+ * two sources -- a structure viewer's hover and a genome view's hover
399
+ * both want to point at a column, and with one slot whoever clears last
400
+ * erases the other's. Keyed by owner, each source adds and removes only
401
+ * its own. Not persisted: a hover is not part of the document.
402
+ */
403
+ transientHighlights: {},
343
404
  /**
344
405
  * #volatile
345
406
  */
@@ -348,6 +409,14 @@ function stateModelFactory() {
348
409
  * #volatile
349
410
  */
350
411
  conservationTrackHeight: 40,
412
+ /**
413
+ * #volatile
414
+ * heights of individual `columnTracks`, by track id. A data track is
415
+ * resized on its own: the shared per-kind heights below belong to the
416
+ * tracks the viewer computes, and dragging a data track's handle used to
417
+ * resize those instead.
418
+ */
419
+ columnTrackHeights: {},
351
420
  /**
352
421
  * #volatile
353
422
  * taller than the conservation track by default: the logo spends its
@@ -355,6 +424,10 @@ function stateModelFactory() {
355
424
  * one too short to identify
356
425
  */
357
426
  sequenceLogoTrackHeight: 80,
427
+ /**
428
+ * #volatile
429
+ */
430
+ arcTrackHeight: 50,
358
431
  /**
359
432
  * #volatile
360
433
  */
@@ -364,6 +437,29 @@ function stateModelFactory() {
364
437
  */
365
438
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
366
439
  error: undefined,
440
+ /**
441
+ * #volatile
442
+ * load problems the view carried on through: an optional layer that did
443
+ * not arrive, an overlay that did not parse. `error` is the other kind --
444
+ * it replaces the view, which is right for the alignment and wrong for a
445
+ * decorative file
446
+ */
447
+ warnings: [],
448
+ /**
449
+ * #volatile
450
+ * bumped by reset(). The React error boundary above the view keeps its
451
+ * caught error until it is remounted, so "Return to import form" did
452
+ * nothing after a render error until this became its key
453
+ */
454
+ resetCount: 0,
455
+ /**
456
+ * #volatile
457
+ * set by a host that restores the loaded documents by its own means --
458
+ * a jbrowse session that holds them, a page that refetches them on load.
459
+ * `unshareableData` then reports nothing, since what it warns about is a
460
+ * link that opens empty, and under such a host the link does not
461
+ */
462
+ hostCarriesData: false,
367
463
  /**
368
464
  * #volatile
369
465
  * overlay annotations drawn on the alignment, whatever their source.
@@ -435,13 +531,35 @@ function stateModelFactory() {
435
531
  setError(error) {
436
532
  self.error = error;
437
533
  },
534
+ /**
535
+ * #action
536
+ * report something the view survived: a layer that failed to load, a
537
+ * file that failed to parse
538
+ */
539
+ addWarning(warning) {
540
+ self.warnings = [...self.warnings, warning];
541
+ },
542
+ /**
543
+ * #action
544
+ */
545
+ clearWarnings() {
546
+ self.warnings = [];
547
+ },
548
+ /**
549
+ * #action
550
+ * declare that this host restores the loaded documents itself, which
551
+ * takes down the "Not in the link" warning. See `hostCarriesData`
552
+ */
553
+ setHostCarriesData(arg) {
554
+ self.hostCarriesData = arg;
555
+ },
438
556
  /**
439
557
  * #action
440
558
  * set mouse position (row, column) in the MSA
441
559
  *
442
- * CROSS-REPO CONTRACT: jbrowse-plugin-protein3d calls this (and reads the
443
- * `mouseCol` volatile) to sync MSA<->3D-structure hover. Keep the name and
444
- * signature stable; see that repo's ProteinToMsaHoverSync.tsx.
560
+ * PUBLIC API: a host drives this (and reads the `mouseCol` volatile) to
561
+ * sync the alignment's hover with a view of its own -- a genome view, a
562
+ * 3D structure. Keep the name and signature stable.
445
563
  */
446
564
  setMousePos(col, row) {
447
565
  self.mouseCol = col;
@@ -451,9 +569,9 @@ function stateModelFactory() {
451
569
  * #action
452
570
  * set highlighted columns
453
571
  *
454
- * CROSS-REPO CONTRACT: called by jbrowse-plugin-msaview
455
- * (afterCreateAutoruns.ts) to highlight alignment columns. It has no
456
- * in-repo caller, so do not flag it as dead code — it is public API.
572
+ * PUBLIC API: jbrowse-plugin-msaview calls this from its
573
+ * afterCreateAutoruns to highlight alignment columns, and MSAViewer
574
+ * passes its `highlightColumns` prop through it. Not dead code.
457
575
  */
458
576
  setHighlightedColumns(columns) {
459
577
  self.highlightedColumns = columns;
@@ -464,6 +582,29 @@ function stateModelFactory() {
464
582
  setHighlights(highlights) {
465
583
  self.highlights.replace(highlights);
466
584
  },
585
+ /**
586
+ * #action
587
+ * show `highlights` on behalf of `owner`, replacing whatever that owner
588
+ * showed before and leaving every other owner's alone. The object is
589
+ * replaced rather than mutated so one assignment is the observable
590
+ * change.
591
+ */
592
+ applyHighlight(owner, highlights) {
593
+ self.transientHighlights = {
594
+ ...self.transientHighlights,
595
+ [owner]: highlights,
596
+ };
597
+ },
598
+ /**
599
+ * #action
600
+ * drop what `owner` was showing, leaving every other owner's in place
601
+ */
602
+ clearHighlight(owner) {
603
+ if (owner in self.transientHighlights) {
604
+ const { [owner]: _dropped, ...rest } = self.transientHighlights;
605
+ self.transientHighlights = rest;
606
+ }
607
+ },
467
608
  /**
468
609
  * #action
469
610
  * toggle the annotation overlay on the alignment
@@ -508,10 +649,23 @@ function stateModelFactory() {
508
649
  },
509
650
  /**
510
651
  * #action
511
- *
652
+ * switch to another alignment of a multi-alignment file (Stockholm). The
653
+ * new alignment has its own rows and its own tree, so everything naming
654
+ * the old one's -- the collapsed node ids, the subtree in focus, the
655
+ * reference row, the scroll position -- goes with it
512
656
  */
513
657
  setCurrentAlignment(n) {
514
- self.currentAlignment = n;
658
+ if (n === self.currentAlignment) {
659
+ return;
660
+ }
661
+ transaction(() => {
662
+ self.currentAlignment = n;
663
+ self.collapsed.clear();
664
+ this.setShowOnly(undefined);
665
+ this.drawRelativeTo(undefined);
666
+ self.scrollX = 0;
667
+ self.scrollY = 0;
668
+ });
515
669
  },
516
670
  /**
517
671
  * #action
@@ -583,6 +737,17 @@ function stateModelFactory() {
583
737
  setTreeMetadata(result) {
584
738
  self.data.setTreeMetadata(result);
585
739
  },
740
+ /**
741
+ * #action
742
+ * keep the GFF text the way the alignment and the tree are kept, rather
743
+ * than only its parsed annotations. The annotations are volatile, so a
744
+ * file opened from disk used to leave no trace in the snapshot at all --
745
+ * not the text, and not the filehandle, which is cleared once a blob is
746
+ * read. An autorun parses this back into annotations.
747
+ */
748
+ setGFF(result) {
749
+ self.data.setGFF(result);
750
+ },
586
751
  }))
587
752
  .views(self => ({
588
753
  /**
@@ -605,6 +770,22 @@ function stateModelFactory() {
605
770
  get actuallyShowDomains() {
606
771
  return self.showDomains && self.annotations.length > 0;
607
772
  },
773
+ /**
774
+ * #getter
775
+ * whether this host brings the loaded documents back by means the
776
+ * snapshot cannot see, which is what decides whether `unshareableData`
777
+ * has anything to warn about.
778
+ *
779
+ * A simple host flips the `hostCarriesData` volatile. A host whose
780
+ * answer depends on how the view was opened overrides this getter in a
781
+ * `.views` block of its own composed model -- jbrowse-plugin-msaview's
782
+ * indexed-location views refetch from a URL the session holds, while its
783
+ * data-store views really are absent from a link someone pastes
784
+ * elsewhere. `unshareableData` reads it off `self`, so an override wins.
785
+ */
786
+ get hostRestoresData() {
787
+ return self.hostCarriesData;
788
+ },
608
789
  get viewInitialized() {
609
790
  return self.volatileWidth !== undefined;
610
791
  },
@@ -619,13 +800,6 @@ function stateModelFactory() {
619
800
  },
620
801
  }))
621
802
  .views(self => ({
622
- /**
623
- * #method
624
- * unused here, but can be used by derived classes to add extra items
625
- */
626
- extraViewMenuItems() {
627
- return [];
628
- },
629
803
  /**
630
804
  * #getter
631
805
  */
@@ -656,6 +830,46 @@ function stateModelFactory() {
656
830
  get noDomains() {
657
831
  return self.annotations.length === 0;
658
832
  },
833
+ /**
834
+ * #getter
835
+ * the loaded documents this view's own snapshot cannot carry, largest
836
+ * first. A file opened from disk or pasted in becomes inline text, and
837
+ * DataModel drops an inline document past `maxInlineSnapshotBytes`
838
+ * rather than put megabytes of sequence into a session or a URL.
839
+ *
840
+ * Dropping it is right. Dropping it silently is what makes a copied link
841
+ * open an empty viewer, so the header says so and the standalone app
842
+ * stops rewriting the address bar while this is non-empty. A document
843
+ * fetched from a URL never appears here whatever its size: the snapshot
844
+ * keeps the filehandle and refetches through it.
845
+ *
846
+ * Nothing is unshareable when the host restores the data by its own
847
+ * means (see `hostRestoresData`) -- inside a session that reloads these
848
+ * documents from somewhere the snapshot does not show, the warning is
849
+ * simply wrong.
850
+ */
851
+ get unshareableData() {
852
+ if (self.hostRestoresData) {
853
+ return [];
854
+ }
855
+ const { data } = self;
856
+ // a data track past the limit leaves the snapshot the same way an
857
+ // inline document does, and left unreported the same way too
858
+ const trackBytes = columnTrackSizes(self.columnTracks)
859
+ .filter(bytes => bytes > maxInlineSnapshotBytes)
860
+ .reduce((a, b) => a + b, 0);
861
+ return [
862
+ ['alignment', data.msa, self.msaFilehandle],
863
+ ['tree', data.tree, self.treeFilehandle],
864
+ ['annotations', data.gff, self.gffFilehandle],
865
+ ['row metadata', data.treeMetadata, self.treeMetadataFilehandle],
866
+ ]
867
+ .flatMap(([what, text, filehandle]) => !filehandle && text && text.length > maxInlineSnapshotBytes
868
+ ? [{ what, bytes: text.length }]
869
+ : [])
870
+ .concat(trackBytes ? [{ what: 'data tracks', bytes: trackBytes }] : [])
871
+ .sort((a, b) => b.bytes - a.bytes);
872
+ },
659
873
  menuItems() {
660
874
  return [];
661
875
  },
@@ -872,19 +1086,48 @@ function stateModelFactory() {
872
1086
  return this.leaves.length;
873
1087
  },
874
1088
  /**
875
- * #getter
876
- * per-row index of the global column holding each ungapped sequence
877
- * position, so seqPos -> column is a lookup rather than a scan of the row.
878
- * The domain overlay resolves thousands of these per redraw.
1089
+ * #method
1090
+ * index of the global column holding each ungapped sequence position of a
1091
+ * row, so seqPos -> column is a lookup rather than a scan. The domain
1092
+ * overlay resolves thousands of these per redraw.
1093
+ *
1094
+ * Built per row, on the row asked for: the first lookup used to index
1095
+ * every row in the alignment. The cache is keyed on the parse the rows
1096
+ * came from, so a new alignment brings a new one.
879
1097
  */
880
- get seqPosGlobalColIndex() {
881
- return new Map(this.rows.map(([name, seq]) => [name, buildSeqPosIndex(seq)]));
1098
+ seqPosIndex(rowName) {
1099
+ const MSA = this.MSA;
1100
+ if (!MSA) {
1101
+ return undefined;
1102
+ }
1103
+ let cache = seqPosIndexCache.get(MSA);
1104
+ if (!cache) {
1105
+ cache = new Map();
1106
+ seqPosIndexCache.set(MSA, cache);
1107
+ }
1108
+ let index = cache.get(rowName);
1109
+ if (!index) {
1110
+ const seq = MSA.getRow(rowName);
1111
+ if (!seq) {
1112
+ return undefined;
1113
+ }
1114
+ index = buildSeqPosIndex(seq);
1115
+ cache.set(rowName, index);
1116
+ }
1117
+ return index;
882
1118
  },
883
1119
  /**
884
1120
  * #getter
1121
+ * every sequence the alignment holds, keyed by row name, whatever the
1122
+ * tree currently shows. `rows` is the rows on screen; this is the rows
1123
+ * that exist, and every lookup about a named row goes through it --
1124
+ * collapsing a clade hides rows, it does not delete their sequence
885
1125
  */
886
1126
  get rowMap() {
887
- return new Map(this.rows);
1127
+ const MSA = this.MSA;
1128
+ return new Map(MSA?.getNames()
1129
+ .map(name => [name, MSA.getRow(name)])
1130
+ .filter(([, seq]) => !!seq));
888
1131
  },
889
1132
  /**
890
1133
  * #getter
@@ -1009,10 +1252,13 @@ function stateModelFactory() {
1009
1252
  get hierarchy() {
1010
1253
  const r = this.root;
1011
1254
  clusterLayout(r, this.totalHeight, self.treeWidth);
1012
- r.data.length = 0;
1013
- const max = maxLength(r);
1255
+ const max = this.rootToTipLength;
1014
1256
  const k = max ? self.treeWidth / max : 0;
1015
- setBrLength(r, 0, k);
1257
+ // the displayed root starts at x=0 whatever branch length it carries,
1258
+ // so its own length is subtracted here rather than zeroed on the parsed
1259
+ // node -- `root` hands out the cached parse, and writing to it made
1260
+ // showOnly shorten that branch for good
1261
+ setBrLength(r, -Math.max(r.data.length || 0, 0), k);
1016
1262
  // for each collapsed clade, record the pixel x-position of its farthest
1017
1263
  // tip so the renderer can draw a triangle spanning the branch-length
1018
1264
  // extent of the hidden subtree
@@ -1038,10 +1284,32 @@ function stateModelFactory() {
1038
1284
  },
1039
1285
  /**
1040
1286
  * #getter
1041
- * max branch length across the tree, used to scale phylogram x-positions
1287
+ * branch-length extent of the displayed tree, root to farthest tip, in
1288
+ * the tree's own units
1289
+ */
1290
+ get rootToTipLength() {
1291
+ const r = this.root;
1292
+ return maxLength(r) - Math.max(r.data.length || 0, 0);
1293
+ },
1294
+ /**
1295
+ * #getter
1296
+ * x-position of the farthest tip in a phylogram, px. The layout scales
1297
+ * the longest root-to-tip path onto treeWidth, so that is where it lands
1298
+ * -- and 0 for a tree carrying no lengths at all, which draws as a
1299
+ * cladogram instead
1042
1300
  */
1043
1301
  get maxBranchLength() {
1044
- return findMaxBranchLen(this.hierarchy);
1302
+ return this.rootToTipLength ? self.treeWidth : 0;
1303
+ },
1304
+ /**
1305
+ * #getter
1306
+ * pixels per unit of branch length in the current phylogram layout, and 0
1307
+ * in cladogram mode, where the x-positions carry no length at all. The
1308
+ * scale bar over the tree is drawn from it.
1309
+ */
1310
+ get pxPerBranchLength() {
1311
+ const max = maxLength(this.root);
1312
+ return this.showBranchLenEffective && max ? self.treeWidth / max : 0;
1045
1313
  },
1046
1314
  /**
1047
1315
  * #getter
@@ -1054,7 +1322,7 @@ function stateModelFactory() {
1054
1322
  * #getter
1055
1323
  */
1056
1324
  get allBranchesLength0() {
1057
- return links(this.hierarchy).every(s => !s.source.data.length);
1325
+ return this.rootToTipLength === 0;
1058
1326
  },
1059
1327
  /**
1060
1328
  * #getter
@@ -1076,605 +1344,905 @@ function stateModelFactory() {
1076
1344
  /**
1077
1345
  * #getter
1078
1346
  */
1079
- get dataInitialized() {
1080
- // truthiness, not `!== ''`: these are types.maybe, and DataModel's
1081
- // postProcessSnapshot drops a document over 50kb, so a restored session
1082
- // that inlined a large alignment comes back `undefined` here -- which
1083
- // `!== ''` reads as initialized and renders an empty view instead of
1084
- // the import form
1085
- return !!(self.data.msa || self.data.tree) && !self.error;
1347
+ get showMsaLetters() {
1348
+ return (self.drawMsaLetters &&
1349
+ self.rowHeight >= minLetterRowHeight &&
1350
+ self.colWidth >= minLetterColWidth &&
1351
+ self.colWidth > self.rowHeight / 2);
1086
1352
  },
1087
1353
  /**
1088
1354
  * #getter
1089
1355
  */
1090
- get blocksX() {
1091
- return calculateBlocks({
1092
- viewportSize: self.msaAreaWidth,
1093
- viewportPos: -self.scrollX,
1094
- blockSize: self.blockSize,
1095
- mapSize: self.totalWidth,
1096
- });
1356
+ get showTreeText() {
1357
+ return self.drawLabels && self.rowHeight >= minLetterRowHeight;
1097
1358
  },
1359
+ }))
1360
+ .views(self => ({
1098
1361
  /**
1099
1362
  * #getter
1100
1363
  */
1101
- get blocksY() {
1102
- return calculateBlocks({
1103
- viewportSize: this.visibleMsaHeight,
1104
- viewportPos: -self.scrollY,
1105
- blockSize: self.blockSize,
1106
- mapSize: self.totalHeight,
1107
- });
1364
+ get labelWidthMap() {
1365
+ const { showTreeText, leaves, treeMetadata } = self;
1366
+ // gated on the same condition the renderer draws labels under, so the
1367
+ // gutter labelsWidth reserves and the labels actually drawn cannot
1368
+ // disagree -- and so turning labels off hands their space to the tree.
1369
+ // Measured at a fixed reference size and scaled by labelWidthScale:
1370
+ // re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
1371
+ // 50k-leaf tree
1372
+ return showTreeText
1373
+ ? new Map(leaves.map(node => {
1374
+ const { name } = node.data;
1375
+ // `||`, matching renderTreeLabels: an empty genome falls back
1376
+ // to the row name, and measuring '' would size the gutter (and
1377
+ // the label's click target) to nothing
1378
+ const displayName = treeMetadata[name]?.genome || name;
1379
+ return [
1380
+ name,
1381
+ measureTextCanvas(displayName, labelReferenceFontSize),
1382
+ ];
1383
+ }))
1384
+ : new Map();
1108
1385
  },
1109
1386
  /**
1110
1387
  * #getter
1111
- * height of the alignment viewport, px. The same subtraction as
1112
- * msaAreaHeight, which is defined later in the views chain
1388
+ * factor turning a labelWidthMap entry into its width at the current
1389
+ * font size
1113
1390
  */
1114
- get visibleMsaHeight() {
1115
- return (self.height -
1116
- self.headerHeight -
1117
- (self.msaAreaWidth < self.totalWidth ? self.minimapHeight : 0));
1391
+ get labelWidthScale() {
1392
+ return self.fontSize / labelReferenceFontSize;
1393
+ },
1394
+ get labelsWidth() {
1395
+ // a loop, not Math.max(...widths.values()): spreading a map of every
1396
+ // leaf passes one argument per row, and the argument limit is somewhere
1397
+ // around 125k -- so the bundled 230k-tip COVID tree threw
1398
+ // "RangeError: Maximum call stack size exceeded" out of a getter the
1399
+ // treeWidth autorun reads on load
1400
+ let max = 0;
1401
+ for (const width of this.labelWidthMap.values()) {
1402
+ if (width > max) {
1403
+ max = width;
1404
+ }
1405
+ }
1406
+ return max * this.labelWidthScale;
1118
1407
  },
1119
- }))
1120
- .views(self => ({
1121
1408
  /**
1122
1409
  * #getter
1123
1410
  */
1124
- get blocks2d() {
1125
- return self.blocksY.flatMap(by => self.blocksX.map(bx => [bx, by]));
1411
+ get secondaryStructureConsensus() {
1412
+ return self.MSA?.secondaryStructureConsensus;
1126
1413
  },
1127
1414
  /**
1128
1415
  * #getter
1129
1416
  */
1130
- get isLoading() {
1131
- return self.loadingMSA || self.loadingTree;
1417
+ get seqConsensus() {
1418
+ return self.MSA?.seqConsensus;
1132
1419
  },
1133
1420
  /**
1134
1421
  * #getter
1422
+ * the base pairs of the consensus secondary structure, as arcs. The WUSS
1423
+ * string is collapsed through the hidden columns before it is parsed, so
1424
+ * the pairs land in the same visible column space the text track does
1135
1425
  */
1136
- get maxScrollX() {
1137
- return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0);
1426
+ get secondaryStructureArcs() {
1427
+ const { blanks, hideGapsEffective } = self;
1428
+ const ss = this.secondaryStructureConsensus;
1429
+ if (!ss) {
1430
+ return undefined;
1431
+ }
1432
+ return parseWuss(hideGapsEffective ? skipBlanks(blanks, ss) : ss).map(({ start, end, pseudoknot }) => ({
1433
+ start,
1434
+ end,
1435
+ color: pseudoknot ? PSEUDOKNOT_ARC : HELIX_ARC,
1436
+ }));
1138
1437
  },
1139
1438
  /**
1140
1439
  * #getter
1141
- * most-negative allowed scrollY, keeping the last row in view rather than
1142
- * letting the whole alignment scroll off the top.
1143
1440
  */
1144
- get maxScrollY() {
1145
- return Math.min(-self.totalHeight + self.visibleMsaHeight, 0);
1441
+ get adapterTrackModels() {
1442
+ const { MSA, hideGapsEffective, blanks } = self;
1443
+ const tracks = (MSA?.tracks ?? []).filter(t => !!t.data);
1444
+ if (tracks.length === 0) {
1445
+ // reading rowHeight up front made every zoom frame rebuild the track
1446
+ // list, and the canvases redraw on the track object they are handed
1447
+ // changing
1448
+ return [];
1449
+ }
1450
+ const { rowHeight } = self;
1451
+ return tracks.map(t => ({
1452
+ model: {
1453
+ ...t,
1454
+ kind: 'text',
1455
+ data: hideGapsEffective ? skipBlanks(blanks, t.data) : t.data,
1456
+ height: rowHeight,
1457
+ },
1458
+ ReactComponent: TrackBlocks,
1459
+ }));
1146
1460
  },
1147
1461
  /**
1148
1462
  * #getter
1463
+ * a data track's values or string, projected from its row's residues
1464
+ * onto alignment columns when it names a row
1149
1465
  */
1150
- get showMsaLetters() {
1151
- return (self.drawMsaLetters &&
1152
- self.rowHeight >= minLetterRowHeight &&
1153
- self.colWidth >= minLetterColWidth &&
1154
- self.colWidth > self.rowHeight / 2);
1466
+ get columnTrackContent() {
1467
+ const { MSA, blanks, hideGapsEffective } = self;
1468
+ const width = MSA?.getWidth() ?? 0;
1469
+ const project = (track, items, fill) => {
1470
+ if (!track.row) {
1471
+ return items;
1472
+ }
1473
+ const out = Array.from({ length: width }, () => fill);
1474
+ const index = self.seqPosIndex(track.row);
1475
+ items.forEach((item, seqPos) => {
1476
+ const col = index?.[seqPos];
1477
+ if (col !== undefined) {
1478
+ out[col] = item;
1479
+ }
1480
+ });
1481
+ return out;
1482
+ };
1483
+ const skip = (items) => hideGapsEffective ? dropBlanks(blanks, items) : items;
1484
+ // an arc names two positions rather than one per column, so it takes
1485
+ // the same two steps the arrays take -- a row's residues onto columns,
1486
+ // then columns onto the visible ones -- as a lookup. visibleColsBefore,
1487
+ // not globalColToVisibleCol: an endpoint in a hidden column collapses
1488
+ // to where that column went instead of taking the whole arc with it
1489
+ const resolve = (track, pos) => {
1490
+ const col = track.row
1491
+ ? self.seqPosIndex(track.row)?.[pos - 1]
1492
+ : pos - 1;
1493
+ if (col === undefined || col < 0 || col >= width) {
1494
+ return undefined;
1495
+ }
1496
+ return hideGapsEffective ? visibleColsBefore(blanks, col) : col;
1497
+ };
1498
+ return new Map(self.columnTracks.map(track => {
1499
+ if (track.kind === 'arc') {
1500
+ const arcs = (track.arcs ?? [])
1501
+ .map(arc => {
1502
+ const start = resolve(track, Math.min(arc.start, arc.end));
1503
+ const end = resolve(track, Math.max(arc.start, arc.end));
1504
+ return start !== undefined && end !== undefined && start < end
1505
+ ? { start, end, color: arc.color }
1506
+ : undefined;
1507
+ })
1508
+ .filter(notEmpty);
1509
+ return [track.id, { arcs }];
1510
+ }
1511
+ if (track.kind === 'bar') {
1512
+ const max = track.max ?? 1;
1513
+ const values = skip(project(track, track.values ?? [], 0)).map(v => Math.min(1, Math.max(0, v / max)));
1514
+ return [track.id, { values }];
1515
+ }
1516
+ const data = skip(project(track, (track.data ?? '').split(''), ' '));
1517
+ return [track.id, { data: data.join('') }];
1518
+ }));
1155
1519
  },
1156
1520
  /**
1157
1521
  * #getter
1158
1522
  */
1159
- get showTreeText() {
1160
- return self.drawLabels && self.rowHeight >= minLetterRowHeight;
1523
+ get columnTrackModels() {
1524
+ // read per kind, not up front: a text track is the only kind sized by
1525
+ // the row height, and reading it here rebuilt every data track on every
1526
+ // vertical zoom step
1527
+ const defaultHeight = (kind) => kind === 'bar'
1528
+ ? self.conservationTrackHeight
1529
+ : kind === 'arc'
1530
+ ? self.arcTrackHeight
1531
+ : self.rowHeight;
1532
+ return self.columnTracks.map(track => ({
1533
+ model: {
1534
+ id: track.id,
1535
+ name: track.name,
1536
+ kind: track.kind,
1537
+ height: self.columnTrackHeights[track.id] ??
1538
+ track.height ??
1539
+ defaultHeight(track.kind),
1540
+ // the spec has one `color`; bar and arc are separate track models
1541
+ // that read it under their own name
1542
+ barColor: track.color,
1543
+ arcColor: track.color,
1544
+ customColorScheme: track.colors,
1545
+ data: this.columnTrackContent.get(track.id)?.data,
1546
+ arcs: this.columnTrackContent.get(track.id)?.arcs,
1547
+ },
1548
+ ReactComponent: TrackBlocks,
1549
+ }));
1161
1550
  },
1162
- }))
1163
- .actions(self => ({
1164
1551
  /**
1165
- * #action
1552
+ * #getter
1166
1553
  */
1167
- setDrawMsaLetters(arg) {
1168
- self.drawMsaLetters = arg;
1169
- },
1170
1554
  /**
1171
- * #action
1555
+ * #getter
1556
+ * the consensus secondary structure as a track, when there is one. Its
1557
+ * own getter so the object keeps its identity across a zoom: the canvas
1558
+ * redraws on the track it is handed changing, and rebuilding these
1559
+ * alongside everything else made every zoom frame redraw every track
1172
1560
  */
1173
- setScrollZoom(arg) {
1174
- self.scrollZoom = arg;
1561
+ get basePairTrackModels() {
1562
+ const arcs = this.secondaryStructureArcs;
1563
+ return arcs?.length
1564
+ ? [
1565
+ {
1566
+ model: {
1567
+ id: 'base-pairs',
1568
+ name: 'Base pairs',
1569
+ kind: 'arc',
1570
+ height: self.arcTrackHeight,
1571
+ arcs,
1572
+ },
1573
+ ReactComponent: TrackBlocks,
1574
+ },
1575
+ ]
1576
+ : [];
1175
1577
  },
1176
1578
  /**
1177
- * #action
1178
- * set hovered tree node and its descendants
1579
+ * #getter
1580
+ * the tracks computed from the alignment itself, which depend on their
1581
+ * own heights and on the alphabet -- and on nothing zoom changes
1179
1582
  */
1180
- setHoveredTreeNode(nodeId) {
1181
- // the tree's mousemove handler calls this on every event, and both the
1182
- // lookup and the write are expensive: `find` walks the whole hierarchy,
1183
- // and a fresh object here invalidates hoveredRowIndices and redraws the
1184
- // tree and MSA overlays. Re-hovering the same node is the common case
1185
- if (nodeId === self.hoveredTreeNode?.nodeId) {
1186
- return;
1187
- }
1188
- if (!nodeId) {
1189
- self.hoveredTreeNode = undefined;
1190
- return;
1191
- }
1192
- const node = find(self.hierarchy, n => n.data.id === nodeId);
1193
- self.hoveredTreeNode = node
1194
- ? {
1195
- nodeId,
1196
- descendantNames: leaves(node).map(leaf => leaf.data.name),
1197
- }
1198
- : undefined;
1583
+ get computedTrackModels() {
1584
+ return [
1585
+ {
1586
+ id: 'conservation',
1587
+ name: 'Conservation',
1588
+ kind: 'bar',
1589
+ height: self.conservationTrackHeight,
1590
+ barColor: 'gray',
1591
+ },
1592
+ ...(self.sequenceType === 'amino'
1593
+ ? [
1594
+ {
1595
+ id: 'property-conservation',
1596
+ name: 'Property conservation',
1597
+ kind: 'bar',
1598
+ height: self.conservationTrackHeight,
1599
+ barColor: '#6a51a3',
1600
+ },
1601
+ ]
1602
+ : []),
1603
+ {
1604
+ id: 'sequence-logo',
1605
+ name: 'Sequence logo',
1606
+ kind: 'logo',
1607
+ height: self.sequenceLogoTrackHeight,
1608
+ },
1609
+ // last, so it sits against the alignment it numbers
1610
+ {
1611
+ id: 'position-ruler',
1612
+ name: 'Position',
1613
+ kind: 'ruler',
1614
+ height: 20,
1615
+ },
1616
+ ].map(model => ({ model, ReactComponent: TrackBlocks }));
1617
+ },
1618
+ get tracks() {
1619
+ return [
1620
+ ...this.adapterTrackModels,
1621
+ ...this.basePairTrackModels,
1622
+ ...this.columnTrackModels,
1623
+ ...this.computedTrackModels,
1624
+ ];
1199
1625
  },
1200
1626
  /**
1201
- * #action
1202
- * Calculate a neighbor joining tree from the current MSA using BLOSUM62 distances
1627
+ * #getter
1203
1628
  */
1204
- calculateNeighborJoiningTreeFromMSA() {
1205
- if (self.rows.length < 2) {
1206
- throw new Error('Need at least 2 sequences to build a tree');
1207
- }
1208
- const newickTree = calculateNeighborJoiningTree(self.rows);
1209
- self.setTree(newickTree);
1629
+ get turnedOnTracks() {
1630
+ return this.tracks.filter(f => !trackIsOff(self.turnedOffTracks, f.model.id, f.model.defaultOff));
1210
1631
  },
1211
1632
  /**
1212
- * #action
1213
- * restore the default column width and row height
1633
+ * #getter
1214
1634
  */
1215
- resetZoom() {
1216
- self.setColWidth(defaultColWidth);
1217
- self.setRowHeight(defaultRowHeight);
1635
+ get showHorizontalScrollbar() {
1636
+ return overflows(self.totalWidth, self.msaAreaWidth);
1218
1637
  },
1219
1638
  /**
1220
- * #action
1639
+ * #method
1640
+ * Return a row-specific letter at a visible column, or undefined if gap.
1641
+ *
1642
+ * @param rowName - The name of the row
1643
+ * @param visibleCol - The visible column index (what the user sees on screen)
1644
+ * @returns The letter at that position, or undefined if it's a gap
1221
1645
  */
1222
- zoomOutHorizontal() {
1223
- self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75));
1224
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1646
+ visibleColToRowLetter(rowName, visibleCol) {
1647
+ return self.rowMap.get(rowName)?.[this.visibleColToGlobalCol(visibleCol)];
1225
1648
  },
1226
1649
  /**
1227
- * #action
1650
+ * #method
1651
+ * Convert a visible column to a row-specific sequence position (0-based).
1652
+ * Returns undefined if the position is a gap in the sequence.
1653
+ *
1654
+ * PUBLIC API: this and the sibling coordinate converters
1655
+ * (visibleColToGlobalCol, seqPosToVisibleCol, globalColToVisibleCol,
1656
+ * seqPosToGlobalCol) are how a host translates between alignment columns
1657
+ * and a row's residue positions across gaps. Keep them stable.
1658
+ *
1659
+ * @param rowName - The name of the row
1660
+ * @param visibleCol - The visible column index
1661
+ * @returns The sequence position (0-based), or undefined if it's a gap
1228
1662
  */
1229
- zoomInHorizontal() {
1230
- self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5));
1231
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1663
+ visibleColToSeqPos(rowName, visibleCol) {
1664
+ // a binary search of the row's index, not a scan of the row: this
1665
+ // answers on every mouse move, and a 30k-column row scanned per event
1666
+ // is the whole frame
1667
+ return seqPosOfGlobalCol(self.seqPosIndex(rowName), this.visibleColToGlobalCol(visibleCol));
1232
1668
  },
1233
1669
  /**
1234
- * #action
1670
+ * #method
1671
+ * Convert a visible column to a row-specific sequence position (1-based).
1672
+ * Returns undefined if the position is a gap in the sequence.
1673
+ *
1674
+ * @param rowName - The name of the row
1675
+ * @param visibleCol - The visible column index
1676
+ * @returns The sequence position (1-based), or undefined if it's a gap
1235
1677
  */
1236
- zoomInVertical() {
1237
- self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5));
1678
+ visibleColToSeqPosOneBased(rowName, visibleCol) {
1679
+ const val = this.visibleColToSeqPos(rowName, visibleCol);
1680
+ return val !== undefined ? val + 1 : undefined;
1238
1681
  },
1239
1682
  /**
1240
- * #action
1683
+ * #method
1684
+ * Convert a global column index to a visible column index.
1685
+ * Returns undefined if the column is hidden (in blanks).
1686
+ * This is the inverse of visibleColToGlobalCol.
1687
+ *
1688
+ * @param globalCol - The global column index in the full MSA
1689
+ * @returns The visible column index, or undefined if the column is hidden
1241
1690
  */
1242
- zoomOutVertical() {
1243
- self.rowHeight = Math.max(minRowHeight, Math.floor(self.rowHeight * 0.75));
1691
+ globalColToVisibleCol(globalCol) {
1692
+ const { blanks, hideGapsEffective } = self;
1693
+ if (!hideGapsEffective) {
1694
+ return globalCol;
1695
+ }
1696
+ return globalColToVisibleCol(blanks, globalCol);
1244
1697
  },
1245
1698
  /**
1246
- * #action
1699
+ * #method
1700
+ * Convert a visible column index (what a mouse handler reports) back to a
1701
+ * column of the full alignment. Hidden columns shift everything to their
1702
+ * right, so a host that holds per-column data of its own has to make this
1703
+ * hop before indexing it.
1704
+ *
1705
+ * @param visibleCol - The visible column index
1706
+ * @returns The global column index in the full MSA
1247
1707
  */
1248
- zoomIn() {
1249
- transaction(() => {
1250
- this.zoomInHorizontal();
1251
- this.zoomInVertical();
1252
- });
1708
+ visibleColToGlobalCol(visibleCol) {
1709
+ const { blanks, hideGapsEffective } = self;
1710
+ return hideGapsEffective
1711
+ ? visibleColToGlobalCol(blanks, visibleCol)
1712
+ : visibleCol;
1253
1713
  },
1254
1714
  /**
1255
- * #action
1715
+ * #method
1716
+ * Convert a sequence position (ungapped) to a global column index.
1717
+ * Returns undefined for a row the alignment does not have -- answering
1718
+ * anyway is how a mistyped or stale row name came to highlight column 0.
1719
+ *
1720
+ * @param rowName - The name of the row
1721
+ * @param seqPos - The sequence position (0-based, ungapped)
1722
+ * @returns The global column index in the full MSA, or undefined
1256
1723
  */
1257
- zoomOut() {
1258
- transaction(() => {
1259
- this.zoomOutHorizontal();
1260
- this.zoomOutVertical();
1261
- });
1724
+ seqPosToGlobalCol(rowName, seqPos) {
1725
+ const seq = self.rowMap.get(rowName);
1726
+ if (seq === undefined) {
1727
+ return undefined;
1728
+ }
1729
+ const col = self.seqPosIndex(rowName)?.[seqPos];
1730
+ // past the end of the ungapped sequence: 0 for the degenerate all-gap
1731
+ // row, otherwise one past the last column
1732
+ return col ?? (seqPos === 0 ? 0 : seq.length);
1262
1733
  },
1263
1734
  /**
1264
- * #action
1265
- * Smoothly zoom by a continuous scaleFactor. The column under the cursor
1266
- * (offsetX/offsetY, px relative to the MSA area) stays anchored
1267
- * horizontally. Vertically the anchor is biased toward the top: when the
1268
- * alignment nearly fits the viewport, snap to y=0 rather than pinning a
1269
- * random row under the cursor, with the bias fading out as the alignment
1270
- * grows taller than the viewport (where cursor-anchoring is useful).
1271
- * Drives wheel/trackpad-pinch zoom.
1735
+ * #method
1736
+ * Convert a sequence position (ungapped) directly to a visible column index.
1737
+ * This combines seqPosToGlobalCol and globalColToVisibleCol.
1738
+ *
1739
+ * @param rowName - The name of the row
1740
+ * @param seqPos - The sequence position (0-based, ungapped)
1741
+ * @returns The visible column index, or undefined if the column is hidden
1272
1742
  */
1273
- zoomToPos(scaleFactor, offsetX, offsetY) {
1274
- transaction(() => {
1275
- const colInView = (-self.scrollX + offsetX) / self.colWidth;
1276
- const rowInView = (-self.scrollY + offsetY) / self.rowHeight;
1277
- self.colWidth = clamp(self.colWidth * scaleFactor, minColWidth, maxCellSize);
1278
- self.rowHeight = clamp(self.rowHeight * scaleFactor, minRowHeight, maxCellSize);
1279
- self.scrollX = clamp(offsetX - colInView * self.colWidth, self.maxScrollX, 0);
1280
- const anchoredScrollY = offsetY - rowInView * self.rowHeight;
1281
- // maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
1282
- // overflows, so -maxScrollY is exactly that overflow past the
1283
- // scrollable MSA viewport (0 when it fits)
1284
- const overflow = Math.max(0, -self.maxScrollY);
1285
- const visibleHeight = self.totalHeight - overflow;
1286
- const topBias = visibleHeight > 0 ? clamp(1 - overflow / visibleHeight, 0, 1) : 1;
1287
- self.scrollY = clamp(anchoredScrollY * (1 - topBias), self.maxScrollY, 0);
1288
- });
1743
+ seqPosToVisibleCol(rowName, seqPos) {
1744
+ const globalCol = this.seqPosToGlobalCol(rowName, seqPos);
1745
+ return globalCol === undefined
1746
+ ? undefined
1747
+ : this.globalColToVisibleCol(globalCol);
1748
+ },
1749
+ /**
1750
+ * #getter
1751
+ * every reason a mapping is being ignored, so a host can say which. A
1752
+ * mapping outlives the alignment it was computed for; when the two no
1753
+ * longer agree the lookups have to refuse, and refusing invisibly is how
1754
+ * "there is no structure here" gets confused with "this data is stale".
1755
+ */
1756
+ get residueMappingProblems() {
1757
+ const problems = [];
1758
+ for (const mapping of self.residueMappings) {
1759
+ const where = { row: mapping.row, structureId: mapping.structure.id };
1760
+ const residues = self.seqPosIndex(mapping.row)?.length;
1761
+ if (residues === undefined) {
1762
+ problems.push({
1763
+ ...where,
1764
+ scope: 'mapping',
1765
+ reason: 'no such row in the alignment',
1766
+ });
1767
+ continue;
1768
+ }
1769
+ if (mapping.rowLength !== undefined &&
1770
+ mapping.rowLength !== residues) {
1771
+ problems.push({
1772
+ ...where,
1773
+ scope: 'mapping',
1774
+ reason: `computed against a ${mapping.rowLength}-residue row; this one has ${residues}`,
1775
+ });
1776
+ continue;
1777
+ }
1778
+ const overrun = mapping.segments.find(segment => segment.rowEnd > residues || segment.rowStart < 1);
1779
+ if (overrun) {
1780
+ problems.push({
1781
+ ...where,
1782
+ scope: 'mapping',
1783
+ reason: `segment ${overrun.rowStart}-${overrun.rowEnd} does not fit a ${residues}-residue row`,
1784
+ });
1785
+ continue;
1786
+ }
1787
+ for (const segment of mapping.segments) {
1788
+ if (!sameLength(segment)) {
1789
+ problems.push({
1790
+ ...where,
1791
+ scope: 'segment',
1792
+ reason: `segment ${segment.rowStart}-${segment.rowEnd} maps to ${segment.structStart}-${segment.structEnd}, which is a different length`,
1793
+ });
1794
+ }
1795
+ }
1796
+ }
1797
+ return problems;
1289
1798
  },
1290
1799
  /**
1291
- * #action
1800
+ * #getter
1801
+ * the mappings that still fit the loaded alignment. A row-level problem
1802
+ * takes the whole mapping out; a single malformed segment takes only
1803
+ * itself, since the rest of the mapping is still a claim about residues
1804
+ * that exist.
1292
1805
  */
1293
- doScrollY(deltaY) {
1294
- this.setScrollY(self.scrollY + deltaY);
1806
+ get usableResidueMappings() {
1807
+ const unusable = new Set(this.residueMappingProblems
1808
+ .filter(problem => problem.scope === 'mapping')
1809
+ .map(problem => `${problem.row}\u0000${problem.structureId}`));
1810
+ return self.residueMappings
1811
+ .filter(m => !unusable.has(`${m.row}\u0000${m.structure.id}`))
1812
+ .map(m => ({
1813
+ ...m,
1814
+ segments: m.segments.filter(sameLength),
1815
+ }));
1295
1816
  },
1296
1817
  /**
1297
- * #action
1298
- * set scroll Y-offset (px), clamped to keep the alignment in view
1818
+ * #getter
1819
+ * the structures the loaded alignment has usable mappings onto. A row can
1820
+ * have several -- an experimental entry and a predicted model, say -- so
1821
+ * a host that means a particular one has to name it.
1299
1822
  */
1300
- setScrollY(n) {
1301
- self.scrollY = clamp(n, self.maxScrollY, 0);
1823
+ get mappedStructures() {
1824
+ return this.usableResidueMappings.map(m => ({
1825
+ row: m.row,
1826
+ structure: m.structure,
1827
+ }));
1302
1828
  },
1303
1829
  /**
1304
- * #action
1305
- * Set the overlay annotations and reveal the overlay in a single step (an
1306
- * empty list clears both). Every source funnels through here after its
1307
- * own adapter has flattened it: InterProScan, GFF, user uploads, NCBI CDD.
1308
- */
1309
- setAnnotations(annotations) {
1310
- self.annotations = annotations;
1311
- self.setShowDomains(annotations.length > 0);
1830
+ * #method
1831
+ * The structure residue a row residue is, or undefined. Refusing is the
1832
+ * point: the guess this replaces answered every query, with a wrong
1833
+ * residue when it did not know.
1834
+ *
1835
+ * It also refuses when the answer is not unique. A row commonly maps onto
1836
+ * several structures -- an experimental entry and two predicted models --
1837
+ * and returning whichever came first would be the same class of wrong,
1838
+ * quieter. Name one with `structureId`, or use `mappedStructures` to see
1839
+ * what there is.
1840
+ *
1841
+ * Positions are 1-based, as `residueMappings` and `highlights` are --
1842
+ * note that the column helpers above take 0-based ones.
1843
+ *
1844
+ * @param rowName - The alignment row
1845
+ * @param seqPos - Residue of that row, 1-based
1846
+ * @param structureId - Which structure, when the row maps onto several
1847
+ */
1848
+ structureResidue(rowName, seqPos, structureId) {
1849
+ const hits = [];
1850
+ for (const mapping of this.usableResidueMappings) {
1851
+ if (mapping.row !== rowName) {
1852
+ continue;
1853
+ }
1854
+ if (structureId !== undefined &&
1855
+ mapping.structure.id !== structureId) {
1856
+ continue;
1857
+ }
1858
+ const segment = mapping.segments.find(seg => seqPos >= seg.rowStart && seqPos <= seg.rowEnd);
1859
+ if (segment) {
1860
+ const position = segment.structStart + (seqPos - segment.rowStart);
1861
+ hits.push({
1862
+ structure: mapping.structure,
1863
+ position,
1864
+ observed: !inRanges(mapping.unobserved, position),
1865
+ });
1866
+ }
1867
+ }
1868
+ return hits.length === 1 ? hits[0] : undefined;
1312
1869
  },
1313
1870
  /**
1314
- * #action
1315
- * set the overlay from raw InterProScan results keyed by row name. Kept
1316
- * for downstream plugins that hold the EBI wire format; new code should
1317
- * adapt to Annotation[] and call setAnnotations.
1871
+ * #method
1872
+ * The row residue a structure residue is, the same lookup backwards, and
1873
+ * refusing on the same terms. `asymId` picks between mappings onto the
1874
+ * same entry, which a homodimer -- two rows, two chains, one id -- always
1875
+ * needs; without it such a lookup is ambiguous and gets nothing.
1876
+ *
1877
+ * @param structureId - The structure's id, as the mapping names it
1878
+ * @param position - Residue of that structure, 1-based label_seq_id
1879
+ * @param asymId - Which chain, when the id alone is ambiguous
1880
+ */
1881
+ rowResidue(structureId, position, asymId) {
1882
+ const hits = [];
1883
+ for (const mapping of this.usableResidueMappings) {
1884
+ if (mapping.structure.id !== structureId) {
1885
+ continue;
1886
+ }
1887
+ if (asymId !== undefined && mapping.structure.asymId !== asymId) {
1888
+ continue;
1889
+ }
1890
+ const segment = mapping.segments.find(seg => position >= seg.structStart && position <= seg.structEnd);
1891
+ if (segment) {
1892
+ hits.push({
1893
+ rowName: mapping.row,
1894
+ seqPos: segment.rowStart + (position - segment.structStart),
1895
+ });
1896
+ }
1897
+ }
1898
+ return hits.length === 1 ? hits[0] : undefined;
1899
+ },
1900
+ }))
1901
+ .views(self => ({
1902
+ /**
1903
+ * #getter
1904
+ * the vertical space the alignment rows actually get: the widget height
1905
+ * less everything stacked above and below them -- the header, the tracks,
1906
+ * and the minimap when the columns overflow. Every consumer wants this
1907
+ * same subtraction, so there is one of it: blocksY, maxScrollY, the
1908
+ * vertical scrollbar and fitVertically all read it, and a second getter
1909
+ * that forgot the tracks is what put the last rows out of reach.
1318
1910
  */
1319
- setDomains(data) {
1320
- this.setAnnotations(data ? interProScanToAnnotations(data) : []);
1911
+ get msaAreaHeight() {
1912
+ return (self.height -
1913
+ (self.showHorizontalScrollbar ? self.minimapHeight : 0) -
1914
+ self.headerHeight -
1915
+ this.totalTrackAreaHeight);
1321
1916
  },
1322
- applyGFFText(gffText) {
1323
- this.setAnnotations(gffToAnnotations(parseGFF(gffText)));
1917
+ /**
1918
+ * #getter
1919
+ * total height of track area (px)
1920
+ */
1921
+ get totalTrackAreaHeight() {
1922
+ return sum(self.turnedOnTracks.map(r => r.model.height));
1324
1923
  },
1325
1924
  /**
1326
- * #action
1925
+ * one representative annotation per accession, which is what the legend,
1926
+ * the filter dialog and the palettes key off
1327
1927
  */
1328
- doScrollX(deltaX) {
1329
- this.setScrollX(self.scrollX + deltaX);
1928
+ get annotationTypes() {
1929
+ // first occurrence wins. The representative supplies only the name,
1930
+ // description and -- for ordinal segments -- the start that orders
1931
+ // them, and those agree across an accession's instances
1932
+ const types = new Map();
1933
+ for (const annot of self.annotations) {
1934
+ if (!types.has(annot.accession)) {
1935
+ types.set(annot.accession, annot);
1936
+ }
1937
+ }
1938
+ return types;
1330
1939
  },
1940
+ get filteredAnnotations() {
1941
+ return self.annotations.filter(r => !self.turnedOffFeatures.get(r.accession));
1942
+ },
1943
+ get annotationsByRow() {
1944
+ return groupBy(this.filteredAnnotations, r => r.id);
1945
+ },
1946
+ }))
1947
+ .views(self => ({
1331
1948
  /**
1332
- * #action
1949
+ * #getter
1333
1950
  */
1334
- setScrollX(n) {
1335
- self.scrollX = clamp(n, self.maxScrollX, 0);
1951
+ get showVerticalScrollbar() {
1952
+ return overflows(self.totalHeight, self.msaAreaHeight);
1336
1953
  },
1954
+ }))
1955
+ .views(self => ({
1337
1956
  /**
1338
- * #action
1957
+ * #getter
1339
1958
  */
1340
- setColumnTracks(tracks) {
1341
- self.columnTracks.replace(tracks);
1959
+ get dataInitialized() {
1960
+ // truthiness, not `!== ''`: these are types.maybe, and DataModel's
1961
+ // postProcessSnapshot drops a document over 50kb, so a restored session
1962
+ // that inlined a large alignment comes back `undefined` here -- which
1963
+ // `!== ''` reads as initialized and renders an empty view instead of
1964
+ // the import form
1965
+ return !!(self.data.msa || self.data.tree) && !self.error;
1342
1966
  },
1343
1967
  /**
1344
- * #action
1968
+ * #getter
1345
1969
  */
1346
- toggleTrack(id) {
1347
- // the stored value is "is off", so the current shown state is exactly
1348
- // what the flipped entry should hold
1349
- self.turnedOffTracks.set(id, !trackIsOff(self.turnedOffTracks, id));
1970
+ get blocksX() {
1971
+ return calculateBlocks({
1972
+ viewportSize: self.msaAreaWidth,
1973
+ viewportPos: -self.scrollX,
1974
+ blockSize: self.blockSize,
1975
+ mapSize: self.totalWidth,
1976
+ });
1350
1977
  },
1351
1978
  /**
1352
- * #action
1979
+ * #getter
1353
1980
  */
1354
- setStatus(status) {
1355
- self.status = status;
1981
+ get blocksY() {
1982
+ return calculateBlocks({
1983
+ viewportSize: self.msaAreaHeight,
1984
+ viewportPos: -self.scrollY,
1985
+ blockSize: self.blockSize,
1986
+ mapSize: self.totalHeight,
1987
+ });
1356
1988
  },
1357
1989
  }))
1358
1990
  .views(self => ({
1359
1991
  /**
1360
1992
  * #getter
1361
1993
  */
1362
- get labelWidthMap() {
1363
- const { showTreeText, leaves, treeMetadata } = self;
1364
- // gated on the same condition the renderer draws labels under, so the
1365
- // gutter labelsWidth reserves and the labels actually drawn cannot
1366
- // disagree -- and so turning labels off hands their space to the tree.
1367
- // Measured at a fixed reference size and scaled by labelWidthScale:
1368
- // re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
1369
- // 50k-leaf tree
1370
- return showTreeText
1371
- ? new Map(leaves.map(node => {
1372
- const { name } = node.data;
1373
- // `||`, matching renderTreeLabels: an empty genome falls back
1374
- // to the row name, and measuring '' would size the gutter (and
1375
- // the label's click target) to nothing
1376
- const displayName = treeMetadata[name]?.genome || name;
1377
- return [
1378
- name,
1379
- measureTextCanvas(displayName, labelReferenceFontSize),
1380
- ];
1381
- }))
1382
- : new Map();
1994
+ get blocks2d() {
1995
+ return self.blocksY.flatMap(by => self.blocksX.map(bx => [bx, by]));
1383
1996
  },
1384
1997
  /**
1385
1998
  * #getter
1386
- * factor turning a labelWidthMap entry into its width at the current
1387
- * font size
1388
1999
  */
1389
- get labelWidthScale() {
1390
- return self.fontSize / labelReferenceFontSize;
1391
- },
1392
- get labelsWidth() {
1393
- // a loop, not Math.max(...widths.values()): spreading a map of every
1394
- // leaf passes one argument per row, and the argument limit is somewhere
1395
- // around 125k -- so the bundled 230k-tip COVID tree threw
1396
- // "RangeError: Maximum call stack size exceeded" out of a getter the
1397
- // treeWidth autorun reads on load
1398
- let max = 0;
1399
- for (const width of this.labelWidthMap.values()) {
1400
- if (width > max) {
1401
- max = width;
1402
- }
1403
- }
1404
- return max * this.labelWidthScale;
2000
+ get isLoading() {
2001
+ return self.loadingMSA || self.loadingTree;
1405
2002
  },
1406
2003
  /**
1407
2004
  * #getter
1408
2005
  */
1409
- get secondaryStructureConsensus() {
1410
- return self.MSA?.secondaryStructureConsensus;
2006
+ get maxScrollX() {
2007
+ return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0);
1411
2008
  },
1412
2009
  /**
1413
2010
  * #getter
2011
+ * most-negative allowed scrollY, keeping the last row in view rather than
2012
+ * letting the whole alignment scroll off the top.
1414
2013
  */
1415
- get seqConsensus() {
1416
- return self.MSA?.seqConsensus;
2014
+ get maxScrollY() {
2015
+ return Math.min(-self.totalHeight + self.msaAreaHeight, 0);
1417
2016
  },
2017
+ }))
2018
+ .actions(self => ({
1418
2019
  /**
1419
- * #getter
2020
+ * #action
1420
2021
  */
1421
- get adapterTrackModels() {
1422
- const { rowHeight, MSA, hideGapsEffective, blanks } = self;
1423
- const tracks = MSA?.tracks ?? [];
1424
- return tracks
1425
- .filter(t => !!t.data)
1426
- .map(t => ({
1427
- model: {
1428
- ...t,
1429
- kind: 'text',
1430
- data: hideGapsEffective ? skipBlanks(blanks, t.data) : t.data,
1431
- height: rowHeight,
1432
- },
1433
- ReactComponent: TrackBlocks,
1434
- }));
2022
+ setDrawMsaLetters(arg) {
2023
+ self.drawMsaLetters = arg;
1435
2024
  },
1436
2025
  /**
1437
- * #getter
1438
- * a data track's values or string, projected from its row's residues
1439
- * onto alignment columns when it names a row
2026
+ * #action
1440
2027
  */
1441
- get columnTrackContent() {
1442
- const { MSA, blanks, hideGapsEffective } = self;
1443
- const width = MSA?.getWidth() ?? 0;
1444
- const project = (track, items, fill) => {
1445
- if (!track.row) {
1446
- return items;
1447
- }
1448
- const out = Array.from({ length: width }, () => fill);
1449
- const index = self.seqPosGlobalColIndex.get(track.row);
1450
- items.forEach((item, seqPos) => {
1451
- const col = index?.[seqPos];
1452
- if (col !== undefined) {
1453
- out[col] = item;
1454
- }
1455
- });
1456
- return out;
1457
- };
1458
- const skip = (items) => hideGapsEffective ? dropBlanks(blanks, items) : items;
1459
- return new Map(self.columnTracks.map(track => {
1460
- if (track.kind === 'bar') {
1461
- const max = track.max ?? 1;
1462
- const values = skip(project(track, track.values ?? [], 0)).map(v => Math.min(1, Math.max(0, v / max)));
1463
- return [track.id, { values }];
2028
+ setScrollZoom(arg) {
2029
+ self.scrollZoom = arg;
2030
+ },
2031
+ /**
2032
+ * #action
2033
+ * set hovered tree node and its descendants
2034
+ */
2035
+ setHoveredTreeNode(nodeId) {
2036
+ // the tree's mousemove handler calls this on every event, and both the
2037
+ // lookup and the write are expensive: `find` walks the whole hierarchy,
2038
+ // and a fresh object here invalidates hoveredRowIndices and redraws the
2039
+ // tree and MSA overlays. Re-hovering the same node is the common case
2040
+ if (nodeId === self.hoveredTreeNode?.nodeId) {
2041
+ return;
2042
+ }
2043
+ if (!nodeId) {
2044
+ self.hoveredTreeNode = undefined;
2045
+ return;
2046
+ }
2047
+ const node = find(self.hierarchy, n => n.data.id === nodeId);
2048
+ self.hoveredTreeNode = node
2049
+ ? {
2050
+ nodeId,
2051
+ descendantNames: leaves(node).map(leaf => leaf.data.name),
1464
2052
  }
1465
- const data = skip(project(track, (track.data ?? '').split(''), ' '));
1466
- return [track.id, { data: data.join('') }];
1467
- }));
2053
+ : undefined;
2054
+ },
2055
+ /**
2056
+ * #action
2057
+ * Calculate a neighbor joining tree from the current MSA using BLOSUM62
2058
+ * distances. Refuses above `maxNeighborJoiningRows`: the join loop is
2059
+ * cubic and runs on the main thread, so 800 rows is a ten-second freeze
2060
+ * with no progress and no cancel, and a tree that size wants a tool built
2061
+ * for it anyway.
2062
+ */
2063
+ calculateNeighborJoiningTreeFromMSA() {
2064
+ // every sequence in the alignment, not the rows on screen: a collapsed
2065
+ // clade is a display state, and building the tree from what it leaves
2066
+ // showing drops the sequences it hides out of the result
2067
+ const rows = [...self.rowMap];
2068
+ if (rows.length < 2) {
2069
+ throw new Error('Need at least 2 sequences to build a tree');
2070
+ }
2071
+ if (rows.length > maxNeighborJoiningRows) {
2072
+ throw new Error(`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`);
2073
+ }
2074
+ this.replaceTree(calculateNeighborJoiningTree(rows));
1468
2075
  },
1469
2076
  /**
1470
- * #getter
2077
+ * #action
2078
+ * swap in a different tree over the same alignment. Node ids are derived
2079
+ * from the path (node-0-0-1), so a `collapsed` or `showOnly` id held over
2080
+ * from the old tree matches a real node in the new one and folds whatever
2081
+ * happens to sit there -- the ids go with the tree they name.
1471
2082
  */
1472
- get columnTrackModels() {
1473
- return self.columnTracks.map(track => ({
1474
- model: {
1475
- id: track.id,
1476
- name: track.name,
1477
- kind: track.kind,
1478
- height: track.height ??
1479
- (track.kind === 'bar'
1480
- ? self.conservationTrackHeight
1481
- : self.rowHeight),
1482
- barColor: track.color,
1483
- customColorScheme: track.colors,
1484
- data: this.columnTrackContent.get(track.id)?.data,
1485
- },
1486
- ReactComponent: TrackBlocks,
1487
- }));
2083
+ replaceTree(newick) {
2084
+ transaction(() => {
2085
+ self.collapsed.clear();
2086
+ self.setShowOnly(undefined);
2087
+ self.setTree(newick);
2088
+ });
1488
2089
  },
1489
2090
  /**
1490
- * #getter
2091
+ * #action
2092
+ * restore the default column width and row height
1491
2093
  */
1492
- get tracks() {
1493
- const conservationTrack = {
1494
- id: 'conservation',
1495
- name: 'Conservation',
1496
- kind: 'bar',
1497
- height: self.conservationTrackHeight,
1498
- barColor: 'gray',
1499
- };
1500
- const propertyConservationTrack = {
1501
- id: 'property-conservation',
1502
- name: 'Property conservation',
1503
- kind: 'bar',
1504
- height: self.conservationTrackHeight,
1505
- barColor: '#6a51a3',
1506
- };
1507
- const sequenceLogoTrack = {
1508
- id: 'sequence-logo',
1509
- name: 'Sequence logo',
1510
- kind: 'logo',
1511
- height: self.sequenceLogoTrackHeight,
1512
- };
1513
- return [
1514
- ...this.adapterTrackModels,
1515
- ...this.columnTrackModels,
1516
- ...[
1517
- conservationTrack,
1518
- ...(self.sequenceType === 'amino'
1519
- ? [propertyConservationTrack]
1520
- : []),
1521
- sequenceLogoTrack,
1522
- ].map(model => ({ model, ReactComponent: TrackBlocks })),
1523
- ];
2094
+ resetZoom() {
2095
+ self.setColWidth(defaultColWidth);
2096
+ self.setRowHeight(defaultRowHeight);
1524
2097
  },
1525
2098
  /**
1526
- * #getter
2099
+ * #action
1527
2100
  */
1528
- get turnedOnTracks() {
1529
- return this.tracks.filter(f => !trackIsOff(self.turnedOffTracks, f.model.id));
2101
+ zoomOutHorizontal() {
2102
+ self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75));
2103
+ self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1530
2104
  },
1531
2105
  /**
1532
- * #getter
2106
+ * #action
1533
2107
  */
1534
- get showHorizontalScrollbar() {
1535
- return self.msaAreaWidth < self.totalWidth;
2108
+ zoomInHorizontal() {
2109
+ self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5));
2110
+ self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1536
2111
  },
1537
2112
  /**
1538
- * #method
1539
- * Return a row-specific letter at a visible column, or undefined if gap.
1540
- *
1541
- * @param rowName - The name of the row
1542
- * @param visibleCol - The visible column index (what the user sees on screen)
1543
- * @returns The letter at that position, or undefined if it's a gap
2113
+ * #action
1544
2114
  */
1545
- visibleColToRowLetter(rowName, visibleCol) {
1546
- const { rowMap, blanks } = self;
1547
- return rowMap.get(rowName)?.[visibleColToGlobalCol(blanks, visibleCol)];
2115
+ zoomInVertical() {
2116
+ self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5));
1548
2117
  },
1549
2118
  /**
1550
- * #method
1551
- * Convert a visible column to a row-specific sequence position (0-based).
1552
- * Returns undefined if the position is a gap in the sequence.
1553
- *
1554
- * CROSS-REPO CONTRACT: this and the sibling coordinate converters
1555
- * (seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) are used
1556
- * by jbrowse-plugin-protein3d to translate between alignment columns and
1557
- * structure/sequence residue positions across gaps. Keep them stable.
1558
- *
1559
- * @param rowName - The name of the row
1560
- * @param visibleCol - The visible column index
1561
- * @returns The sequence position (0-based), or undefined if it's a gap
2119
+ * #action
1562
2120
  */
1563
- visibleColToSeqPos(rowName, visibleCol) {
1564
- return visibleColToSeqPosForRow({
1565
- rowName,
1566
- visibleCol,
1567
- rowMap: self.rowMap,
1568
- blanks: self.blanks,
2121
+ zoomOutVertical() {
2122
+ self.rowHeight = Math.max(minRowHeight, Math.floor(self.rowHeight * 0.75));
2123
+ },
2124
+ /**
2125
+ * #action
2126
+ */
2127
+ zoomIn() {
2128
+ transaction(() => {
2129
+ this.zoomInHorizontal();
2130
+ this.zoomInVertical();
1569
2131
  });
1570
2132
  },
1571
2133
  /**
1572
- * #method
1573
- * Convert a visible column to a row-specific sequence position (1-based).
1574
- * Returns undefined if the position is a gap in the sequence.
1575
- *
1576
- * @param rowName - The name of the row
1577
- * @param visibleCol - The visible column index
1578
- * @returns The sequence position (1-based), or undefined if it's a gap
2134
+ * #action
1579
2135
  */
1580
- visibleColToSeqPosOneBased(rowName, visibleCol) {
1581
- const val = this.visibleColToSeqPos(rowName, visibleCol);
1582
- return val !== undefined ? val + 1 : undefined;
2136
+ zoomOut() {
2137
+ transaction(() => {
2138
+ this.zoomOutHorizontal();
2139
+ this.zoomOutVertical();
2140
+ });
1583
2141
  },
1584
2142
  /**
1585
- * #method
1586
- * Convert a global column index to a visible column index.
1587
- * Returns undefined if the column is hidden (in blanks).
1588
- * This is the inverse of visibleColToGlobalCol.
1589
- *
1590
- * @param globalCol - The global column index in the full MSA
1591
- * @returns The visible column index, or undefined if the column is hidden
2143
+ * #action
2144
+ * Smoothly zoom by a continuous scaleFactor. The column under the cursor
2145
+ * (offsetX/offsetY, px relative to the MSA area) stays anchored
2146
+ * horizontally. Vertically the anchor is biased toward the top: when the
2147
+ * alignment nearly fits the viewport, snap to y=0 rather than pinning a
2148
+ * random row under the cursor, with the bias fading out as the alignment
2149
+ * grows taller than the viewport (where cursor-anchoring is useful).
2150
+ * Drives wheel/trackpad-pinch zoom.
1592
2151
  */
1593
- globalColToVisibleCol(globalCol) {
1594
- const { blanks, hideGapsEffective } = self;
1595
- if (!hideGapsEffective) {
1596
- return globalCol;
1597
- }
1598
- return globalColToVisibleCol(blanks, globalCol);
2152
+ zoomToPos(scaleFactor, offsetX, offsetY) {
2153
+ transaction(() => {
2154
+ const colInView = (-self.scrollX + offsetX) / self.colWidth;
2155
+ const rowInView = (-self.scrollY + offsetY) / self.rowHeight;
2156
+ self.colWidth = clamp(self.colWidth * scaleFactor, minColWidth, maxCellSize);
2157
+ self.rowHeight = clamp(self.rowHeight * scaleFactor, minRowHeight, maxCellSize);
2158
+ self.scrollX = clamp(offsetX - colInView * self.colWidth, self.maxScrollX, 0);
2159
+ const anchoredScrollY = offsetY - rowInView * self.rowHeight;
2160
+ // maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
2161
+ // overflows, so -maxScrollY is exactly that overflow past the
2162
+ // scrollable MSA viewport (0 when it fits)
2163
+ const overflow = Math.max(0, -self.maxScrollY);
2164
+ const visibleHeight = self.totalHeight - overflow;
2165
+ const topBias = visibleHeight > 0 ? clamp(1 - overflow / visibleHeight, 0, 1) : 1;
2166
+ self.scrollY = clamp(anchoredScrollY * (1 - topBias), self.maxScrollY, 0);
2167
+ });
1599
2168
  },
1600
2169
  /**
1601
- * #method
1602
- * Convert a sequence position (ungapped) to a global column index.
1603
- *
1604
- * @param rowName - The name of the row
1605
- * @param seqPos - The sequence position (0-based, ungapped)
1606
- * @returns The global column index in the full MSA
2170
+ * #action
1607
2171
  */
1608
- seqPosToGlobalCol(rowName, seqPos) {
1609
- const seq = self.rowMap.get(rowName);
1610
- if (seq === undefined) {
1611
- return 0;
1612
- }
1613
- const col = self.seqPosGlobalColIndex.get(rowName)?.[seqPos];
1614
- // past the end of the ungapped sequence: 0 for the degenerate all-gap
1615
- // row, otherwise one past the last column
1616
- return col ?? (seqPos === 0 ? 0 : seq.length);
2172
+ doScrollY(deltaY) {
2173
+ this.setScrollY(self.scrollY + deltaY);
1617
2174
  },
1618
2175
  /**
1619
- * #method
1620
- * Convert a sequence position (ungapped) directly to a visible column index.
1621
- * This combines seqPosToGlobalCol and globalColToVisibleCol.
2176
+ * #action
2177
+ * set scroll Y-offset (px), clamped to keep the alignment in view
2178
+ */
2179
+ setScrollY(n) {
2180
+ self.scrollY = clamp(n, self.maxScrollY, 0);
2181
+ },
2182
+ /**
2183
+ * #action
2184
+ * Set the overlay annotations (an empty list clears them). Every source
2185
+ * funnels through here after its own adapter has flattened it:
2186
+ * InterProScan, GFF, user uploads, NCBI CDD.
1622
2187
  *
1623
- * @param rowName - The name of the row
1624
- * @param seqPos - The sequence position (0-based, ungapped)
1625
- * @returns The visible column index, or undefined if the column is hidden
2188
+ * It does not touch `showDomains`. Loading used to force the overlay on,
2189
+ * and since a restored snapshot loads its GFF again on the way in, a link
2190
+ * shared with the overlay hidden reopened with it drawn.
1626
2191
  */
1627
- seqPosToVisibleCol(rowName, seqPos) {
1628
- const globalCol = this.seqPosToGlobalCol(rowName, seqPos);
1629
- return this.globalColToVisibleCol(globalCol);
2192
+ setAnnotations(annotations) {
2193
+ self.annotations = annotations;
1630
2194
  },
1631
- }))
1632
- .views(self => ({
1633
2195
  /**
1634
- * #getter
1635
- * widget width minus the tree area gives the space for the MSA
2196
+ * #action
2197
+ * set the overlay from raw InterProScan results keyed by row name. Kept
2198
+ * for downstream plugins that hold the EBI wire format; new code should
2199
+ * adapt to Annotation[] and call setAnnotations.
1636
2200
  */
1637
- get msaAreaHeight() {
1638
- return (self.height -
1639
- (self.showHorizontalScrollbar ? self.minimapHeight : 0) -
1640
- self.headerHeight);
2201
+ setDomains(data) {
2202
+ this.setAnnotations(data ? interProScanToAnnotations(data) : []);
2203
+ },
2204
+ applyGFFText(gffText) {
2205
+ this.setAnnotations(gffToAnnotations(parseGFF(gffText)));
1641
2206
  },
1642
2207
  /**
1643
- * #getter
1644
- * total height of track area (px)
2208
+ * #action
1645
2209
  */
1646
- get totalTrackAreaHeight() {
1647
- return sum(self.turnedOnTracks.map(r => r.model.height));
2210
+ doScrollX(deltaX) {
2211
+ this.setScrollX(self.scrollX + deltaX);
1648
2212
  },
1649
2213
  /**
1650
- * one representative annotation per accession, which is what the legend,
1651
- * the filter dialog and the palettes key off
2214
+ * #action
1652
2215
  */
1653
- get annotationTypes() {
1654
- // first occurrence wins. The representative supplies only the name,
1655
- // description and -- for ordinal segments -- the start that orders
1656
- // them, and those agree across an accession's instances
1657
- const types = new Map();
1658
- for (const annot of self.annotations) {
1659
- if (!types.has(annot.accession)) {
1660
- types.set(annot.accession, annot);
1661
- }
1662
- }
1663
- return types;
2216
+ setScrollX(n) {
2217
+ self.scrollX = clamp(n, self.maxScrollX, 0);
1664
2218
  },
1665
- get filteredAnnotations() {
1666
- return self.annotations.filter(r => self.featureFilters.get(r.accession));
2219
+ /**
2220
+ * #action
2221
+ */
2222
+ setColumnTracks(tracks) {
2223
+ self.columnTracks.replace(tracks);
1667
2224
  },
1668
- get annotationsByRow() {
1669
- return groupBy(this.filteredAnnotations, r => r.id);
2225
+ /**
2226
+ * #action
2227
+ * replace the alignment<->structure correspondence (see docs/layers.md)
2228
+ */
2229
+ setResidueMappings(mappings) {
2230
+ self.residueMappings.replace(mappings);
1670
2231
  },
1671
- }))
1672
- .views(self => ({
1673
2232
  /**
1674
- * #getter
2233
+ * #action
1675
2234
  */
1676
- get showVerticalScrollbar() {
1677
- return self.msaAreaHeight < self.totalHeight;
2235
+ toggleTrack(id) {
2236
+ // the stored value is "is off", so the current shown state is exactly
2237
+ // what the flipped entry should hold
2238
+ const defaultOff = self.MSA?.tracks.find(t => t.id === id)?.defaultOff;
2239
+ self.turnedOffTracks.set(id, !trackIsOff(self.turnedOffTracks, id, defaultOff));
2240
+ },
2241
+ /**
2242
+ * #action
2243
+ */
2244
+ setStatus(status) {
2245
+ self.status = status;
1678
2246
  },
1679
2247
  }))
1680
2248
  .views(self => ({
@@ -1746,7 +2314,7 @@ function stateModelFactory() {
1746
2314
  */
1747
2315
  get visibleDomainTypes() {
1748
2316
  return this.categoricalDomainTypes
1749
- .filter(d => self.featureFilters.get(d.accession))
2317
+ .filter(d => !self.turnedOffFeatures.get(d.accession))
1750
2318
  .toSorted((a, b) => a.start - b.start);
1751
2319
  },
1752
2320
  /**
@@ -1773,9 +2341,14 @@ function stateModelFactory() {
1773
2341
  // gap run -- and a residue whose column is itself hidden
1774
2342
  // collapses onto the neighbouring boundary instead of dropping
1775
2343
  // the band. A band whose every column is hidden spans nothing
1776
- // and is left out.
1777
- const startCol = visibleColsBefore(blanks, self.seqPosToGlobalCol(name, annotation.start - 1));
1778
- const endCol = visibleColsBefore(blanks, self.seqPosToGlobalCol(name, annotation.end - 1) + 1);
2344
+ // and is left out, as is one naming a row the alignment lacks.
2345
+ const start = self.seqPosToGlobalCol(name, annotation.start - 1);
2346
+ const end = self.seqPosToGlobalCol(name, annotation.end - 1);
2347
+ if (start === undefined || end === undefined) {
2348
+ return undefined;
2349
+ }
2350
+ const startCol = visibleColsBefore(blanks, start);
2351
+ const endCol = visibleColsBefore(blanks, end + 1);
1779
2352
  return endCol > startCol
1780
2353
  ? { annotation, startCol, endCol }
1781
2354
  : undefined;
@@ -1809,11 +2382,16 @@ function stateModelFactory() {
1809
2382
  get mouseOverDomains() {
1810
2383
  const { mouseCol } = self;
1811
2384
  const name = self.mouseOverRowName;
1812
- return name !== undefined && mouseCol !== undefined
1813
- ? (this.domainBands.get(name) ?? [])
1814
- .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
1815
- .map(b => b.annotation)
1816
- : [];
2385
+ if (name === undefined || mouseCol === undefined) {
2386
+ return noDomains;
2387
+ }
2388
+ const hits = (this.domainBands.get(name) ?? [])
2389
+ .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
2390
+ .map(b => b.annotation);
2391
+ // the shared empty array, so moving the mouse across an alignment with
2392
+ // no annotations does not hand every canvas block a new value to
2393
+ // re-render on
2394
+ return hits.length > 0 ? hits : noDomains;
1817
2395
  },
1818
2396
  /**
1819
2397
  * #getter
@@ -1868,12 +2446,18 @@ function stateModelFactory() {
1868
2446
  * is dropped. Row names that match no row are ignored.
1869
2447
  */
1870
2448
  get resolvedHighlights() {
1871
- const { blanks, rowNamesSet, rowMap } = self;
2449
+ const { blanks, rowNamesSet, transientHighlights } = self;
1872
2450
  const toVisible = (globalCol) => {
1873
2451
  const visible = self.globalColToVisibleCol(globalCol);
1874
2452
  return visible ?? visibleColsBefore(blanks, globalCol);
1875
2453
  };
1876
- return self.highlights.flatMap(({ row, rows, start, end, label, color }) => {
2454
+ // the document's own highlights first, then what each owner is showing
2455
+ // right now, so a hover draws over a persisted band rather than under it
2456
+ const all = [
2457
+ ...self.highlights,
2458
+ ...Object.values(transientHighlights).flat(),
2459
+ ];
2460
+ return all.flatMap(({ row, rows, start, end, label, color }) => {
1877
2461
  const base = { label, color };
1878
2462
  if (rows) {
1879
2463
  const rowIndices = rows
@@ -1887,11 +2471,13 @@ function stateModelFactory() {
1887
2471
  let startGlobal = start - 1;
1888
2472
  let endGlobal = end - 1;
1889
2473
  if (row !== undefined) {
1890
- if (!rowMap.has(row)) {
2474
+ const rowStart = self.seqPosToGlobalCol(row, start - 1);
2475
+ const rowEnd = self.seqPosToGlobalCol(row, end - 1);
2476
+ if (rowStart === undefined || rowEnd === undefined) {
1891
2477
  return [];
1892
2478
  }
1893
- startGlobal = self.seqPosToGlobalCol(row, start - 1);
1894
- endGlobal = self.seqPosToGlobalCol(row, end - 1);
2479
+ startGlobal = rowStart;
2480
+ endGlobal = rowEnd;
1895
2481
  }
1896
2482
  const startCol = toVisible(startGlobal);
1897
2483
  const endVisible = self.globalColToVisibleCol(endGlobal);
@@ -1961,12 +2547,24 @@ function stateModelFactory() {
1961
2547
  setConservationTrackHeight(arg) {
1962
2548
  self.conservationTrackHeight = arg;
1963
2549
  },
2550
+ /**
2551
+ * #action
2552
+ */
2553
+ setColumnTrackHeight(id, height) {
2554
+ self.columnTrackHeights = { ...self.columnTrackHeights, [id]: height };
2555
+ },
1964
2556
  /**
1965
2557
  * #action
1966
2558
  */
1967
2559
  setSequenceLogoTrackHeight(arg) {
1968
2560
  self.sequenceLogoTrackHeight = arg;
1969
2561
  },
2562
+ /**
2563
+ * #action
2564
+ */
2565
+ setArcTrackHeight(arg) {
2566
+ self.arcTrackHeight = arg;
2567
+ },
1970
2568
  /**
1971
2569
  * #action
1972
2570
  * Return to the import form: every property off `preservedOnReset`
@@ -1975,10 +2573,14 @@ function stateModelFactory() {
1975
2573
  * reach are cleared by hand.
1976
2574
  */
1977
2575
  reset() {
2576
+ self.resetCount++;
2577
+ self.clearWarnings();
2578
+ self.setStatus(undefined);
1978
2579
  applySnapshot(self, Object.fromEntries(Object.entries(getSnapshot(self)).filter(([key]) => preservedOnReset.has(key))));
1979
2580
  self.setError(undefined);
1980
2581
  self.setAnnotations([]);
1981
2582
  self.setHighlightedColumns(undefined);
2583
+ self.transientHighlights = {};
1982
2584
  self.setMousePos(undefined, undefined);
1983
2585
  self.setMouseClickPos(undefined, undefined);
1984
2586
  self.setHoveredTreeNode(undefined);
@@ -1992,20 +2594,35 @@ function stateModelFactory() {
1992
2594
  const blob = new Blob([html], { type: 'image/svg+xml' });
1993
2595
  saveAs(blob, exportFileName(self.msaFilehandle, 'svg'));
1994
2596
  },
1995
- initFilter(arg) {
1996
- if (!self.featureFilters.has(arg)) {
1997
- self.featureFilters.set(arg, true);
2597
+ /**
2598
+ * #action
2599
+ * draw this annotation type, or stop drawing it. Only the "stop" is
2600
+ * recorded -- see `turnedOffFeatures`
2601
+ */
2602
+ setFilter(accession, shown) {
2603
+ if (shown) {
2604
+ self.turnedOffFeatures.delete(accession);
2605
+ }
2606
+ else {
2607
+ self.turnedOffFeatures.set(accession, true);
1998
2608
  }
1999
- },
2000
- setFilter(arg, flag) {
2001
- self.featureFilters.set(arg, flag);
2002
2609
  },
2003
2610
  /**
2004
2611
  * #action
2005
2612
  */
2006
2613
  fit() {
2007
- this.fitVertically();
2008
- this.fitHorizontally();
2614
+ // Each direction's viewport depends on the other's result: fitting the
2615
+ // rows while the columns still overflow measures against a height the
2616
+ // minimap is taking, and fitting the columns while the rows still
2617
+ // overflow measures against a width the vertical scrollbar is taking.
2618
+ // A second pass measures against the geometry the first pass produced,
2619
+ // which is the one the reader ends up looking at.
2620
+ transaction(() => {
2621
+ for (let pass = 0; pass < 2; pass++) {
2622
+ this.fitHorizontally();
2623
+ this.fitVertically();
2624
+ }
2625
+ });
2009
2626
  },
2010
2627
  /**
2011
2628
  * #action
@@ -2035,11 +2652,6 @@ function stateModelFactory() {
2035
2652
  if (self.highlightColumns?.length) {
2036
2653
  self.setHighlightedColumns(self.highlightColumns);
2037
2654
  }
2038
- addDisposer(self, autorun(() => {
2039
- for (const key of self.annotationTypes.keys()) {
2040
- this.initFilter(key);
2041
- }
2042
- }));
2043
2655
  // track the live device pixel ratio so canvas backing stores re-scale
2044
2656
  // when the window moves between monitors or the browser zooms. The
2045
2657
  // matchMedia query is pinned to the current ratio, so each change
@@ -2066,29 +2678,46 @@ function stateModelFactory() {
2066
2678
  * Every loader carries a generation guard: the filehandle can change
2067
2679
  * mid-fetch (a second file picked while the first is still in flight),
2068
2680
  * and the slower earlier request must not clobber the data, status, or
2069
- * loading flag belonging to the newer one.
2681
+ * loading flag belonging to the newer one. A superseded or cleared
2682
+ * request is also aborted, rather than left downloading a file nothing
2683
+ * is waiting for, and it gives back the status line it was writing --
2684
+ * a reset() mid-download used to leave "Downloading file" and a Cancel
2685
+ * button behind on the import form.
2070
2686
  *
2071
2687
  * `clearFilehandle` serves two purposes for the loaders that pass it.
2072
2688
  * A local file has no URL to refetch from, so the handle is dropped
2073
2689
  * once its bytes are in the model; and a fetch the user cancels drops
2074
2690
  * it too, returning the view to the import form rather than leaving a
2075
2691
  * stuck spinner.
2692
+ *
2693
+ * `what` names the layer in a failure message. An optional layer
2694
+ * (annotations, row metadata) that fails to load is a warning: it is
2695
+ * not worth replacing an alignment the reader is looking at with an
2696
+ * error screen over a decorative file.
2076
2697
  */
2077
- const loadOnFilehandleChange = ({ getFilehandle, onLoad, setLoading, clearFilehandle, }) => {
2698
+ const loadOnFilehandleChange = ({ what, getFilehandle, onLoad, setLoading, clearFilehandle, optional = false, }) => {
2078
2699
  let generation = 0;
2700
+ let cancelInFlight;
2701
+ addDisposer(self, () => {
2702
+ cancelInFlight?.();
2703
+ });
2079
2704
  addDisposer(self, autorun(async () => {
2080
2705
  const filehandle = getFilehandle();
2081
- // a cleared filehandle bumps the generation too, so a reset()
2082
- // mid-download invalidates the in-flight fetch instead of letting
2083
- // its onLoad land on the emptied model. That also orphans the
2084
- // invalidated run's `finally`, so the loading flag is cleared
2085
- // here on its behalf
2086
2706
  const current = ++generation;
2707
+ cancelInFlight?.();
2708
+ cancelInFlight = undefined;
2087
2709
  if (!filehandle) {
2088
2710
  setLoading?.(false);
2089
2711
  return;
2090
2712
  }
2091
- const isCurrent = () => current === generation;
2713
+ const isCurrent = () => current === generation && isAlive(self);
2714
+ const controller = new AbortController();
2715
+ cancelInFlight = () => {
2716
+ controller.abort();
2717
+ if (isAlive(self)) {
2718
+ self.setStatus(undefined);
2719
+ }
2720
+ };
2092
2721
  try {
2093
2722
  setLoading?.(true);
2094
2723
  self.setError(undefined);
@@ -2096,7 +2725,7 @@ function stateModelFactory() {
2096
2725
  if (isCurrent()) {
2097
2726
  self.setStatus(status);
2098
2727
  }
2099
- });
2728
+ }, { controller });
2100
2729
  if (isCurrent()) {
2101
2730
  transaction(() => {
2102
2731
  onLoad(text);
@@ -2113,18 +2742,25 @@ function stateModelFactory() {
2113
2742
  }
2114
2743
  else {
2115
2744
  console.error(e);
2116
- self.setError(e);
2745
+ if (optional) {
2746
+ self.addWarning(`The ${what} did not load: ${e}`);
2747
+ }
2748
+ else {
2749
+ self.setError(e);
2750
+ }
2117
2751
  }
2118
2752
  }
2119
2753
  }
2120
2754
  finally {
2121
2755
  if (isCurrent()) {
2756
+ cancelInFlight = undefined;
2122
2757
  setLoading?.(false);
2123
2758
  }
2124
2759
  }
2125
2760
  }));
2126
2761
  };
2127
2762
  loadOnFilehandleChange({
2763
+ what: 'tree',
2128
2764
  getFilehandle: () => self.treeFilehandle,
2129
2765
  onLoad: text => {
2130
2766
  self.setTree(text);
@@ -2139,35 +2775,54 @@ function stateModelFactory() {
2139
2775
  // treeMetadata is decorative and has no import-form step of its own, so
2140
2776
  // it keeps no loading flag and nothing to return to on cancel
2141
2777
  loadOnFilehandleChange({
2778
+ what: 'row metadata',
2779
+ optional: true,
2142
2780
  getFilehandle: () => self.treeMetadataFilehandle,
2143
2781
  onLoad: text => {
2144
2782
  self.setTreeMetadata(text);
2145
2783
  },
2146
2784
  });
2147
- // autorun parses inline gff text from data.gff
2785
+ // autorun parses inline gff text from data.gff. Text that goes away
2786
+ // takes its annotations with it -- setData with a new alignment and no
2787
+ // gff used to leave the previous file's annotations drawn over it --
2788
+ // while annotations a host set directly are left alone, which is why
2789
+ // this tracks what it applied instead of reading the current list
2790
+ let appliedGFF = false;
2148
2791
  addDisposer(self, autorun(() => {
2149
2792
  const gffText = self.data.gff;
2150
2793
  if (gffText) {
2151
2794
  try {
2152
2795
  self.applyGFFText(gffText);
2796
+ appliedGFF = true;
2153
2797
  }
2154
2798
  catch (e) {
2799
+ // a malformed overlay is not worth replacing the alignment
2800
+ // with an error screen
2155
2801
  console.error(e);
2156
- self.setError(e);
2802
+ self.addWarning(`The annotations did not parse: ${e}`);
2157
2803
  }
2158
2804
  }
2805
+ else if (appliedGFF) {
2806
+ appliedGFF = false;
2807
+ self.setAnnotations([]);
2808
+ }
2159
2809
  }));
2160
- // gffFilehandle carries overlay annotations
2810
+ // gffFilehandle carries overlay annotations. It loads into data.gff and
2811
+ // the autorun above parses it, so there is one parse path whether the
2812
+ // text arrived from a file or from a snapshot
2161
2813
  loadOnFilehandleChange({
2814
+ what: 'annotations',
2815
+ optional: true,
2162
2816
  getFilehandle: () => self.gffFilehandle,
2163
2817
  onLoad: text => {
2164
- self.applyGFFText(text);
2818
+ self.setGFF(text);
2165
2819
  },
2166
2820
  clearFilehandle: () => {
2167
2821
  self.setGFFFilehandle(undefined);
2168
2822
  },
2169
2823
  });
2170
2824
  loadOnFilehandleChange({
2825
+ what: 'alignment',
2171
2826
  getFilehandle: () => self.msaFilehandle,
2172
2827
  onLoad: text => {
2173
2828
  self.setMSA(text);
@@ -2220,9 +2875,23 @@ function stateModelFactory() {
2220
2875
  self.setTreeAreaWidth(self.labelsWidth + self.marginLeft + 12);
2221
2876
  }
2222
2877
  }));
2223
- // autorun synchronizes treeWidth with treeAreaWidth
2878
+ // treeWidth trails the tree area, less whatever the labels take out of
2879
+ // it. A width that arrived with the snapshot is left alone until the
2880
+ // tree area itself moves: a host that opens a view with a narrow tree
2881
+ // beside wide labels -- jbrowse-plugin-msaview does, at treeWidth 100
2882
+ // in a 200px area -- had its value overwritten on the first frame, and
2883
+ // a restored session came back re-derived rather than as it was left.
2884
+ // A getter cannot do this: labelsWidth is measured off the leaves,
2885
+ // which are laid out against treeWidth.
2886
+ let pinnedAreaWidth = self.treeWidth === defaultTreeWidth ? undefined : self.treeAreaWidth;
2224
2887
  addDisposer(self, autorun(() => {
2225
- self.setTreeWidth(Math.max(50, self.treeAreaWidth - self.labelsWidth - 10 - self.marginLeft));
2888
+ const areaWidth = self.treeAreaWidth;
2889
+ const labelsWidth = self.labelsWidth;
2890
+ if (pinnedAreaWidth === areaWidth) {
2891
+ return;
2892
+ }
2893
+ pinnedAreaWidth = undefined;
2894
+ self.setTreeWidth(Math.max(50, areaWidth - labelsWidth - 10 - self.marginLeft));
2226
2895
  }));
2227
2896
  },
2228
2897
  }))