react-msaview 8.1.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 (195) 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.d.ts +46 -0
  99. package/dist/expandSpec.js +127 -0
  100. package/dist/expandSpec.js.map +1 -0
  101. package/dist/exportFileName.d.ts +3 -3
  102. package/dist/exportFileName.js +4 -4
  103. package/dist/exportFileName.js.map +1 -1
  104. package/dist/hierarchy.d.ts +51 -0
  105. package/dist/hierarchy.js +125 -0
  106. package/dist/hierarchy.js.map +1 -1
  107. package/dist/index.d.ts +4 -2
  108. package/dist/index.js +2 -0
  109. package/dist/index.js.map +1 -1
  110. package/dist/model/treeModel.d.ts +12 -0
  111. package/dist/model/treeModel.js +14 -1
  112. package/dist/model/treeModel.js.map +1 -1
  113. package/dist/model.d.ts +188 -47
  114. package/dist/model.js +482 -192
  115. package/dist/model.js.map +1 -1
  116. package/dist/renderToSvg.js +2 -4
  117. package/dist/renderToSvg.js.map +1 -1
  118. package/dist/rerootTree.d.ts +33 -0
  119. package/dist/rerootTree.js +144 -0
  120. package/dist/rerootTree.js.map +1 -0
  121. package/dist/svgTestUtil.d.ts +114 -20
  122. package/dist/types.d.ts +25 -12
  123. package/dist/useMsaSvgFigure.d.ts +2 -1
  124. package/dist/useMsaSvgFigure.js +11 -4
  125. package/dist/useMsaSvgFigure.js.map +1 -1
  126. package/dist/version.d.ts +1 -1
  127. package/dist/version.js +1 -1
  128. package/dist/writeNewick.d.ts +8 -0
  129. package/dist/writeNewick.js +34 -0
  130. package/dist/writeNewick.js.map +1 -0
  131. package/package.json +3 -3
  132. package/src/columnCounts.ts +14 -6
  133. package/src/components/CopyButton.tsx +2 -2
  134. package/src/components/DragHandle.tsx +21 -1
  135. package/src/components/Loading.tsx +14 -30
  136. package/src/components/MSAViewer.tsx +26 -0
  137. package/src/components/SequenceTextArea.tsx +54 -8
  138. package/src/components/StatusMessage.tsx +70 -0
  139. package/src/components/Track.tsx +8 -4
  140. package/src/components/VerticalScrollbar.tsx +7 -0
  141. package/src/components/dialogs/ExportSVGDialog.tsx +5 -2
  142. package/src/components/dialogs/FeatureDialog.tsx +59 -38
  143. package/src/components/header/FileMenu.tsx +9 -0
  144. package/src/components/header/GappynessSlider.tsx +17 -4
  145. package/src/components/header/Header.tsx +7 -8
  146. package/src/components/header/RowSearch.tsx +34 -0
  147. package/src/components/header/revealRow.ts +17 -0
  148. package/src/components/header/settingsMenuItems.ts +66 -0
  149. package/src/components/import/ImportForm.tsx +14 -7
  150. package/src/components/import/ImportFormExamples.tsx +20 -28
  151. package/src/components/minimap/Minimap.tsx +7 -0
  152. package/src/components/msa/AnnotationLegend.tsx +12 -6
  153. package/src/components/msa/MSACanvas.tsx +8 -0
  154. package/src/components/msa/MSACanvasBlock.tsx +5 -11
  155. package/src/components/msa/msaKeys.ts +39 -0
  156. package/src/components/msa/useMsaBlockMouse.ts +23 -7
  157. package/src/components/rowpanels/RowPanelBlock.tsx +16 -23
  158. package/src/components/tracks/drawTracks.ts +15 -2
  159. package/src/components/tracks/useTrackHover.ts +5 -5
  160. package/src/components/tree/ScaleBarSVG.tsx +30 -0
  161. package/src/components/tree/TreeCanvas.tsx +9 -40
  162. package/src/components/tree/TreeCanvasBlock.tsx +19 -39
  163. package/src/components/tree/TreeMenu.tsx +129 -0
  164. package/src/components/tree/TreeRuler.tsx +7 -13
  165. package/src/components/tree/renderTreeCanvas.ts +20 -8
  166. package/src/components/tree/renderTreeMouseover.ts +41 -0
  167. package/src/components/tree/useTreeHover.ts +8 -17
  168. package/src/components/useHoverAnchor.ts +31 -0
  169. package/src/constants.ts +14 -3
  170. package/src/expandSpec.ts +196 -0
  171. package/src/exportFileName.ts +4 -3
  172. package/src/hierarchy.ts +151 -4
  173. package/src/index.ts +11 -1
  174. package/src/model/treeModel.ts +22 -0
  175. package/src/model.ts +588 -221
  176. package/src/renderToSvg.tsx +6 -9
  177. package/src/rerootTree.ts +172 -0
  178. package/src/types.ts +34 -12
  179. package/src/useMsaSvgFigure.ts +11 -4
  180. package/src/version.ts +1 -1
  181. package/src/writeNewick.ts +38 -0
  182. package/dist/components/PortalTooltip.d.ts +0 -11
  183. package/dist/components/PortalTooltip.js +0 -29
  184. package/dist/components/PortalTooltip.js.map +0 -1
  185. package/dist/components/header/HeaderStatusArea.js +0 -21
  186. package/dist/components/header/HeaderStatusArea.js.map +0 -1
  187. package/dist/components/tree/TreeBranchMenu.js +0 -29
  188. package/dist/components/tree/TreeBranchMenu.js.map +0 -1
  189. package/dist/components/tree/TreeNodeMenu.d.ts +0 -13
  190. package/dist/components/tree/TreeNodeMenu.js +0 -41
  191. package/dist/components/tree/TreeNodeMenu.js.map +0 -1
  192. package/src/components/PortalTooltip.tsx +0 -47
  193. package/src/components/header/HeaderStatusArea.tsx +0 -32
  194. package/src/components/tree/TreeBranchMenu.tsx +0 -68
  195. package/src/components/tree/TreeNodeMenu.tsx +0 -95
