react-msaview 8.2.0 → 8.3.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 (192) hide show
  1. package/bundle/index.js +104 -117
  2. package/bundle/index.js.map +4 -4
  3. package/dist/columnCounts.d.ts +4 -3
  4. package/dist/columnCounts.js +11 -6
  5. package/dist/columnCounts.js.map +1 -1
  6. package/dist/components/CopyButton.d.ts +1 -1
  7. package/dist/components/CopyButton.js +1 -1
  8. package/dist/components/CopyButton.js.map +1 -1
  9. package/dist/components/DragHandle.js +14 -2
  10. package/dist/components/DragHandle.js.map +1 -1
  11. package/dist/components/Loading.js +10 -11
  12. package/dist/components/Loading.js.map +1 -1
  13. package/dist/components/MSAViewer.d.ts +12 -1
  14. package/dist/components/MSAViewer.js +12 -1
  15. package/dist/components/MSAViewer.js.map +1 -1
  16. package/dist/components/SequenceTextArea.d.ts +11 -0
  17. package/dist/components/SequenceTextArea.js +40 -8
  18. package/dist/components/SequenceTextArea.js.map +1 -1
  19. package/dist/components/StatusMessage.d.ts +12 -0
  20. package/dist/components/StatusMessage.js +43 -0
  21. package/dist/components/StatusMessage.js.map +1 -0
  22. package/dist/components/Track.js +3 -3
  23. package/dist/components/Track.js.map +1 -1
  24. package/dist/components/VerticalScrollbar.js +6 -1
  25. package/dist/components/VerticalScrollbar.js.map +1 -1
  26. package/dist/components/dialogs/ExportSVGDialog.d.ts +3 -2
  27. package/dist/components/dialogs/ExportSVGDialog.js +4 -2
  28. package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
  29. package/dist/components/dialogs/FeatureDialog.js +15 -10
  30. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  31. package/dist/components/getVisibleLeaves.d.ts +1 -1
  32. package/dist/components/header/FileMenu.js +9 -0
  33. package/dist/components/header/FileMenu.js.map +1 -1
  34. package/dist/components/header/GappynessSlider.js +15 -3
  35. package/dist/components/header/GappynessSlider.js.map +1 -1
  36. package/dist/components/header/Header.js +6 -7
  37. package/dist/components/header/Header.js.map +1 -1
  38. package/dist/components/header/{HeaderStatusArea.d.ts → RowSearch.d.ts} +2 -2
  39. package/dist/components/header/RowSearch.js +15 -0
  40. package/dist/components/header/RowSearch.js.map +1 -0
  41. package/dist/components/header/revealRow.d.ts +3 -0
  42. package/dist/components/header/revealRow.js +15 -0
  43. package/dist/components/header/revealRow.js.map +1 -0
  44. package/dist/components/header/settingsMenuItems.js +57 -1
  45. package/dist/components/header/settingsMenuItems.js.map +1 -1
  46. package/dist/components/import/ImportForm.js +10 -4
  47. package/dist/components/import/ImportForm.js.map +1 -1
  48. package/dist/components/import/ImportFormExamples.js +19 -27
  49. package/dist/components/import/ImportFormExamples.js.map +1 -1
  50. package/dist/components/minimap/Minimap.js +6 -1
  51. package/dist/components/minimap/Minimap.js.map +1 -1
  52. package/dist/components/msa/AnnotationLegend.js +11 -5
  53. package/dist/components/msa/AnnotationLegend.js.map +1 -1
  54. package/dist/components/msa/MSACanvas.js +6 -1
  55. package/dist/components/msa/MSACanvas.js.map +1 -1
  56. package/dist/components/msa/MSACanvasBlock.js +3 -7
  57. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  58. package/dist/components/msa/msaKeys.d.ts +5 -0
  59. package/dist/components/msa/msaKeys.js +35 -0
  60. package/dist/components/msa/msaKeys.js.map +1 -0
  61. package/dist/components/msa/useMsaBlockMouse.d.ts +2 -4
  62. package/dist/components/msa/useMsaBlockMouse.js +18 -5
  63. package/dist/components/msa/useMsaBlockMouse.js.map +1 -1
  64. package/dist/components/rowpanels/RowPanelBlock.js +10 -14
  65. package/dist/components/rowpanels/RowPanelBlock.js.map +1 -1
  66. package/dist/components/tracks/drawTracks.d.ts +1 -0
  67. package/dist/components/tracks/drawTracks.js +14 -2
  68. package/dist/components/tracks/drawTracks.js.map +1 -1
  69. package/dist/components/tracks/useTrackHover.d.ts +1 -5
  70. package/dist/components/tracks/useTrackHover.js +5 -5
  71. package/dist/components/tracks/useTrackHover.js.map +1 -1
  72. package/dist/components/tree/ScaleBarSVG.d.ts +14 -0
  73. package/dist/components/tree/ScaleBarSVG.js +11 -0
  74. package/dist/components/tree/ScaleBarSVG.js.map +1 -0
  75. package/dist/components/tree/TreeCanvas.js +8 -26
  76. package/dist/components/tree/TreeCanvas.js.map +1 -1
  77. package/dist/components/tree/TreeCanvasBlock.js +11 -20
  78. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  79. package/dist/components/tree/{TreeBranchMenu.d.ts → TreeMenu.d.ts} +5 -4
  80. package/dist/components/tree/TreeMenu.js +48 -0
  81. package/dist/components/tree/TreeMenu.js.map +1 -0
  82. package/dist/components/tree/TreeRuler.js +3 -3
  83. package/dist/components/tree/TreeRuler.js.map +1 -1
  84. package/dist/components/tree/renderTreeCanvas.js +19 -9
  85. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  86. package/dist/components/tree/renderTreeMouseover.d.ts +5 -0
  87. package/dist/components/tree/renderTreeMouseover.js +20 -0
  88. package/dist/components/tree/renderTreeMouseover.js.map +1 -0
  89. package/dist/components/tree/useTreeHover.d.ts +3 -8
  90. package/dist/components/tree/useTreeHover.js +8 -10
  91. package/dist/components/tree/useTreeHover.js.map +1 -1
  92. package/dist/components/useHoverAnchor.d.ts +19 -0
  93. package/dist/components/useHoverAnchor.js +18 -0
  94. package/dist/components/useHoverAnchor.js.map +1 -0
  95. package/dist/constants.d.ts +4 -1
  96. package/dist/constants.js +12 -3
  97. package/dist/constants.js.map +1 -1
  98. package/dist/expandSpec.js +3 -3
  99. package/dist/expandSpec.js.map +1 -1
  100. package/dist/exportFileName.d.ts +3 -3
  101. package/dist/exportFileName.js +4 -4
  102. package/dist/exportFileName.js.map +1 -1
  103. package/dist/hierarchy.d.ts +51 -0
  104. package/dist/hierarchy.js +125 -0
  105. package/dist/hierarchy.js.map +1 -1
  106. package/dist/index.d.ts +2 -2
  107. package/dist/model/treeModel.d.ts +12 -0
  108. package/dist/model/treeModel.js +14 -1
  109. package/dist/model/treeModel.js.map +1 -1
  110. package/dist/model.d.ts +168 -47
  111. package/dist/model.js +435 -192
  112. package/dist/model.js.map +1 -1
  113. package/dist/renderToSvg.js +2 -4
  114. package/dist/renderToSvg.js.map +1 -1
  115. package/dist/rerootTree.d.ts +33 -0
  116. package/dist/rerootTree.js +144 -0
  117. package/dist/rerootTree.js.map +1 -0
  118. package/dist/svgTestUtil.d.ts +106 -20
  119. package/dist/types.d.ts +25 -12
  120. package/dist/useMsaSvgFigure.d.ts +2 -1
  121. package/dist/useMsaSvgFigure.js +11 -4
  122. package/dist/useMsaSvgFigure.js.map +1 -1
  123. package/dist/version.d.ts +1 -1
  124. package/dist/version.js +1 -1
  125. package/dist/writeNewick.d.ts +8 -0
  126. package/dist/writeNewick.js +34 -0
  127. package/dist/writeNewick.js.map +1 -0
  128. package/package.json +3 -3
  129. package/src/columnCounts.ts +14 -6
  130. package/src/components/CopyButton.tsx +2 -2
  131. package/src/components/DragHandle.tsx +21 -1
  132. package/src/components/Loading.tsx +14 -30
  133. package/src/components/MSAViewer.tsx +26 -0
  134. package/src/components/SequenceTextArea.tsx +54 -8
  135. package/src/components/StatusMessage.tsx +70 -0
  136. package/src/components/Track.tsx +8 -4
  137. package/src/components/VerticalScrollbar.tsx +7 -0
  138. package/src/components/dialogs/ExportSVGDialog.tsx +5 -2
  139. package/src/components/dialogs/FeatureDialog.tsx +59 -38
  140. package/src/components/header/FileMenu.tsx +9 -0
  141. package/src/components/header/GappynessSlider.tsx +17 -4
  142. package/src/components/header/Header.tsx +7 -8
  143. package/src/components/header/RowSearch.tsx +34 -0
  144. package/src/components/header/revealRow.ts +17 -0
  145. package/src/components/header/settingsMenuItems.ts +66 -0
  146. package/src/components/import/ImportForm.tsx +14 -7
  147. package/src/components/import/ImportFormExamples.tsx +20 -28
  148. package/src/components/minimap/Minimap.tsx +7 -0
  149. package/src/components/msa/AnnotationLegend.tsx +12 -6
  150. package/src/components/msa/MSACanvas.tsx +8 -0
  151. package/src/components/msa/MSACanvasBlock.tsx +5 -11
  152. package/src/components/msa/msaKeys.ts +39 -0
  153. package/src/components/msa/useMsaBlockMouse.ts +23 -7
  154. package/src/components/rowpanels/RowPanelBlock.tsx +16 -23
  155. package/src/components/tracks/drawTracks.ts +15 -2
  156. package/src/components/tracks/useTrackHover.ts +5 -5
  157. package/src/components/tree/ScaleBarSVG.tsx +30 -0
  158. package/src/components/tree/TreeCanvas.tsx +9 -40
  159. package/src/components/tree/TreeCanvasBlock.tsx +19 -39
  160. package/src/components/tree/TreeMenu.tsx +129 -0
  161. package/src/components/tree/TreeRuler.tsx +7 -13
  162. package/src/components/tree/renderTreeCanvas.ts +20 -8
  163. package/src/components/tree/renderTreeMouseover.ts +41 -0
  164. package/src/components/tree/useTreeHover.ts +8 -17
  165. package/src/components/useHoverAnchor.ts +31 -0
  166. package/src/constants.ts +14 -3
  167. package/src/expandSpec.ts +3 -3
  168. package/src/exportFileName.ts +4 -3
  169. package/src/hierarchy.ts +151 -4
  170. package/src/index.ts +3 -1
  171. package/src/model/treeModel.ts +22 -0
  172. package/src/model.ts +534 -223
  173. package/src/renderToSvg.tsx +6 -9
  174. package/src/rerootTree.ts +172 -0
  175. package/src/types.ts +34 -12
  176. package/src/useMsaSvgFigure.ts +11 -4
  177. package/src/version.ts +1 -1
  178. package/src/writeNewick.ts +38 -0
  179. package/dist/components/PortalTooltip.d.ts +0 -11
  180. package/dist/components/PortalTooltip.js +0 -29
  181. package/dist/components/PortalTooltip.js.map +0 -1
  182. package/dist/components/header/HeaderStatusArea.js +0 -21
  183. package/dist/components/header/HeaderStatusArea.js.map +0 -1
  184. package/dist/components/tree/TreeBranchMenu.js +0 -29
  185. package/dist/components/tree/TreeBranchMenu.js.map +0 -1
  186. package/dist/components/tree/TreeNodeMenu.d.ts +0 -13
  187. package/dist/components/tree/TreeNodeMenu.js +0 -41
  188. package/dist/components/tree/TreeNodeMenu.js.map +0 -1
  189. package/src/components/PortalTooltip.tsx +0 -47
  190. package/src/components/header/HeaderStatusArea.tsx +0 -32
  191. package/src/components/tree/TreeBranchMenu.tsx +0 -68
  192. package/src/components/tree/TreeNodeMenu.tsx +0 -95
package/dist/model.js CHANGED
@@ -2,7 +2,7 @@ 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
4
  import { addDisposer, applySnapshot, cast, getSnapshot, isAlive, types, } from '@jbrowse/mobx-state-tree';
5
- import { autorun, computed, transaction } from 'mobx';
5
+ import { autorun, compareStructural, computed, reaction, transaction, } from 'mobx';
6
6
  import { generateNodeIds, gffToAnnotations, interProScanToAnnotations, parseEmfTree, parseGFF, parseMSA, parseNewick, } from 'msa-parsers';
7
7
  import { calculateBlocks } from './calculateBlocks.js';
8
8
  import { clustalXColumnColors } from './clustalX.js';
@@ -20,7 +20,7 @@ import { exportFileName } from './exportFileName.js';
20
20
  import { featureField, featureName } from './featureFields.js';
21
21
  import { fetchTextWithProgress, isAbortError } from './fetchUtils.js';
22
22
  import { flatToTree } from './flatToTree.js';
23
- import { calcDepthToLeaf, clusterLayout, collapse, collapsedSubtreeMaxLength, find, forEachDescendant, hierarchy, leafIndex, leaves, maxLength, mrca, nodeCoveringRows, setBrLength, sort, sum as hierarchySum, } from './hierarchy.js';
23
+ import { calcDepthToLeaf, clusterLayout, collapse, find, forEachDescendant, hierarchy, layoutTree, leafIndex, leaves, maxLength, mrca, nodeCoveringRows, setBrLength, sort, sum as hierarchySum, } from './hierarchy.js';
24
24
  import { measureTextCanvas } from './measureTextCanvas.js';
25
25
  import { DataModelF } from './model/DataModel.js';
26
26
  import { DialogQueueSessionMixin } from './model/DialogQueue.js';
@@ -29,6 +29,7 @@ import { TreeModelF } from './model/treeModel.js';
29
29
  import { calculateNeighborJoiningTree } from './neighborJoining.js';
30
30
  import { parseAsn1 } from './parseAsn1.js';
31
31
  import { calculatePropertyConservation } from './propertyConservation.js';
32
+ import { midpointRoot, rerootTree } from './rerootTree.js';
32
33
  import { globalColToVisibleCol, seqPosOfGlobalCol, visibleColToGlobalCol, visibleColsBefore, } from './rowCoordinateCalculations.js';
33
34
  import { resolveScale } from './scales.js';
34
35
  import { buildSeqPosIndex } from './seqPosToGlobalCol.js';
@@ -36,6 +37,7 @@ import { maxBitsFor } from './sequenceLogo.js';
36
37
  import { stripDefault } from './stripDefault.js';
37
38
  import { computeRowInsertions, dropBlanks, len, outlineColor, skipBlanks, withAlpha, } from './util.js';
38
39
  import { saveAs } from './vendor/fileSaver.js';
40
+ import { writeNewick } from './writeNewick.js';
39
41
  import { parseWuss } from './wuss.js';