@@ -6,6 +6,7 @@ import {
6
6
  highlightLabelHeight,
7
7
  highlightRowFill,
8
8
  } from '../msa/renderHighlights.ts'
9
+ import { referenceColor } from '../overlayColors.ts'
9
10
  import { bracketBarWidth, bracketGap } from './cladeBrackets.ts'
10
11
 
11
12
  import type { HierarchyNode } from '../../hierarchy.ts'
@@ -64,6 +65,13 @@ function forEachNodeInBlock(
64
65
  }
65
66
  }
66
67
 
68
+ // withId gives an unnamed node its path, node-0-1-1, as its name
69
+ function cladeName(node: HierarchyNode, unnamed: string) {
70
+ const { id, name } = node.data
71
+ const label = name === id ? unnamed : name
72
+ return node.value === undefined ? label : `${label} (${node.value} tips)`
73
+ }
74
+
67
75
  // Calculate node x-coordinate for both phylogram (with branch lengths) and
68
76
  // cladogram (topology only) modes. tipX is the pixel x the tips sit at.
69
77
  // For cladograms: x = (maxDepthToLeaf - nodeDepthToLeaf) / maxDepthToLeaf * tipX
@@ -182,7 +190,7 @@ function renderCollapsedTriangles({
182
190
  const by = blockSizeYOverride ?? blockSize
183
191
  const halfHeight = Math.max(2, rowHeight * 0.42)
184
192
  forEachNodeInBlock(hierarchy, offsetY, by, blockPad(model), node => {
185
- const { id, name } = node.data
193
+ const { id } = node.data
186
194
  if (collapsedSet.has(id)) {
187
195
  const apexX = getNodeX(node, showBranchLen, tipX, maxDepthToLeaf)
188
196
  const y = node.x!
@@ -212,7 +220,6 @@ function renderCollapsedTriangles({
212
220
  // the whole triangle is a click/hover target that opens the
213
221
  // branch menu (Expand this node); the apex bubble's own click entry is
214
222
  // skipped for collapsed nodes so this descriptive label wins
215
- const label = name === id ? 'Collapsed clade' : name
216
223
  clickMap?.insert({
217
224
  minX: apexX + ml,
218
225
  maxX: baseX + ml,
@@ -220,7 +227,7 @@ function renderCollapsedTriangles({
220
227
  maxY: y + halfHeight,
221
228
  branch: true,
222
229
  id,
223
- name: count === undefined ? label : `${label} (${count} tips)`,
230
+ name: cladeName(node, 'Collapsed clade'),
224
231
  })
225
232
  }
226
233
  }
@@ -266,7 +273,7 @@ function renderNodeBubbles({
266
273
  }
267
274
  const { data } = node
268
275
  const y = node.x!
269
- const { id, name } = data
276
+ const { id } = data
270
277
  if (node.height >= 1 && inYBlock(y, offsetY, by, blockPad(model))) {
271
278
  const isCollapsed = collapsedSet.has(id)
272
279
  if (draw) {
@@ -290,7 +297,7 @@ function renderNodeBubbles({
290
297
  maxY: y - radius + d,
291
298
  branch: true,
292
299
  id,
293
- name,
300
+ name: cladeName(node, 'Clade'),
294
301
  })
295
302
  }
296
303
  }
@@ -572,9 +579,9 @@ function renderCladeBrackets({
572
579
  ctx.strokeStyle = theme.palette.text.primary
573
580
  }
574
581
 
575
- // the `rowTint` encoding and the row sets of `highlights`, washed across the
576
- // tree area under the labels, with each set's label in the gutter at its first
577
- // row
582
+ // the `rowTint` encoding, the reference row and the row sets of `highlights`,
583
+ // washed across the tree area under the labels, with each set's label in the
584
+ // gutter at its first row
578
585
  function renderRowHighlights({
579
586
  ctx,
580
587
  model,
@@ -591,6 +598,7 @@ function renderRowHighlights({
591
598
  const {
592
599
  resolvedHighlights,
593
600
  rowTints,
601
+ referenceRowIndex,
594
602
  rowHeight,
595
603
  treeAreaWidth,
596
604
  marginLeft,
@@ -616,6 +624,10 @@ function renderRowHighlights({
616
624
  }
617
625
  }
618
626
  }
627
+ if (referenceRowIndex !== undefined) {
628
+ ctx.fillStyle = referenceColor
629
+ fillRow(referenceRowIndex)
630
+ }
619
631
  for (const { rowIndices, label, color } of resolvedHighlights) {
620
632
  ctx.fillStyle = color ?? highlightRowFill
621
633
  for (const index of rowIndices) {
@@ -0,0 +1,41 @@
1
+ import { treeHoverColor } from '../overlayColors.ts'
2
+
3
+ import type { MsaViewModel } from '../../model.ts'
4
+
5
+ export function renderTreeMouseover({
6
+ ctx,
7
+ model,
8
+ }: {
9
+ ctx: CanvasRenderingContext2D
10
+ model: MsaViewModel
11
+ }) {
12
+ const {
13
+ rowHeight,
14
+ treeAreaWidth: width,
15
+ height,
16
+ scrollY,
17
+ mouseRow,
18
+ referenceRowIndex,
19
+ hoveredRowIndices,
20
+ highResScaleFactor,
21
+ } = model
22
+ ctx.resetTransform()
23
+ ctx.clearRect(0, 0, width * highResScaleFactor, height * highResScaleFactor)
24
+ ctx.scale(highResScaleFactor, highResScaleFactor)
25
+
26
+ const rowBand = (index: number) => {
27
+ ctx.fillRect(0, index * rowHeight + scrollY, width, rowHeight)
28
+ }
29
+
30
+ ctx.fillStyle = treeHoverColor
31
+ for (const index of hoveredRowIndices) {
32
+ rowBand(index)
33
+ }
34
+ if (
35
+ mouseRow !== undefined &&
36
+ mouseRow !== referenceRowIndex &&
37
+ !hoveredRowIndices.includes(mouseRow)
38
+ ) {
39
+ rowBand(mouseRow)
40
+ }
41
+ }
@@ -1,21 +1,16 @@
1
1
  import { useState } from 'react'
2
2
 
3
+ import { useHoverAnchor } from '../useHoverAnchor.ts'
3
4
  import { ClickMapIndex } from './clickMap.ts'
4
5
 
5
6
  import type { MsaViewModel } from '../../model.ts'
6
7
  import type { ClickEntry } from './clickMap.ts'
7
8
  import type React from 'react'
8
9
 
9
- export interface TreeHoverTarget extends ClickEntry {
10
- clientX: number
11
- clientY: number
12
- }
13
-
14
10
  /**
15
11
  * Owns hit-testing for one tree block: the spatial index the render pass fills
16
- * in, the entry currently under the pointer, and the model hover state that the
17
- * MSA panel mirrors. The index is a render artifact rather than display state, so
18
- * it lives in a ref; everything the component draws comes back as plain state.
12
+ * in, the entry under the pointer with its tooltip anchor, and the model hover
13
+ * state that the MSA panel mirrors.
19
14
  */
20
15
  export function useTreeHover({
21
16
  model,
@@ -25,10 +20,10 @@ export function useTreeHover({
25
20
  offsetY: number
26
21
  }) {
27
22
  const [clickMap] = useState(() => new ClickMapIndex())
28
- const [hovered, setHovered] = useState<TreeHoverTarget>()
23
+ const { anchor, hoverAt, clearAnchor } = useHoverAnchor<ClickEntry>()
29
24
 
30
25
  // leaf labels win over the branch/bubble targets they overlap, so a click on a
31
- // name opens the node menu rather than the branch menu
26
+ // name opens the leaf's menu
32
27
  function hitTest(event: React.MouseEvent) {
33
28
  const x = event.nativeEvent.offsetX
34
29
  const y = event.nativeEvent.offsetY + offsetY
@@ -43,11 +38,7 @@ export function useTreeHover({
43
38
 
44
39
  function onMouseMove(event: React.MouseEvent) {
45
40
  const entry = hitTest(event)
46
- setHovered(
47
- entry
48
- ? { ...entry, clientX: event.clientX, clientY: event.clientY }
49
- : undefined,
50
- )
41
+ hoverAt(event, entry)
51
42
  // hovering an internal node highlights every tip below it; hovering a leaf
52
43
  // label additionally drives the single-row highlight
53
44
  model.setHoveredTreeNode(entry?.id)
@@ -58,10 +49,10 @@ export function useTreeHover({
58
49
  }
59
50
 
60
51
  function onMouseLeave() {
61
- setHovered(undefined)
52
+ clearAnchor()
62
53
  model.setHoveredTreeNode(undefined)
63
54
  model.setMousePos(undefined, undefined)
64
55
  }
65
56
 
66
- return { clickMap, hovered, hitTest, onMouseMove, onMouseLeave }
57
+ return { clickMap, anchor, hitTest, onMouseMove, onMouseLeave }
67
58
  }
@@ -0,0 +1,31 @@
1
+ import { useState } from 'react'
2
+
3
+ export interface HoverAnchor<T> {
4
+ clientPoint: { x: number; y: number }
5
+ value: T
6
+ }
7
+
8
+ /**
9
+ * The thing under the pointer and the point a tooltip about it hangs from,
10
+ * 15px below the cursor so the tooltip clears it.
11
+ */
12
+ export function useHoverAnchor<T>() {
13
+ const [anchor, setAnchor] = useState<HoverAnchor<T>>()
14
+
15
+ function hoverAt(
16
+ event: { clientX: number; clientY: number },
17
+ value: T | undefined,
18
+ ) {
19
+ setAnchor(
20
+ value === undefined
21
+ ? undefined
22
+ : { value, clientPoint: { x: event.clientX, y: event.clientY + 15 } },
23
+ )
24
+ }
25
+
26
+ function clearAnchor() {
27
+ setAnchor(undefined)
28
+ }
29
+
30
+ return { anchor, hoverAt, clearAnchor }
31
+ }
package/src/constants.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { TreeOrder } from './types.ts'
2
+
1
3
  // Main model defaults
2
4
  export const defaultRowHeight = 16
3
5
  export const defaultColWidth = 12
@@ -75,6 +77,13 @@ export const defaultLabelsAlignRight = false
75
77
  export const defaultTreeAreaWidth = 400
76
78
  export const defaultTreeWidth = 300
77
79
  export const defaultShowBranchLen = true
80
+ export const treeOrders = [
81
+ 'branchLength',
82
+ 'input',
83
+ 'ladderize',
84
+ 'ladderizeReverse',
85
+ ] as const satisfies readonly TreeOrder[]
86
+ export const defaultTreeOrder: TreeOrder = 'branchLength'
78
87
  export const defaultDrawTree = true
79
88
  export const defaultDrawNodeBubbles = true
80
89
  export const defaultDrawNodeLabels = false
@@ -98,9 +107,11 @@ export const cladeHighlightColor = '#fff3c4'
98
107
  export const cladeHighlightAlpha = 0.6
99
108
 
100
109
  // The largest inline document the snapshot carries. A larger pasted or
101
- // locally-opened file stays in the live model and out of the snapshot, keeping
102
- // shared URLs sendable. `unshareableData` warns using the same number.
103
- export const maxInlineSnapshotBytes = 50_000
110
+ // locally-opened file stays in the live model and out of the snapshot, and
111
+ // `unshareableData` warns using the same number. The demo app gzips the
112
+ // snapshot into `?data=`, where 15 kB of the hosted protein alignments encodes
113
+ // to 6,300-7,600 characters, under the 8,192 gmod.org accepts.
114
+ export const maxInlineSnapshotBytes = 15_000
104
115
 
105
116
  // Height of the band the `rowPanels` headers draw in, above the strips in the
106
117
  // top area beside the tree ruler and the minimap. A panel wider than the band
@@ -0,0 +1,196 @@
1
+ import type { ColumnTrackSpec, Highlight, Region } from './types.ts'
2
+
3
+ /**
4
+ * `row: null` opts one entry out of the spec's `query` row, back to alignment
5
+ * columns.
6
+ */
7
+ type RowChoice = { row?: string | null }
8
+
9
+ export type HighlightShorthand =
10
+ | number
11
+ | string
12
+ | (Omit<Highlight, 'row'> & RowChoice)
13
+
14
+ export type RegionShorthand = string | (Omit<Region, 'row'> & RowChoice)
15
+
16
+ export type ColumnTrackShorthand = Omit<
17
+ ColumnTrackSpec,
18
+ 'id' | 'kind' | 'row'
19
+ > &
20
+ RowChoice & {
21
+ id?: string
22
+ kind?: ColumnTrackSpec['kind']
23
+ /** the 1-based position `values` or `data` begins at */
24
+ start?: number
25
+ }
26
+
27
+ /**
28
+ * The short forms a hand-written or generated link can use. Every long form
29
+ * passes through unchanged, so a snapshot is also a valid spec.
30
+ */
31
+ export interface MsaSpec {
32
+ /**
33
+ * The row the spec is about: sets `relativeTo`, and is the `row` of every
34
+ * highlight, region and column track that names none
35
+ */
36
+ query?: string
37
+ /** a url, or the alignment itself when it spans more than one line */
38
+ msa?: string
39
+ /** a url, or a newick string when it starts with "(" */
40
+ tree?: string
41
+ highlights?: HighlightShorthand[]
42
+ region?: RegionShorthand
43
+ columnTracks?: ColumnTrackShorthand[]
44
+ [key: string]: unknown
45
+ }
46
+
47
+ /**
48
+ * The label a single-residue shorthand highlight gets, filled in from the
49
+ * sequence when drawn: `175` on a row reading R there becomes "R175".
50
+ */
51
+ export const residueLabel = '{residue}{position}'
52
+
53
+ const span = /^(\d+)(?:-(\d+))?(?:\s+(.+))?$/
54
+
55
+ function parseSpan(text: string, what: string) {
56
+ const match = span.exec(text.trim())
57
+ if (!match) {
58
+ throw new Error(
59
+ `${what} "${text}" is not "start", "start-end" or either followed by a label`,
60
+ )
61
+ }
62
+ const a = Number(match[1])
63
+ const b = match[2] === undefined ? a : Number(match[2])
64
+ return { start: Math.min(a, b), end: Math.max(a, b), label: match[3] }
65
+ }
66
+
67
+ function withRow<T extends RowChoice>(entry: T, query?: string) {
68
+ const { row, ...rest } = entry
69
+ const resolved = row === undefined ? query : (row ?? undefined)
70
+ return resolved === undefined ? rest : { ...rest, row: resolved }
71
+ }
72
+
73
+ function expandHighlight(entry: HighlightShorthand, query?: string) {
74
+ if (typeof entry === 'object') {
75
+ return (
76
+ entry.rows
77
+ ? entry
78
+ : withRow(entry, entry.start === undefined ? undefined : query)
79
+ ) as Highlight
80
+ }
81
+ const { start, end, label } = parseSpan(String(entry), 'highlight')
82
+ const single = start === end && query !== undefined
83
+ return {
84
+ ...(query === undefined ? {} : { row: query }),
85
+ start,
86
+ end,
87
+ ...(label ? { label } : single ? { label: residueLabel } : {}),
88
+ }
89
+ }
90
+
91
+ function expandRegion(region: RegionShorthand, query?: string) {
92
+ if (typeof region === 'string') {
93
+ const { start, end } = parseSpan(region, 'region')
94
+ const parsed: Omit<Region, 'row'> & RowChoice = { start, end }
95
+ return withRow(parsed, query) as Region
96
+ }
97
+ return withRow(region, query) as Region
98
+ }
99
+
100
+ function slug(name: string) {
101
+ return name
102
+ .toLowerCase()
103
+ .replaceAll(/[^a-z0-9]+/g, '-')
104
+ .replaceAll(/^-|-$/g, '')
105
+ }
106
+
107
+ function kindOf(track: ColumnTrackShorthand) {
108
+ if (track.kind) {
109
+ return track.kind
110
+ }
111
+ if (track.values) {
112
+ return 'bar'
113
+ }
114
+ if (track.data !== undefined) {
115
+ return 'text'
116
+ }
117
+ if (track.arcs) {
118
+ return 'arc'
119
+ }
120
+ throw new Error(
121
+ `column track "${track.name}" has no values, data or arcs to take its kind from`,
122
+ )
123
+ }
124
+
125
+ function expandTrack(
126
+ track: ColumnTrackShorthand,
127
+ query: string | undefined,
128
+ ids: Set<string>,
129
+ ): ColumnTrackSpec {
130
+ const { start, ...rest } = track
131
+ const pad = start && start > 1 ? start - 1 : 0
132
+ let id = track.id ?? (slug(track.name) || 'track')
133
+ for (let n = 2; !track.id && ids.has(id); n++) {
134
+ id = `${slug(track.name) || 'track'}-${n}`
135
+ }
136
+ ids.add(id)
137
+ return {
138
+ ...(withRow(rest, query) as Omit<ColumnTrackSpec, 'id' | 'kind'>),
139
+ id,
140
+ kind: kindOf(track),
141
+ ...(pad && track.values
142
+ ? { values: [...Array<number>(pad).fill(0), ...track.values] }
143
+ : {}),
144
+ ...(pad && track.data !== undefined
145
+ ? { data: ' '.repeat(pad) + track.data }
146
+ : {}),
147
+ }
148
+ }
149
+
150
+ function isInlineMsa(msa: string) {
151
+ return msa.includes('\n')
152
+ }
153
+
154
+ function isInlineTree(tree: string) {
155
+ return tree.trimStart().startsWith('(')
156
+ }
157
+
158
+ function uri(location: string) {
159
+ return { uri: location, locationType: 'UriLocation' as const }
160
+ }
161
+
162
+ /**
163
+ * Expand the short forms of an MsaView spec into the snapshot the model takes.
164
+ * Idempotent, and a no-op on a spec that uses none of them.
165
+ */
166
+ export function expandSpec(spec: MsaSpec): Record<string, unknown> {
167
+ const { query, msa, tree, highlights, region, columnTracks, ...rest } = spec
168
+ const data = {
169
+ ...(rest.data as Record<string, unknown> | undefined),
170
+ ...(msa !== undefined && isInlineMsa(msa) ? { msa } : {}),
171
+ ...(tree !== undefined && isInlineTree(tree) ? { tree } : {}),
172
+ }
173
+ const ids = new Set(
174
+ (columnTracks ?? []).map(t => t.id).filter(id => id !== undefined),
175
+ )
176
+ return {
177
+ ...rest,
178
+ ...(msa !== undefined && !isInlineMsa(msa)
179
+ ? { msaFilehandle: uri(msa) }
180
+ : {}),
181
+ ...(tree !== undefined && !isInlineTree(tree)
182
+ ? { treeFilehandle: uri(tree) }
183
+ : {}),
184
+ ...(Object.keys(data).length > 0 ? { data } : {}),
185
+ ...(query !== undefined && rest.relativeTo === undefined
186
+ ? { relativeTo: query }
187
+ : {}),
188
+ ...(highlights
189
+ ? { highlights: highlights.map(h => expandHighlight(h, query)) }
190
+ : {}),
191
+ ...(region === undefined ? {} : { region: expandRegion(region, query) }),
192
+ ...(columnTracks
193
+ ? { columnTracks: columnTracks.map(t => expandTrack(t, query, ids)) }
194
+ : {}),
195
+ }
196
+ }
@@ -1,12 +1,13 @@
1
1
  import type { FileLocation } from '@jbrowse/core/util/types'
2
2
 
3
3
  /**
4
- * The name to save an export under: the loaded alignment's file name with the
5
- * extension swapped, or a plain default when the alignment was pasted in.
4
+ * The name to save an export under: the loaded file's name with the extension
5
+ * swapped, or `fallback` when the data was pasted in.
6
6
  */
7
7
  export function exportFileName(
8
8
  location: FileLocation | undefined,
9
9
  extension: string,
10
+ fallback = 'image',
10
11
  ) {
11
12
  const loc = location as
12
13
  | { uri?: string; localPath?: string; name?: string }
@@ -17,5 +18,5 @@ export function exportFileName(
17
18
  ?.split(/[/\\]/)
18
19
  .pop()
19
20
  ?.replace(/\.[^.]*$/, '')
20
- return `${base || 'image'}.${extension}`
21
+ return `${base || fallback}.${extension}`
21
22
  }
package/src/hierarchy.ts CHANGED
@@ -25,16 +25,16 @@ export interface HierarchyNode<T = NodeWithIds> extends CoreHierarchyNode<T> {
25
25
  parent: HierarchyNode<T> | null
26
26
  x?: number
27
27
  y?: number
28
- // the span of `x` over this node's subtree, set by clusterLayout. Leaves are
29
- // laid out in order, so a subtree occupies one contiguous run of rows and a
30
- // renderer can skip the whole thing when the run misses its block.
28
+ // the span of `x` over this node's subtree. Leaves are laid out in order, so
29
+ // a subtree occupies one contiguous run of rows and a renderer can skip the
30
+ // whole thing when the run misses its block.
31
31
  xMin?: number
32
32
  xMax?: number
33
33
  len?: number
34
34
  depthToLeaf?: number
35
35
  _children?: HierarchyNode<T>[] | null
36
36
  // pixel x-position of the farthest tip of a collapsed subtree, where the base
37
- // of the collapsed-clade triangle goes. Set in the model's hierarchy getter.
37
+ // of the collapsed-clade triangle goes
38
38
  collapsedTipXFar?: number
39
39
  }
40
40
 
@@ -111,6 +111,153 @@ export function clusterLayout<T>(
111
111
  }
112
112
  }
113
113
 
114
+ export interface TreeScale {
115
+ readonly rowHeight: number
116
+ readonly treeWidth: number
117
+ }
118
+
119
+ interface LayoutFrame {
120
+ scale: TreeScale
121
+ rootToTipLength: number
122
+ }
123
+
124
+ /**
125
+ * A node of `layoutTree`: its source node's fields plus its place in unit
126
+ * space, rows down and branch length across. The pixel getters multiply by the
127
+ * scale on every read, so a zoom leaves the nodes valid, and an observer
128
+ * reading `x` observes whatever the scale reads.
129
+ */
130
+ export class LaidOutNode<T> implements HierarchyNode<T> {
131
+ data: T
132
+ depth: number
133
+ height: number
134
+ value?: number
135
+ children: LaidOutNode<T>[] | null
136
+ depthToLeaf = 0
137
+ row = 0
138
+ rowMin = 0
139
+ rowMax = 0
140
+ depthFraction = 0
141
+ branchLength = 0
142
+ collapsedBranchLength?: number
143
+
144
+ constructor(
145
+ source: HierarchyNode<T>,
146
+ readonly parent: LaidOutNode<T> | null,
147
+ private readonly frame: LayoutFrame,
148
+ ) {
149
+ this.data = source.data
150
+ this.depth = source.depth
151
+ this.height = source.height
152
+ this.value = source.value
153
+ this.children = source.children ? [] : null
154
+ }
155
+
156
+ private get pxPerBranchLength() {
157
+ const { scale, rootToTipLength } = this.frame
158
+ return rootToTipLength ? scale.treeWidth / rootToTipLength : 0
159
+ }
160
+
161
+ get x() {
162
+ return this.row * this.frame.scale.rowHeight
163
+ }
164
+
165
+ get xMin() {
166
+ return this.rowMin * this.frame.scale.rowHeight
167
+ }
168
+
169
+ get xMax() {
170
+ return this.rowMax * this.frame.scale.rowHeight
171
+ }
172
+
173
+ get y() {
174
+ return this.depthFraction * this.frame.scale.treeWidth
175
+ }
176
+
177
+ get len() {
178
+ return this.branchLength * this.pxPerBranchLength
179
+ }
180
+
181
+ get collapsedTipXFar() {
182
+ return this.collapsedBranchLength === undefined
183
+ ? undefined
184
+ : this.collapsedBranchLength * this.pxPerBranchLength
185
+ }
186
+ }
187
+
188
+ /**
189
+ * The layout `clusterLayout` and `setBrLength` write, on new nodes: tips one
190
+ * row apart, a parent at the mean of its children, and branch length measured
191
+ * from the root, whose own branch is not drawn. A collapsed node records the
192
+ * farthest tip of its hidden subtree, where the base of its triangle goes.
193
+ */
194
+ export function layoutTree<T extends { length?: number }>(
195
+ root: HierarchyNode<T>,
196
+ scale: TreeScale,
197
+ ) {
198
+ const frame: LayoutFrame = { scale, rootToTipLength: 0 }
199
+ const nodes: LaidOutNode<T>[] = []
200
+ const leafNodes: LaidOutNode<T>[] = []
201
+ const rootHeight = root.height
202
+ const stack: { source: HierarchyNode<T>; parent: LaidOutNode<T> | null }[] = [
203
+ { source: root, parent: null },
204
+ ]
205
+ while (stack.length > 0) {
206
+ const { source, parent } = stack.pop()!
207
+ const node = new LaidOutNode(source, parent, frame)
208
+ nodes.push(node)
209
+ node.depthFraction =
210
+ rootHeight === 0 ? 1 : (source.depth - root.depth) / rootHeight
211
+ if (parent) {
212
+ parent.children!.push(node)
213
+ node.branchLength =
214
+ parent.branchLength + Math.max(source.data.length || 0, 0)
215
+ frame.rootToTipLength = Math.max(frame.rootToTipLength, node.branchLength)
216
+ }
217
+ if (source._children) {
218
+ node.collapsedBranchLength =
219
+ node.branchLength + collapsedSubtreeMaxLength(source)
220
+ }
221
+ if (source.children) {
222
+ for (let i = source.children.length - 1; i >= 0; i--) {
223
+ stack.push({ source: source.children[i]!, parent: node })
224
+ }
225
+ } else {
226
+ node.row = leafNodes.length + 0.5
227
+ node.depthToLeaf = source.depthToLeaf ?? 0
228
+ leafNodes.push(node)
229
+ }
230
+ }
231
+
232
+ for (let i = nodes.length - 1; i >= 0; i--) {
233
+ const node = nodes[i]!
234
+ if (node.children) {
235
+ let sum = 0
236
+ let min = Infinity
237
+ let max = -Infinity
238
+ let depthToLeaf = 0
239
+ for (const child of node.children) {
240
+ sum += child.row
241
+ min = Math.min(min, child.rowMin)
242
+ max = Math.max(max, child.rowMax)
243
+ depthToLeaf = Math.max(depthToLeaf, 1 + child.depthToLeaf)
244
+ }
245
+ node.row = sum / node.children.length
246
+ node.rowMin = min
247
+ node.rowMax = max
248
+ node.depthToLeaf = depthToLeaf
249
+ } else {
250
+ node.rowMin = node.row
251
+ node.rowMax = node.row
252
+ }
253
+ }
254
+ return {
255
+ root: nodes[0]!,
256
+ leaves: leafNodes,
257
+ rootToTipLength: frame.rootToTipLength,
258
+ }
259
+ }
260
+
114
261
  export function collapse<T>(node: HierarchyNode<T>) {
115
262
  if (node.children) {
116
263
  // memoize the real subtree depth onto the node before detaching its
package/src/index.ts CHANGED
@@ -18,6 +18,14 @@ export {
18
18
  // the row count above which calculateNeighborJoiningTreeFromMSA throws, for
19
19
  // hosts gating their own menu item
20
20
  export { maxNeighborJoiningRows } from './constants.ts'
21
+ // the short forms a link or a script can write a view in; see docs/layers.md
22
+ export { expandSpec, residueLabel } from './expandSpec.ts'
23
+ export type {
24
+ ColumnTrackShorthand,
25
+ HighlightShorthand,
26
+ MsaSpec,
27
+ RegionShorthand,
28
+ } from './expandSpec.ts'
21
29
  export { default as MSAView } from './components/Loading.tsx'
22
30
  export { default as MSAViewer } from './components/MSAViewer.tsx'
23
31
  export type { MSAViewerProps } from './components/MSAViewer.tsx'
@@ -28,7 +36,7 @@ export type { MsaSvgFigure } from './useMsaSvgFigure.ts'
28
36
  export type { MountedViewer } from './mount.tsx'
29
37
  export { type MsaViewModel, default as MSAModelF } from './model.ts'
30
38
  export type { MSAFormat, MSAParserType } from 'msa-parsers'
31
- export type { HierarchyNode } from './hierarchy.ts'
39
+ export type { HierarchyNode, LaidOutNode, TreeScale } from './hierarchy.ts'
32
40
  // InterProScanResults is the EBI wire format that setDomains still accepts;
33
41
  // Annotation is the source-agnostic shape everything else is written against
34
42
  export type { InterProScanResults } from 'msa-parsers'
@@ -78,6 +86,8 @@ export type {
78
86
  TextTrackModel,
79
87
  TidyDomainAnnotation,
80
88
  TrackKind,
89
+ TreeOrder,
90
+ TreeRoot,
81
91
  UnshareableData,
82
92
  Viewport,
83
93
  } from './types.ts'