40
42
  function parseTreeText(text) {
41
43
  if (text.startsWith('BioTreeContainer')) {
@@ -73,10 +75,24 @@ const UNSCALED_FEATURE = '#d9d9d9';
73
75
  // its own in a `features` panel
74
76
  const laneOverlap = 0.1;
75
77
  // a row panel's own width, or a default: the row height, which makes a strip
76
- // cell square, and a gene neighborhood's width for a features panel
77
- function rowPanelWidth(panel, rowHeight) {
78
+ // cell square, and a gene neighborhood's width for a features panel. The row
79
+ // height is read only when used, so a panel with a width of its own does not
80
+ // recompute on a vertical zoom
81
+ function rowPanelWidth(panel, view) {
78
82
  return (panel.width ??
79
- (panel.kind === 'features' ? defaultFeaturePanelWidth : rowHeight));
83
+ (panel.kind === 'features' ? defaultFeaturePanelWidth : view.rowHeight));
84
+ }
85
+ // the color each row takes from a field of the row table under a scale
86
+ function colorByRow(rowData, field, colorOf) {
87
+ const colors = new Map();
88
+ for (const [name, row] of Object.entries(rowData)) {
89
+ const value = row?.[field];
90
+ const color = value === undefined ? undefined : colorOf(value);
91
+ if (color) {
92
+ colors.set(name, color);
93
+ }
94
+ }
95
+ return colors;
80
96
  }
81
97
  // the fill and outline of every feature in a list: its own GFF `color=` first,
82
98
  // then the scale an encoding resolves over one of its fields, then the
@@ -209,10 +225,19 @@ function resolveFeatureScale(field, scale, annotations) {
209
225
  ...resolveScale(scale, annotations.map(a => featureField(a, field)).filter(notEmpty)),
210
226
  };
211
227
  }
228
+ const columnTrackBytes = new WeakMap();
229
+ function columnTrackSize(track) {
230
+ let bytes = columnTrackBytes.get(track);
231
+ if (bytes === undefined) {
232
+ bytes = JSON.stringify(track).length;
233
+ columnTrackBytes.set(track, bytes);
234
+ }
235
+ return bytes;
236
+ }
212
237
  // a data track over this size stays in the live model but leaves the snapshot,
213
238
  // the same rule DataModel applies to an inline document
214
239
  function columnTrackSizes(tracks) {
215
- return (tracks ?? []).map(t => JSON.stringify(t).length);
240
+ return (tracks ?? []).map(columnTrackSize);
216
241
  }
217
242
  function smallColumnTracks(tracks) {
218
243
  const sizes = columnTrackSizes(tracks);
@@ -246,6 +271,7 @@ export const preservedOnReset = new Set([
246
271
  'treeAreaWidth',
247
272
  'treeWidth',
248
273
  'showBranchLen',
274
+ 'treeOrder',
249
275
  'drawTree',
250
276
  'drawNodeBubbles',
251
277
  'drawNodeLabels',
@@ -266,6 +292,8 @@ export const preservedOnReset = new Set([
266
292
  function trackIsOff(turnedOffTracks, id, defaultOff) {
267
293
  return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id));
268
294
  }
295
+ // the marks that set tree state from a node rather than drawing
296
+ const seedingMarks = new Set(['collapse', 'focus', 'rotate']);
269
297
  // shared empty results, so observers don't see a fresh [] as a change
270
298
  const noDomains = [];
271
299
  const noClades = [];
@@ -309,14 +337,13 @@ function cladeRows(clade, root, index, rowNamesSet) {
309
337
  * Every clade that resolves, with its fill color settled, against the leaf
310
338
  * order `rowNamesSet` gives. The tree panel resolves against the displayed
311
339
  * rows and the overview against its own, so both take the tree they draw. A
312
- * `range` record names no node, so `collapse` and `focus`, which need one,
313
- * drop it.
340
+ * `range` record names no node, so the seeding marks, which need one, drop it.
314
341
  */
315
342
  function resolveClades(clades, root, rowNamesSet) {
316
343
  const index = leafIndex(root);
317
344
  return clades.flatMap(clade => {
318
345
  const resolved = cladeRows(clade, root, index, rowNamesSet);
319
- const seeding = clade.mark === 'collapse' || clade.mark === 'focus';
346
+ const seeding = seedingMarks.has(clade.mark);
320
347
  return resolved && !(seeding && resolved.nodeId === undefined)
321
348
  ? [
322
349
  {
@@ -330,16 +357,33 @@ function resolveClades(clades, root, rowNamesSet) {
330
357
  : [];
331
358
  });
332
359
  }
360
+ const childOrders = {
361
+ input: undefined,
362
+ branchLength: (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1),
363
+ ladderize: (a, b) => a.value - b.value,
364
+ ladderizeReverse: (a, b) => b.value - a.value,
365
+ };
333
366
  /**
334
367
  * The tree with the display transforms applied: leaf counts summed, children
335
- * sorted by branch length, `showOnly` taken as the new root, and each
336
- * `collapsed` clade folded. The overview builds the same tree without the
337
- * focus, so it shows the focused subtree inside the whole.
368
+ * put in `order` and each `rotated` node's reversed, `showOnly` taken as the
369
+ * new root, and each `collapsed` clade folded. The overview builds the same
370
+ * tree without the focus, so it shows the focused subtree inside the whole.
338
371
  */
339
- function buildTreeRoot(tree, collapsed, showOnly) {
372
+ function buildTreeRoot(tree, { collapsed, rotated, order, showOnly, }) {
340
373
  let hier = hierarchy(tree, d => d.children);
341
374
  hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1));
342
- sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1));
375
+ const compare = childOrders[order];
376
+ if (compare) {
377
+ sort(hier, compare);
378
+ }
379
+ if (rotated.length > 0) {
380
+ const flip = new Set(rotated);
381
+ forEachDescendant(hier, n => {
382
+ if (flip.has(n.data.id)) {
383
+ n.children?.reverse();
384
+ }
385
+ });
386
+ }
343
387
  if (showOnly) {
344
388
  const res = find(hier, n => n.data.id === showOnly);
345
389
  if (res) {
@@ -359,6 +403,48 @@ function buildTreeRoot(tree, collapsed, showOnly) {
359
403
  }
360
404
  return hier;
361
405
  }
406
+ /**
407
+ * The node whose branch an outgroup roots the tree on: the outgroup's most
408
+ * recent common ancestor, or the rest of the tips' where that ancestor is the
409
+ * root, since the outgroup then straddles the root and the branch above the
410
+ * other side is the same branch of the unrooted tree.
411
+ */
412
+ function outgroupNode(root, outgroup) {
413
+ const index = leafIndex(root);
414
+ const node = mrca(root, outgroup, index);
415
+ if (node !== root) {
416
+ return node;
417
+ }
418
+ const out = new Set(outgroup);
419
+ const rest = leaves(root)
420
+ .map(n => n.data.name)
421
+ .filter(name => !out.has(name));
422
+ const other = mrca(root, rest, index);
423
+ return other === root ? undefined : other;
424
+ }
425
+ /**
426
+ * `tree` rerooted as `treeRoot` asks, with fresh path ids. The ids of the tree
427
+ * as written become names on unnamed nodes, and a rerooting would carry them
428
+ * along as labels, so they come off first.
429
+ */
430
+ function rootTree(tree, treeRoot) {
431
+ const plain = new Map();
432
+ for (const node of preorder(tree).reverse()) {
433
+ plain.set(node, {
434
+ name: node.name === node.id ? undefined : node.name,
435
+ length: node.length,
436
+ children: node.children.map(c => plain.get(c)),
437
+ });
438
+ }
439
+ const input = plain.get(tree);
440
+ if (treeRoot === 'midpoint') {
441
+ return generateNodeIds(midpointRoot(input));
442
+ }
443
+ const target = outgroupNode(hierarchy(tree, d => d.children), treeRoot.outgroup);
444
+ return target
445
+ ? generateNodeIds(rerootTree(input, plain.get(target.data)))
446
+ : tree;
447
+ }
362
448
  // the inclusive tip indices a node covers, from the row-space extent
363
449
  // clusterLayout writes to xMin/xMax as tip centers
364
450
  function tipRange(node) {
@@ -535,6 +621,20 @@ function stateModelFactory() {
535
621
  * hidden)
536
622
  */
537
623
  collapsed: stripDefault(types.array(types.string), []),
624
+ /**
625
+ * #property
626
+ * tree nodes whose children draw in reverse order, which is ggtree's
627
+ * `rotate`: the same tree, with the clades on either side of the node
628
+ * swapped
629
+ */
630
+ rotated: stripDefault(types.array(types.string), []),
631
+ /**
632
+ * #property
633
+ * where the tree is rooted: `midpoint` roots it halfway along the
634
+ * longest path between two tips, `{outgroup}` on the branch above
635
+ * the tips named, and undefined keeps the file's root
636
+ */
637
+ treeRoot: types.frozen(),
538
638
  /**
539
639
  * #property
540
640
  * focus on particular subtree
@@ -619,7 +719,8 @@ function stateModelFactory() {
619
719
  * clades of the tree with a mark drawn over them. `mrca` names tips
620
720
  * whose common ancestor is the clade, or `range` its first and last
621
721
  * tip in display order, and `tips` is the leaf count the producer
622
- * measured. `mark` is `highlight`, `bracket`, `collapse` or `focus`.
722
+ * measured. `mark` is `highlight`, `bracket`, `collapse`, `focus` or
723
+ * `rotate`.
623
724
  * See docs/layers.md
624
725
  */
625
726
  clades: stripDefault(types.array(types.frozen()), []),
@@ -735,9 +836,15 @@ function stateModelFactory() {
735
836
  * #volatile
736
837
  * non-fatal load problems: an optional layer that failed to load, an
737
838
  * overlay that failed to parse. `error` replaces the view and is for the
738
- * alignment itself
839
+ * alignment itself. `warnings` adds the ones derived from the data
840
+ */
841
+ loadWarnings: [],
842
+ /**
843
+ * #volatile
844
+ * derived warnings the user dismissed, which stay hidden while the data
845
+ * behind them holds
739
846
  */
740
- warnings: [],
847
+ dismissedWarnings: [],
741
848
  /**
742
849
  * #volatile
743
850
  * bumped by reset(). The error boundary above the view uses it as its key,
@@ -826,16 +933,10 @@ function stateModelFactory() {
826
933
  * that failed to parse. A message already on the list is not added again
827
934
  */
828
935
  addWarning(warning) {
829
- if (!self.warnings.includes(warning)) {
830
- self.warnings = [...self.warnings, warning];
936
+ if (!self.loadWarnings.includes(warning)) {
937
+ self.loadWarnings = [...self.loadWarnings, warning];
831
938
  }
832
939
  },
833
- /**
834
- * #action
835
- */
836
- clearWarnings() {
837
- self.warnings = [];
838
- },
839
940
  /**
840
941
  * #action
841
942
  * declare that this host restores the loaded documents itself, which
@@ -952,8 +1053,9 @@ function stateModelFactory() {
952
1053
  /**
953
1054
  * #action
954
1055
  * switch to another alignment of a multi-alignment file (Stockholm).
955
- * Clears the collapsed node ids, the subtree in focus, the reference row
956
- * and the scroll position, which all refer to the previous alignment
1056
+ * Clears the collapsed and rotated node ids, the subtree in focus, the
1057
+ * reference row and the scroll position, which all refer to the
1058
+ * previous alignment
957
1059
  */
958
1060
  setCurrentAlignment(n) {
959
1061
  if (n === self.currentAlignment) {
@@ -962,6 +1064,7 @@ function stateModelFactory() {
962
1064
  transaction(() => {
963
1065
  self.currentAlignment = n;
964
1066
  self.collapsed.clear();
1067
+ self.rotated.clear();
965
1068
  this.setShowOnly(undefined);
966
1069
  this.drawRelativeTo(undefined);
967
1070
  self.scrollX = 0;
@@ -980,6 +1083,40 @@ function stateModelFactory() {
980
1083
  self.collapsed.push(node);
981
1084
  }
982
1085
  },
1086
+ /**
1087
+ * #action
1088
+ * reverse the order of the given node's children, or restore it
1089
+ */
1090
+ toggleRotated(node) {
1091
+ if (self.rotated.includes(node)) {
1092
+ self.rotated.remove(node);
1093
+ }
1094
+ else {
1095
+ self.rotated.push(node);
1096
+ }
1097
+ },
1098
+ /**
1099
+ * #action
1100
+ */
1101
+ clearRotated() {
1102
+ self.rotated.clear();
1103
+ },
1104
+ /**
1105
+ * #action
1106
+ * reroot the tree. Clears `collapsed`, `rotated` and `showOnly`, whose
1107
+ * path-derived node ids name other nodes in the rerooted tree
1108
+ */
1109
+ setTreeRoot(treeRoot) {
1110
+ if (compareStructural(self.treeRoot, treeRoot)) {
1111
+ return;
1112
+ }
1113
+ transaction(() => {
1114
+ self.treeRoot = treeRoot;
1115
+ self.collapsed.clear();
1116
+ self.rotated.clear();
1117
+ this.setShowOnly(undefined);
1118
+ });
1119
+ },
983
1120
  /**
984
1121
  * #action
985
1122
  * show only the subtree rooted at the given node id (pass undefined to
@@ -1134,12 +1271,13 @@ function stateModelFactory() {
1134
1271
  * from disk or pasted in becomes inline text, and DataModel drops an
1135
1272
  * inline document past `maxInlineSnapshotBytes`.
1136
1273
  *
1137
- * The header lists these, and the standalone app stops rewriting the
1138
- * address bar while the list is non-empty, so a copied link does not
1139
- * open an empty viewer unannounced. A document fetched from a URL never
1140
- * appears here, since the snapshot keeps its filehandle.
1274
+ * The header lists these, and the standalone app drops `?data=` from
1275
+ * the address bar while the list is non-empty or the encoded link would
1276
+ * pass 8,000 characters, so a copied link never opens an empty viewer
1277
+ * unannounced. A document fetched from a URL never appears here, since
1278
+ * the snapshot keeps its filehandle.
1141
1279
  *
1142
- * Empty when `hostRestoresData` is true.
1280
+ * Empty when `hostCarriesData` is true.
1143
1281
  */
1144
1282
  get unshareableData() {
1145
1283
  if (self.hostRestoresData) {
@@ -1230,8 +1368,9 @@ function stateModelFactory() {
1230
1368
  },
1231
1369
  /**
1232
1370
  * #getter
1371
+ * the tree the file or the alignment gives, before `treeRoot`
1233
1372
  */
1234
- get tree() {
1373
+ get inputTree() {
1235
1374
  const text = self.data.tree;
1236
1375
  return text
1237
1376
  ? generateNodeIds(parseTreeText(text))
@@ -1242,6 +1381,18 @@ function stateModelFactory() {
1242
1381
  name: 'empty',
1243
1382
  });
1244
1383
  },
1384
+ /**
1385
+ * #getter
1386
+ * the tree as the file or the alignment gives it, rerooted where
1387
+ * `treeRoot` asks. Every node id, and so `collapsed`, `rotated` and
1388
+ * `showOnly`, refers to this tree.
1389
+ */
1390
+ get tree() {
1391
+ const tree = this.inputTree;
1392
+ return self.treeRoot && !tree.noTree
1393
+ ? rootTree(tree, self.treeRoot)
1394
+ : tree;
1395
+ },
1245
1396
  /**
1246
1397
  * #getter
1247
1398
  * Returns the list of row (sequence) names in display order.
@@ -1261,8 +1412,8 @@ function stateModelFactory() {
1261
1412
  * `clades` resolved to the rows each one covers. The tip names resolve
1262
1413
  * against `tree` rather than `root`, so a clade whose ancestor the user
1263
1414
  * collapsed keeps its rows. One leaf pass over the tree serves every
1264
- * clade. A `range` record names no node, so `collapse` and `focus`, which
1265
- * need one, drop it.
1415
+ * clade. A `range` record names no node, so `collapse`, `focus` and
1416
+ * `rotate`, which need one, drop it.
1266
1417
  */
1267
1418
  get resolvedClades() {
1268
1419
  if (self.clades.length === 0) {
@@ -1315,7 +1466,24 @@ function stateModelFactory() {
1315
1466
  * #getter
1316
1467
  */
1317
1468
  get root() {
1318
- return buildTreeRoot(this.tree, self.collapsed, self.showOnly);
1469
+ return buildTreeRoot(this.tree, {
1470
+ collapsed: self.collapsed,
1471
+ rotated: self.rotated,
1472
+ order: self.treeOrder,
1473
+ showOnly: self.showOnly,
1474
+ });
1475
+ },
1476
+ /**
1477
+ * #getter
1478
+ * the tree as Newick in the order and with the root it is drawn, with
1479
+ * every clade, collapsed or out of focus
1480
+ */
1481
+ get treeNewick() {
1482
+ return writeNewick(buildTreeRoot(this.tree, {
1483
+ collapsed: [],
1484
+ rotated: self.rotated,
1485
+ order: self.treeOrder,
1486
+ }));
1319
1487
  },
1320
1488
  /**
1321
1489
  * #getter
@@ -1337,7 +1505,11 @@ function stateModelFactory() {
1337
1505
  if (!self.showTreeOverview) {
1338
1506
  return undefined;
1339
1507
  }
1340
- const root = buildTreeRoot(this.tree, self.collapsed);
1508
+ const root = buildTreeRoot(this.tree, {
1509
+ collapsed: self.collapsed,
1510
+ rotated: self.rotated,
1511
+ order: self.treeOrder,
1512
+ });
1341
1513
  const numTips = leaves(root).length;
1342
1514
  clusterLayout(root, numTips, 1);
1343
1515
  const rootLen = Math.max(root.data.length || 0, 0);
@@ -1409,7 +1581,7 @@ function stateModelFactory() {
1409
1581
  * alignment, the sum of each record's width
1410
1582
  */
1411
1583
  get rowPanelsWidth() {
1412
- return sum(self.rowPanels.map(p => rowPanelWidth(p, self.rowHeight)));
1584
+ return sum(self.rowPanels.map(p => rowPanelWidth(p, self)));
1413
1585
  },
1414
1586
  /**
1415
1587
  * #getter
@@ -1453,9 +1625,10 @@ function stateModelFactory() {
1453
1625
  return [];
1454
1626
  }
1455
1627
  // ragged input (a3m, hand-edited fasta) can have rows shorter than the
1456
- // alignment; the widest row defines the column count and a row that
1457
- // stops early counts as gapped for the remainder
1458
- const numCols = strs.reduce((max, str) => Math.max(max, str.length), 0);
1628
+ // alignment; the widest row of the whole alignment, collapsed rows
1629
+ // included, defines the column count as it does for numColumns, and a
1630
+ // row that stops early counts as gapped for the remainder
1631
+ const numCols = this.MSA?.getWidth() ?? 0;
1459
1632
  const numRows = strs.length;
1460
1633
  const threshold = Math.ceil((realAllowedGappyness / 100) * numRows);
1461
1634
  const blankCounts = new Uint32Array(numCols);
@@ -1577,7 +1750,7 @@ function stateModelFactory() {
1577
1750
  * #getter
1578
1751
  */
1579
1752
  get colStats() {
1580
- return columnCountsFromRows(this.columns2d);
1753
+ return columnCountsFromRows(this.columns2d, this.numColumns);
1581
1754
  },
1582
1755
  /**
1583
1756
  * #getter
@@ -1669,38 +1842,39 @@ function stateModelFactory() {
1669
1842
  },
1670
1843
  /**
1671
1844
  * #getter
1672
- * generates a new tree that is clustered with x,y positions
1845
+ * `root` laid out on new nodes in row and branch-length units, read in
1846
+ * pixels through `rowHeight` and `treeWidth`. A zoom leaves it and every
1847
+ * node in it as they are, and an observer reading a node's `x` or `len`
1848
+ * observes the size it scales by.
1673
1849
  */
1674
- get hierarchy() {
1675
- const r = this.root;
1676
- clusterLayout(r, this.totalHeight, self.treeWidth);
1677
- const max = this.rootToTipLength;
1678
- const k = max ? self.treeWidth / max : 0;
1679
- // the displayed root starts at x=0, so subtract its length here; `root`
1680
- // returns the cached parse, which must not be mutated
1681
- setBrLength(r, -Math.max(r.data.length || 0, 0), k);
1682
- // for each collapsed clade, record the pixel x-position of its farthest
1683
- // tip so the renderer can draw a triangle spanning the branch-length
1684
- // extent of the hidden subtree
1685
- forEachDescendant(r, node => {
1686
- if (node._children) {
1687
- node.collapsedTipXFar =
1688
- (node.len ?? 0) + collapsedSubtreeMaxLength(node) * k;
1689
- }
1850
+ get treeLayout() {
1851
+ return layoutTree(this.root, {
1852
+ get rowHeight() {
1853
+ return self.rowHeight;
1854
+ },
1855
+ get treeWidth() {
1856
+ return self.treeWidth;
1857
+ },
1690
1858
  });
1691
- return r;
1859
+ },
1860
+ /**
1861
+ * #getter
1862
+ * the laid-out tree, with pixel positions on every node
1863
+ */
1864
+ get hierarchy() {
1865
+ return this.treeLayout.root;
1692
1866
  },
1693
1867
  /**
1694
1868
  * #getter
1695
1869
  */
1696
1870
  get totalHeight() {
1697
- return leaves(this.root).length * self.rowHeight;
1871
+ return this.leaves.length * self.rowHeight;
1698
1872
  },
1699
1873
  /**
1700
1874
  * #getter
1701
1875
  */
1702
1876
  get leaves() {
1703
- return leaves(this.hierarchy);
1877
+ return this.treeLayout.leaves;
1704
1878
  },
1705
1879
  /**
1706
1880
  * #getter
@@ -1708,8 +1882,7 @@ function stateModelFactory() {
1708
1882
  * the tree's own units
1709
1883
  */
1710
1884
  get rootToTipLength() {
1711
- const r = this.root;
1712
- return maxLength(r) - Math.max(r.data.length || 0, 0);
1885
+ return this.treeLayout.rootToTipLength;
1713
1886
  },
1714
1887
  /**
1715
1888
  * #getter
@@ -1733,7 +1906,7 @@ function stateModelFactory() {
1733
1906
  * max topological depth to a tip, used to scale cladogram x-positions
1734
1907
  */
1735
1908
  get maxDepthToLeaf() {
1736
- return calcDepthToLeaf(this.hierarchy);
1909
+ return this.treeLayout.root.depthToLeaf;
1737
1910
  },
1738
1911
  /**
1739
1912
  * #getter
@@ -1942,12 +2115,6 @@ function stateModelFactory() {
1942
2115
  get secondaryStructureConsensus() {
1943
2116
  return self.MSA?.secondaryStructureConsensus;
1944
2117
  },
1945
- /**
1946
- * #getter
1947
- */
1948
- get seqConsensus() {
1949
- return self.MSA?.seqConsensus;
1950
- },
1951
2118
  /**
1952
2119
  * #getter
1953
2120
  * the base pairs of the consensus secondary structure, as arcs, in
@@ -2080,11 +2247,13 @@ function stateModelFactory() {
2080
2247
  },
2081
2248
  /**
2082
2249
  * #method
2083
- * Return a row-specific letter at a visible column, or undefined if gap.
2250
+ * Return the character a row holds at a visible column: a residue, or
2251
+ * the gap character (`-` or `.`) on a gap. Undefined for a row name the
2252
+ * alignment does not have or a column past the row's end.
2084
2253
  *
2085
2254
  * @param rowName - The name of the row
2086
2255
  * @param visibleCol - The visible column index (what the user sees on screen)
2087
- * @returns The letter at that position, or undefined if it's a gap
2256
+ * @returns The character at that position
2088
2257
  */
2089
2258
  visibleColToRowLetter(rowName, visibleCol) {
2090
2259
  return self.rowMap.get(rowName)?.[this.visibleColToGlobalCol(visibleCol)];
@@ -2171,6 +2340,21 @@ function stateModelFactory() {
2171
2340
  // row, otherwise one past the last column
2172
2341
  return col ?? (seqPos === 0 ? 0 : seq.length);
2173
2342
  },
2343
+ /**
2344
+ * #method
2345
+ * the global column of a span's last residue, clamping a position past
2346
+ * the end of the row to its last residue. Undefined for a row name the
2347
+ * alignment does not have, or a row with no residues.
2348
+ *
2349
+ * @param rowName - The name of the row
2350
+ * @param seqPos - The sequence position (0-based, ungapped)
2351
+ */
2352
+ seqEndToGlobalCol(rowName, seqPos) {
2353
+ const index = self.seqPosIndex(rowName);
2354
+ return index && index.length > 0
2355
+ ? index[Math.min(seqPos, index.length - 1)]
2356
+ : undefined;
2357
+ },
2174
2358
  /**
2175
2359
  * #method
2176
2360
  * Convert a sequence position (ungapped) directly to a visible column index.
@@ -2199,10 +2383,10 @@ function stateModelFactory() {
2199
2383
  const start = Math.max(1, Math.floor(rawStart));
2200
2384
  const end = Math.ceil(rawEnd);
2201
2385
  let startGlobal = start - 1;
2202
- let endGlobal = end - 1;
2386
+ let endGlobal = Math.min(end, self.MSA?.getWidth() ?? end) - 1;
2203
2387
  if (row !== undefined) {
2204
2388
  const rowStart = this.seqPosToGlobalCol(row, start - 1);
2205
- const rowEnd = this.seqPosToGlobalCol(row, end - 1);
2389
+ const rowEnd = this.seqEndToGlobalCol(row, end - 1);
2206
2390
  if (rowStart === undefined || rowEnd === undefined) {
2207
2391
  return undefined;
2208
2392
  }
@@ -2457,6 +2641,28 @@ function stateModelFactory() {
2457
2641
  },
2458
2642
  }))
2459
2643
  .views(self => ({
2644
+ /**
2645
+ * #getter
2646
+ */
2647
+ get verticalScrollbarWidth() {
2648
+ return self.showVerticalScrollbar ? 20 : 0;
2649
+ },
2650
+ /**
2651
+ * #getter
2652
+ * width of the alignment canvas: the msa area less the vertical
2653
+ * scrollbar. showHorizontalScrollbar must not read it, since that feeds
2654
+ * msaAreaHeight -> showVerticalScrollbar and would form a cycle
2655
+ */
2656
+ get msaCanvasWidth() {
2657
+ return self.msaAreaWidth - this.verticalScrollbarWidth;
2658
+ },
2659
+ /**
2660
+ * #getter
2661
+ * most-negative allowed scrollX, which keeps the last column in view
2662
+ */
2663
+ get maxScrollX() {
2664
+ return Math.min(-self.totalWidth + this.msaCanvasWidth, 0);
2665
+ },
2460
2666
  /**
2461
2667
  * #getter
2462
2668
  */
@@ -2471,9 +2677,29 @@ function stateModelFactory() {
2471
2677
  },
2472
2678
  /**
2473
2679
  * #getter
2680
+ * warnings that follow from the loaded data and clear when it changes. A
2681
+ * GFF whose first column names rows of some other alignment parses
2682
+ * without error and draws nothing
2474
2683
  */
2475
- get maxScrollX() {
2476
- return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0);
2684
+ get dataWarnings() {
2685
+ const { annotations, rowNamesSet } = self;
2686
+ return self.dataInitialized &&
2687
+ annotations.length > 0 &&
2688
+ !annotations.some(a => rowNamesSet.has(a.id))
2689
+ ? [
2690
+ `0 of ${annotations.length} annotations name a row in this alignment`,
2691
+ ]
2692
+ : [];
2693
+ },
2694
+ /**
2695
+ * #getter
2696
+ * the load warnings and the undismissed data warnings, for the header
2697
+ */
2698
+ get warnings() {
2699
+ return [
2700
+ ...self.loadWarnings,
2701
+ ...this.dataWarnings.filter(w => !self.dismissedWarnings.includes(w)),
2702
+ ];
2477
2703
  },
2478
2704
  /**
2479
2705
  * #getter
@@ -2499,6 +2725,13 @@ function stateModelFactory() {
2499
2725
  setDrawMsaLetters(arg) {
2500
2726
  self.drawMsaLetters = arg;
2501
2727
  },
2728
+ /**
2729
+ * #action
2730
+ */
2731
+ clearWarnings() {
2732
+ self.loadWarnings = [];
2733
+ self.dismissedWarnings = self.dataWarnings;
2734
+ },
2502
2735
  /**
2503
2736
  * #action
2504
2737
  */
@@ -2553,13 +2786,36 @@ function stateModelFactory() {
2553
2786
  },
2554
2787
  /**
2555
2788
  * #action
2556
- * swap in a different tree over the same alignment. Clears `collapsed`
2557
- * and `showOnly`, since path-derived node ids (node-0-0-1) from the old
2789
+ * root the tree on the branch above the given node. The node's tips are
2790
+ * a split of the unrooted tree, and the side of it that is a clade in
2791
+ * the tree as written stands for it as an outgroup, by its first and
2792
+ * last tip, so the root survives the path ids changing under it
2793
+ */
2794
+ rerootAt(nodeId) {
2795
+ const shown = find(hierarchy(self.tree, d => d.children), n => n.data.id === nodeId);
2796
+ if (!shown?.parent) {
2797
+ return;
2798
+ }
2799
+ const node = outgroupNode(hierarchy(self.inputTree, d => d.children), leaves(shown).map(n => n.data.name));
2800
+ if (node) {
2801
+ const tips = leaves(node);
2802
+ self.setTreeRoot({
2803
+ outgroup: [
2804
+ ...new Set([tips[0].data.name, tips.at(-1).data.name]),
2805
+ ],
2806
+ });
2807
+ }
2808
+ },
2809
+ /**
2810
+ * #action
2811
+ * swap in a different tree over the same alignment. Clears `collapsed`,
2812
+ * `rotated` and `showOnly`, since path-derived node ids (node-0-0-1) from the old
2558
2813
  * tree would match unrelated nodes in the new one.
2559
2814
  */
2560
2815
  replaceTree(newick) {
2561
2816
  transaction(() => {
2562
2817
  self.collapsed.clear();
2818
+ self.rotated.clear();
2563
2819
  self.setShowOnly(undefined);
2564
2820
  self.setTree(newick);
2565
2821
  });
@@ -2572,49 +2828,48 @@ function stateModelFactory() {
2572
2828
  self.setColWidth(defaultColWidth);
2573
2829
  self.setRowHeight(defaultRowHeight);
2574
2830
  },
2831
+ /**
2832
+ * #action
2833
+ * zoom by `scaleFactor` about the center of the alignment area
2834
+ */
2835
+ zoomAtCenter(scaleFactor, axis = 'both') {
2836
+ this.zoomToPos(scaleFactor, self.msaCanvasWidth / 2, self.msaAreaHeight / 2, axis);
2837
+ },
2575
2838
  /**
2576
2839
  * #action
2577
2840
  */
2578
2841
  zoomOutHorizontal() {
2579
- self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75));
2580
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
2842
+ this.zoomAtCenter(0.75, 'horizontal');
2581
2843
  },
2582
2844
  /**
2583
2845
  * #action
2584
2846
  */
2585
2847
  zoomInHorizontal() {
2586
- self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5));
2587
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
2848
+ this.zoomAtCenter(1.5, 'horizontal');
2588
2849
  },
2589
2850
  /**
2590
2851
  * #action
2591
2852
  */
2592
2853
  zoomInVertical() {
2593
- self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5));
2854
+ this.zoomAtCenter(1.5, 'vertical');
2594
2855
  },
2595
2856
  /**
2596
2857
  * #action
2597
2858
  */
2598
2859
  zoomOutVertical() {
2599
- self.rowHeight = Math.max(minRowHeight, Math.floor(self.rowHeight * 0.75));
2860
+ this.zoomAtCenter(0.75, 'vertical');
2600
2861
  },
2601
2862
  /**
2602
2863
  * #action
2603
2864
  */
2604
2865
  zoomIn() {
2605
- transaction(() => {
2606
- this.zoomInHorizontal();
2607
- this.zoomInVertical();
2608
- });
2866
+ this.zoomAtCenter(1.5);
2609
2867
  },
2610
2868
  /**
2611
2869
  * #action
2612
2870
  */
2613
2871
  zoomOut() {
2614
- transaction(() => {
2615
- this.zoomOutHorizontal();
2616
- this.zoomOutVertical();
2617
- });
2872
+ this.zoomAtCenter(0.75);
2618
2873
  },
2619
2874
  /**
2620
2875
  * #action
@@ -2744,25 +2999,11 @@ function stateModelFactory() {
2744
2999
  },
2745
3000
  }))
2746
3001
  .views(self => ({
2747
- /**
2748
- * #getter
2749
- */
2750
- get verticalScrollbarWidth() {
2751
- return self.showVerticalScrollbar ? 20 : 0;
2752
- },
2753
- /**
2754
- * #getter
2755
- * width of the alignment canvas: the msa area less the vertical
2756
- * scrollbar. showHorizontalScrollbar must not read it, since that feeds
2757
- * msaAreaHeight -> showVerticalScrollbar and would form a cycle
2758
- */
2759
- get msaCanvasWidth() {
2760
- return self.msaAreaWidth - this.verticalScrollbarWidth;
2761
- },
2762
3002
  /**
2763
3003
  * #method
2764
3004
  * the cell at a visible column and row index, in the coordinates a host
2765
- * writes highlights in
3005
+ * writes highlights in. On a gap `residue` is undefined and `letter` is
3006
+ * the gap character
2766
3007
  */
2767
3008
  cellAt(visibleCol, rowIndex) {
2768
3009
  const column = self.visibleColToGlobalCol(visibleCol);
@@ -2809,7 +3050,7 @@ function stateModelFactory() {
2809
3050
  }
2810
3051
  const { xStart, xEnd } = visibleColRange({
2811
3052
  offsetX: -scrollX,
2812
- blockWidth: this.msaCanvasWidth,
3053
+ blockWidth: self.msaCanvasWidth,
2813
3054
  colWidth,
2814
3055
  });
2815
3056
  const last = Math.max(0, Math.min(xEnd, numColumns) - 1);
@@ -2946,7 +3187,7 @@ function stateModelFactory() {
2946
3187
  add(field, legend);
2947
3188
  }
2948
3189
  }
2949
- for (const { legendTitle, legend } of this.resolvedRowPanels) {
3190
+ for (const { legendTitle, legend } of this.rowPanelScales) {
2950
3191
  add(legendTitle, legend);
2951
3192
  }
2952
3193
  return [...byKey.values()];
@@ -2988,7 +3229,7 @@ function stateModelFactory() {
2988
3229
  // column moves to the neighboring boundary. Bands with no visible
2989
3230
  // columns, or naming a missing row, are dropped.
2990
3231
  const start = self.seqPosToGlobalCol(name, annotation.start - 1);
2991
- const end = self.seqPosToGlobalCol(name, annotation.end - 1);
3232
+ const end = self.seqEndToGlobalCol(name, annotation.end - 1);
2992
3233
  if (start === undefined || end === undefined) {
2993
3234
  return undefined;
2994
3235
  }
@@ -3173,45 +3414,30 @@ function stateModelFactory() {
3173
3414
  },
3174
3415
  /**
3175
3416
  * #getter
3176
- * each row panel with its scale resolved against the values its field
3177
- * takes across the row table, giving the color per row name, the pixel
3178
- * column it draws in, and the entries its legend lists. Resolved once
3179
- * per change of that table or the panels, never per block per frame.
3417
+ * each row panel's scale resolved against the values its field takes,
3418
+ * giving the colors, labels and legend entries. Reads no cell size, so
3419
+ * a zoom reuses it and the legends built from it.
3180
3420
  */
3181
- get resolvedRowPanels() {
3182
- const rows = Object.entries(self.rowData);
3421
+ get rowPanelScales() {
3422
+ const { rowData } = self;
3183
3423
  const { fillPalette, featureFillEncoding, featureLabels } = this;
3184
3424
  const annotations = self.filteredAnnotations;
3185
- let offsetX = 0;
3186
- return self.rowPanels.map((panel, index) => {
3187
- const width = rowPanelWidth(panel, self.rowHeight);
3188
- const base = { id: `rowpanel-${index}`, width, offsetX };
3189
- offsetX += width;
3425
+ return self.rowPanels.map(panel => {
3190
3426
  if (panel.kind === 'features') {
3191
3427
  const color = panel.encoding?.color;
3192
3428
  const encoding = color
3193
3429
  ? resolveFeatureScale(color.field, color.scale, annotations)
3194
3430
  : featureFillEncoding;
3195
3431
  const label = panel.encoding?.label;
3196
- const align = panel.transform?.find(t => t.type === 'align');
3197
3432
  return {
3198
- ...base,
3199
3433
  kind: panel.kind,
3200
3434
  x: panel.x,
3201
3435
  header: panel.header ?? '',
3202
3436
  field: encoding?.field,
3203
3437
  legendTitle: encoding?.field,
3204
- spans: featurePanelSpans({
3205
- panel,
3206
- width,
3207
- colWidth: self.colWidth,
3208
- domainBands: this.domainBands,
3209
- annotationsByRow: self.annotationsByRow,
3210
- shifts: align
3211
- ? this.featureAlignShifts.get(align.on)
3212
- : undefined,
3213
- }),
3214
- colors: featureColorMap(annotations, encoding, fillPalette),
3438
+ colors: color
3439
+ ? featureColorMap(annotations, encoding, fillPalette)
3440
+ : this.featureColors,
3215
3441
  labels: label
3216
3442
  ? featureLabelMap(annotations, label)
3217
3443
  : featureLabels,
@@ -3223,26 +3449,53 @@ function stateModelFactory() {
3223
3449
  })),
3224
3450
  };
3225
3451
  }
3226
- const { colorOf, legend } = resolveScale(panel.scale, rows.map(([, row]) => row?.[panel.field]).filter(notEmpty));
3227
- const colors = new Map();
3228
- for (const [name, row] of rows) {
3229
- const value = row?.[panel.field];
3230
- const color = value === undefined ? undefined : colorOf(value);
3231
- if (color) {
3232
- colors.set(name, color);
3233
- }
3234
- }
3452
+ const { colorOf, legend } = resolveScale(panel.scale, Object.values(rowData)
3453
+ .map(row => row?.[panel.field])
3454
+ .filter(notEmpty));
3235
3455
  return {
3236
- ...base,
3237
3456
  kind: panel.kind,
3238
3457
  field: panel.field,
3239
3458
  header: panel.header ?? panel.field,
3240
3459
  legendTitle: panel.legend ?? panel.field,
3241
- colors,
3460
+ colors: colorByRow(rowData, panel.field, colorOf),
3242
3461
  legend,
3243
3462
  };
3244
3463
  });
3245
3464
  },
3465
+ /**
3466
+ * #getter
3467
+ * each row panel with its scale from `rowPanelScales` and its geometry:
3468
+ * the pixel column it draws in and, for a features panel, the spans per
3469
+ * row. Resolved once per change of those inputs, never per block per
3470
+ * frame.
3471
+ */
3472
+ get resolvedRowPanels() {
3473
+ const scales = this.rowPanelScales;
3474
+ let offsetX = 0;
3475
+ return self.rowPanels.map((panel, index) => {
3476
+ const width = rowPanelWidth(panel, self);
3477
+ const base = { id: `rowpanel-${index}`, width, offsetX };
3478
+ offsetX += width;
3479
+ if (panel.kind === 'features') {
3480
+ const align = panel.transform?.find(t => t.type === 'align');
3481
+ return {
3482
+ ...base,
3483
+ ...scales[index],
3484
+ spans: featurePanelSpans({
3485
+ panel,
3486
+ width,
3487
+ colWidth: self.colWidth,
3488
+ domainBands: this.domainBands,
3489
+ annotationsByRow: self.annotationsByRow,
3490
+ shifts: align
3491
+ ? this.featureAlignShifts.get(align.on)
3492
+ : undefined,
3493
+ }),
3494
+ };
3495
+ }
3496
+ return { ...base, ...scales[index] };
3497
+ });
3498
+ },
3246
3499
  /**
3247
3500
  * #getter
3248
3501
  * the shift an `align` transform gives each row, keyed by the feature
@@ -3274,18 +3527,9 @@ function stateModelFactory() {
3274
3527
  */
3275
3528
  get tipLabelColors() {
3276
3529
  const encoding = this.resolvedEncodings.find(e => e.channel === 'tipLabel');
3277
- if (!encoding) {
3278
- return undefined;
3279
- }
3280
- const colors = new Map();
3281
- for (const [name, row] of Object.entries(self.rowData)) {
3282
- const value = row?.[encoding.field];
3283
- const color = value === undefined ? undefined : encoding.colorOf(value);
3284
- if (color) {
3285
- colors.set(name, color);
3286
- }
3287
- }
3288
- return colors;
3530
+ return encoding
3531
+ ? colorByRow(self.rowData, encoding.field, encoding.colorOf)
3532
+ : undefined;
3289
3533
  },
3290
3534
  /**
3291
3535
  * #getter
@@ -3298,13 +3542,10 @@ function stateModelFactory() {
3298
3542
  if (!encoding) {
3299
3543
  return undefined;
3300
3544
  }
3545
+ const colors = colorByRow(self.rowData, encoding.field, encoding.colorOf);
3301
3546
  return self.rowNames.map(name => {
3302
- const value = self.rowDataOf(name)?.[encoding.field];
3303
- const color = value === undefined ? undefined : encoding.colorOf(value);
3304
- if (!color) {
3305
- return undefined;
3306
- }
3307
- return withAlpha(color, rowTintAlpha);
3547
+ const color = colors.get(name);
3548
+ return color && withAlpha(color, rowTintAlpha);
3308
3549
  });
3309
3550
  },
3310
3551
  /**
@@ -3380,7 +3621,8 @@ function stateModelFactory() {
3380
3621
  */
3381
3622
  reset() {
3382
3623
  self.resetCount++;
3383
- self.clearWarnings();
3624
+ self.loadWarnings = [];
3625
+ self.dismissedWarnings = [];
3384
3626
  self.setStatus(undefined);
3385
3627
  applySnapshot(self, Object.fromEntries(Object.entries(getSnapshot(self)).filter(([key]) => preservedOnReset.has(key))));
3386
3628
  self.setError(undefined);
@@ -3400,6 +3642,14 @@ function stateModelFactory() {
3400
3642
  const blob = new Blob([html], { type: 'image/svg+xml' });
3401
3643
  saveAs(blob, exportFileName(self.msaFilehandle, 'svg'));
3402
3644
  },
3645
+ /**
3646
+ * #action
3647
+ * save `treeNewick` as a file
3648
+ */
3649
+ exportNewick() {
3650
+ const blob = new Blob([self.treeNewick], { type: 'text/plain' });
3651
+ saveAs(blob, exportFileName(self.treeFilehandle ?? self.msaFilehandle, 'nwk', 'tree'));
3652
+ },
3403
3653
  /**
3404
3654
  * #action
3405
3655
  * show or hide an annotation type. Only hidden types are recorded; see
@@ -3465,23 +3715,22 @@ function stateModelFactory() {
3465
3715
  if (self.highlightColumns?.length) {
3466
3716
  self.setHighlightedColumns(self.highlightColumns);
3467
3717
  }
3468
- // The `collapse` and `focus` clade marks seed the collapsed list and
3469
- // the subtree in focus, which the tree, `hideGapsEffective` and the
3470
- // alignment all read. The tree arrives with the model for inline data
3471
- // and later for a filehandle, so the seeding waits for it and then runs
3472
- // once: expanding a seeded clade sticks, and the record collapses it
3473
- // again only on reload. `dataInitialized` is true once the MSA alone
3474
- // has loaded, when the tree is still the flat stub, so a tree
3475
- // filehandle holds the seeding until its text lands.
3476
- let cladesSeeded = false;
3477
- addDisposer(self, autorun(() => {
3478
- if (cladesSeeded ||
3479
- !self.dataInitialized ||
3480
- (self.treeFilehandle && !self.data.tree) ||
3481
- self.clades.length === 0) {
3718
+ // The `collapse`, `focus` and `rotate` clade marks seed the collapsed
3719
+ // list, the subtree in focus and the rotated nodes, which the tree,
3720
+ // `hideGapsEffective` and the alignment all read. The tree arrives with
3721
+ // the model for inline data and later for a filehandle, so the seeding
3722
+ // waits for it and then runs once per data load, per change of
3723
+ // `clades` and per reroot, which clears the node ids the seeding
3724
+ // wrote: expanding a seeded clade sticks until one of those changes.
3725
+ // `dataInitialized` is true once the MSA alone has loaded, when the
3726
+ // tree is still the flat stub, so a tree filehandle holds the seeding
3727
+ // until its text lands.
3728
+ addDisposer(self, reaction(() => self.dataInitialized && !(self.treeFilehandle && !self.data.tree)
3729
+ ? { clades: getSnapshot(self.clades), treeRoot: self.treeRoot }
3730
+ : undefined, seed => {
3731
+ if (!seed?.clades.length) {
3482
3732
  return;
3483
3733
  }
3484
- cladesSeeded = true;
3485
3734
  for (const { mark, nodeId } of self.resolvedClades) {
3486
3735
  if (nodeId === undefined) {
3487
3736
  continue;
@@ -3492,8 +3741,12 @@ function stateModelFactory() {
3492
3741
  else if (mark === 'focus') {
3493
3742
  self.setShowOnly(nodeId);
3494
3743
  }
3744
+ else if (mark === 'rotate' &&
3745
+ !self.rotated.includes(nodeId)) {
3746
+ self.toggleRotated(nodeId);
3747
+ }
3495
3748
  }
3496
- }));
3749
+ }, { fireImmediately: true, equals: compareStructural }));
3497
3750
  // zoomToRegion needs a width and resolves residues through the visible
3498
3751
  // columns, so this waits for both, and for a tree file whose collapsed
3499
3752
  // clades would hide columns and move the target after the zoom
@@ -3646,16 +3899,6 @@ function stateModelFactory() {
3646
3899
  self.setAnnotations([]);
3647
3900
  }
3648
3901
  }));
3649
- // a GFF whose first column names rows of some other alignment parses
3650
- // without error and draws nothing
3651
- addDisposer(self, autorun(() => {
3652
- const { annotations, rowNamesSet } = self;
3653
- if (annotations.length > 0 &&
3654
- rowNamesSet.size > 0 &&
3655
- !annotations.some(a => rowNamesSet.has(a.id))) {
3656
- self.addWarning(`0 of ${annotations.length} annotations name a row in this alignment`);
3657
- }
3658
- }));
3659
3902
  // gffFilehandle loads into data.gff, which the autorun above parses
3660
3903
  loadOnFilehandleChange({
3661
3904
  what: 'annotations',