react-msaview 9.0.0 → 9.1.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 (157) hide show
  1. package/README.md +19 -10
  2. package/bundle/index.js +74 -74
  3. package/bundle/index.js.map +4 -4
  4. package/dist/colorSchemes.js +1 -3
  5. package/dist/colorSchemes.js.map +1 -1
  6. package/dist/columnCounts.d.ts +0 -1
  7. package/dist/columnCounts.js +1 -1
  8. package/dist/columnCounts.js.map +1 -1
  9. package/dist/components/Loading.js +5 -5
  10. package/dist/components/Loading.js.map +1 -1
  11. package/dist/components/MSAViewer.d.ts +1891 -4
  12. package/dist/components/MSAViewer.js +144 -201
  13. package/dist/components/MSAViewer.js.map +1 -1
  14. package/dist/components/ResizeHandles.js +3 -2
  15. package/dist/components/ResizeHandles.js.map +1 -1
  16. package/dist/components/Track.js +2 -1
  17. package/dist/components/Track.js.map +1 -1
  18. package/dist/components/controls.d.ts +11 -0
  19. package/dist/components/controls.js +48 -0
  20. package/dist/components/controls.js.map +1 -0
  21. package/dist/components/dialogs/AnnotationFileDialog.js +5 -16
  22. package/dist/components/dialogs/AnnotationFileDialog.js.map +1 -1
  23. package/dist/components/dialogs/ExportSVGDialog.js +5 -7
  24. package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
  25. package/dist/components/dialogs/HelpDialog.d.ts +6 -0
  26. package/dist/components/dialogs/HelpDialog.js +30 -0
  27. package/dist/components/dialogs/HelpDialog.js.map +1 -0
  28. package/dist/components/header/FileMenu.js +33 -7
  29. package/dist/components/header/FileMenu.js.map +1 -1
  30. package/dist/components/header/Header.js +4 -6
  31. package/dist/components/header/Header.js.map +1 -1
  32. package/dist/components/header/TreeSettingsMenu.d.ts +1 -1
  33. package/dist/components/header/TreeSettingsMenu.js +1 -32
  34. package/dist/components/header/TreeSettingsMenu.js.map +1 -1
  35. package/dist/components/header/ZoomControls.js +28 -44
  36. package/dist/components/header/ZoomControls.js.map +1 -1
  37. package/dist/components/header/settingsMenuItems.d.ts +1 -0
  38. package/dist/components/header/settingsMenuItems.js +44 -0
  39. package/dist/components/header/settingsMenuItems.js.map +1 -1
  40. package/dist/components/import/data/seq2.d.ts +0 -1
  41. package/dist/components/import/data/seq2.js +0 -17
  42. package/dist/components/import/data/seq2.js.map +1 -1
  43. package/dist/components/msa/MSACanvas.js +2 -1
  44. package/dist/components/msa/MSACanvas.js.map +1 -1
  45. package/dist/components/msa/MSACanvasBlock.js +2 -5
  46. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  47. package/dist/components/msa/drawFeatureSpans.d.ts +50 -11
  48. package/dist/components/msa/drawFeatureSpans.js +99 -56
  49. package/dist/components/msa/drawFeatureSpans.js.map +1 -1
  50. package/dist/components/msa/msaRaster.d.ts +14 -1
  51. package/dist/components/msa/msaRaster.js +19 -9
  52. package/dist/components/msa/msaRaster.js.map +1 -1
  53. package/dist/components/msa/renderBoxFeatureCanvasBlock.d.ts +9 -0
  54. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +19 -8
  55. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  56. package/dist/components/msa/renderHighlights.d.ts +2 -2
  57. package/dist/components/msa/renderHighlights.js +2 -3
  58. package/dist/components/msa/renderHighlights.js.map +1 -1
  59. package/dist/components/msa/renderMSAMouseover.js +2 -1
  60. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  61. package/dist/components/renderCtx.d.ts +1 -1
  62. package/dist/components/rowpanels/renderFeaturePanel.d.ts +5 -1
  63. package/dist/components/rowpanels/renderFeaturePanel.js +12 -5
  64. package/dist/components/rowpanels/renderFeaturePanel.js.map +1 -1
  65. package/dist/components/tracks/drawTracks.d.ts +1 -53
  66. package/dist/components/tracks/drawTracks.js +5 -5
  67. package/dist/components/tracks/drawTracks.js.map +1 -1
  68. package/dist/components/tree/CladeLabels.d.ts +1 -1
  69. package/dist/components/tree/CladeLabels.js +22 -34
  70. package/dist/components/tree/CladeLabels.js.map +1 -1
  71. package/dist/components/tree/cladeBrackets.d.ts +17 -10
  72. package/dist/components/tree/cladeBrackets.js +33 -3
  73. package/dist/components/tree/cladeBrackets.js.map +1 -1
  74. package/dist/components/tree/renderTreeCanvas.js +2 -8
  75. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  76. package/dist/components/tree/renderTreeOverview.js +4 -11
  77. package/dist/components/tree/renderTreeOverview.js.map +1 -1
  78. package/dist/element.js +3 -6
  79. package/dist/element.js.map +1 -1
  80. package/dist/exportLayout.d.ts +39 -0
  81. package/dist/exportLayout.js +78 -0
  82. package/dist/exportLayout.js.map +1 -0
  83. package/dist/headlessRenderEnv.d.ts +12 -39
  84. package/dist/headlessRenderEnv.js +18 -12
  85. package/dist/headlessRenderEnv.js.map +1 -1
  86. package/dist/hierarchy.d.ts +5 -8
  87. package/dist/hierarchy.js +5 -79
  88. package/dist/hierarchy.js.map +1 -1
  89. package/dist/index.d.ts +3 -1
  90. package/dist/index.js +4 -1
  91. package/dist/index.js.map +1 -1
  92. package/dist/measureTextCanvas.d.ts +7 -0
  93. package/dist/measureTextCanvas.js +8 -6
  94. package/dist/measureTextCanvas.js.map +1 -1
  95. package/dist/model/treeModel.d.ts +0 -5
  96. package/dist/model/treeModel.js +0 -7
  97. package/dist/model/treeModel.js.map +1 -1
  98. package/dist/model.d.ts +38 -44
  99. package/dist/model.js +153 -112
  100. package/dist/model.js.map +1 -1
  101. package/dist/renderToSvg.d.ts +2 -7
  102. package/dist/renderToSvg.js +32 -120
  103. package/dist/renderToSvg.js.map +1 -1
  104. package/dist/svgTestUtil.d.ts +12 -20
  105. package/dist/types.d.ts +0 -1
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/dist/viewerSnapshot.d.ts +169 -0
  109. package/dist/viewerSnapshot.js +100 -0
  110. package/dist/viewerSnapshot.js.map +1 -0
  111. package/package.json +3 -3
  112. package/src/colorSchemes.ts +1 -4
  113. package/src/columnCounts.ts +1 -1
  114. package/src/components/Loading.tsx +19 -12
  115. package/src/components/MSAViewer.tsx +275 -251
  116. package/src/components/ResizeHandles.tsx +9 -2
  117. package/src/components/Track.tsx +2 -1
  118. package/src/components/controls.ts +61 -0
  119. package/src/components/dialogs/AnnotationFileDialog.tsx +5 -21
  120. package/src/components/dialogs/ExportSVGDialog.tsx +8 -19
  121. package/src/components/dialogs/HelpDialog.tsx +79 -0
  122. package/src/components/header/FileMenu.tsx +33 -7
  123. package/src/components/header/Header.tsx +4 -6
  124. package/src/components/header/TreeSettingsMenu.tsx +2 -32
  125. package/src/components/header/ZoomControls.tsx +30 -66
  126. package/src/components/header/settingsMenuItems.ts +59 -0
  127. package/src/components/import/data/seq2.ts +0 -18
  128. package/src/components/msa/MSACanvas.tsx +2 -1
  129. package/src/components/msa/MSACanvasBlock.tsx +2 -6
  130. package/src/components/msa/drawFeatureSpans.ts +160 -79
  131. package/src/components/msa/msaRaster.ts +27 -9
  132. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +39 -24
  133. package/src/components/msa/renderHighlights.ts +2 -3
  134. package/src/components/msa/renderMSAMouseover.ts +5 -1
  135. package/src/components/renderCtx.ts +0 -1
  136. package/src/components/rowpanels/renderFeaturePanel.ts +26 -7
  137. package/src/components/tracks/drawTracks.ts +5 -5
  138. package/src/components/tree/CladeLabels.tsx +30 -54
  139. package/src/components/tree/cladeBrackets.ts +50 -3
  140. package/src/components/tree/renderTreeCanvas.ts +2 -13
  141. package/src/components/tree/renderTreeOverview.ts +7 -14
  142. package/src/element.ts +3 -7
  143. package/src/exportLayout.ts +134 -0
  144. package/src/headlessRenderEnv.ts +24 -11
  145. package/src/hierarchy.ts +5 -88
  146. package/src/index.ts +5 -5
  147. package/src/measureTextCanvas.ts +8 -6
  148. package/src/model/treeModel.ts +0 -7
  149. package/src/model.ts +170 -122
  150. package/src/renderToSvg.tsx +55 -194
  151. package/src/types.ts +0 -1
  152. package/src/version.ts +1 -1
  153. package/src/viewerSnapshot.ts +147 -0
  154. package/dist/components/header/ZoomMenu.d.ts +0 -6
  155. package/dist/components/header/ZoomMenu.js +0 -37
  156. package/dist/components/header/ZoomMenu.js.map +0 -1
  157. package/src/components/header/ZoomMenu.tsx +0 -48
@@ -3,13 +3,7 @@ import React from 'react'
3
3
  import { useTheme } from '@mui/material'
4
4
  import { observer } from 'mobx-react'
5
5
 
6
- import {
7
- bracketGap,
8
- cladeHeight,
9
- cladeInGutter,
10
- cladeLabelHorizontal,
11
- cladeLabelOffset,
12
- } from './cladeBrackets.ts'
6
+ import { cladeLabelBoxes } from './cladeBrackets.ts'
13
7
 
14
8
  import type { MsaViewModel } from '../../model.ts'
15
9
 
@@ -20,56 +14,38 @@ import type { MsaViewModel } from '../../model.ts'
20
14
  * the same labels with a `rotate(-90)`.
21
15
  */
22
16
  const CladeLabels = observer(function ({ model }: { model: MsaViewModel }) {
23
- const {
24
- resolvedClades,
25
- cladeGutterWidth,
26
- rowHeight,
27
- effectiveTreeAreaWidth,
28
- fontSize,
29
- } = model
30
17
  const theme = useTheme()
31
- if (cladeGutterWidth === 0) {
32
- return null
33
- }
34
- const gutterLeft = effectiveTreeAreaWidth - cladeGutterWidth
18
+ const { fontSize } = model
35
19
  return (
36
20
  <>
37
- {resolvedClades.map((clade, index) => {
38
- const { label, markColor, rows } = clade
39
- if (!label || !cladeInGutter(clade)) {
40
- return null
41
- }
42
- const left = gutterLeft + cladeLabelOffset(clade)
43
- const horizontal = cladeLabelHorizontal(clade, rowHeight, fontSize)
44
- return (
45
- <div
46
- key={`${rows[0]}-${rows[1]}-${index}`}
47
- title={label}
48
- style={{
49
- position: 'absolute',
50
- left,
51
- top: rows[0] * rowHeight,
52
- width: effectiveTreeAreaWidth - left - bracketGap,
53
- height: cladeHeight(clade, rowHeight),
54
- display: 'flex',
55
- alignItems: 'center',
56
- color: markColor ?? theme.palette.text.primary,
57
- fontSize,
58
- overflow: 'hidden',
59
- textOverflow: 'ellipsis',
60
- whiteSpace: 'nowrap',
61
- pointerEvents: 'none',
62
- // vertical-rl reads top to bottom, and the half turn makes it
63
- // read upwards, which is what rotate(-90) exports
64
- ...(horizontal
65
- ? {}
66
- : { writingMode: 'vertical-rl', transform: 'rotate(180deg)' }),
67
- }}
68
- >
69
- {label}
70
- </div>
71
- )
72
- })}
21
+ {cladeLabelBoxes(model).map(box => (
22
+ <div
23
+ key={box.key}
24
+ title={box.label}
25
+ style={{
26
+ position: 'absolute',
27
+ left: box.left,
28
+ top: box.top,
29
+ width: box.width,
30
+ height: box.height,
31
+ display: 'flex',
32
+ alignItems: 'center',
33
+ color: box.color ?? theme.palette.text.primary,
34
+ fontSize,
35
+ overflow: 'hidden',
36
+ textOverflow: 'ellipsis',
37
+ whiteSpace: 'nowrap',
38
+ pointerEvents: 'none',
39
+ // vertical-rl reads top to bottom, and the half turn makes it
40
+ // read upwards, which is what rotate(-90) exports
41
+ ...(box.horizontal
42
+ ? {}
43
+ : { writingMode: 'vertical-rl', transform: 'rotate(180deg)' }),
44
+ }}
45
+ >
46
+ {box.label}
47
+ </div>
48
+ ))}
73
49
  </>
74
50
  )
75
51
  })
@@ -1,6 +1,7 @@
1
1
  import { labelFitFontSize } from '../../constants.ts'
2
2
  import { measureTextCanvas } from '../../measureTextCanvas.ts'
3
3
 
4
+ import type { MsaViewModel } from '../../model.ts'
4
5
  import type { Clade, ResolvedClade } from '../../types.ts'
5
6
 
6
7
  // The bar of the bracket mark and the space around it, in px.
@@ -19,7 +20,7 @@ export function cladeInGutter(clade: Pick<Clade, 'mark' | 'label'>) {
19
20
  }
20
21
 
21
22
  /** the pixel height of the rows a clade covers */
22
- export function cladeHeight(clade: ResolvedClade, rowHeight: number) {
23
+ function cladeHeight(clade: ResolvedClade, rowHeight: number) {
23
24
  return (clade.rows[1] - clade.rows[0] + 1) * rowHeight
24
25
  }
25
26
 
@@ -27,7 +28,7 @@ export function cladeHeight(clade: ResolvedClade, rowHeight: number) {
27
28
  * Whether a label reads across the rows. It does where they are taller than the
28
29
  * font, and runs up the bar where they are not.
29
30
  */
30
- export function cladeLabelHorizontal(
31
+ function cladeLabelHorizontal(
31
32
  clade: ResolvedClade,
32
33
  rowHeight: number,
33
34
  fontSize: number,
@@ -36,7 +37,7 @@ export function cladeLabelHorizontal(
36
37
  }
37
38
 
38
39
  /** the px between the left edge of the gutter and the label's own column */
39
- export function cladeLabelOffset(clade: Pick<Clade, 'mark'>) {
40
+ function cladeLabelOffset(clade: Pick<Clade, 'mark'>) {
40
41
  return clade.mark === 'bracket'
41
42
  ? bracketGap + bracketBarWidth + bracketGap
42
43
  : bracketGap
@@ -64,3 +65,49 @@ export function cladeGutterWidth(clades: Pick<Clade, 'mark' | 'label'>[]) {
64
65
  // the measurement here sized the gutter for
65
66
  return Math.min(maxCladeGutterWidth, Math.ceil(widest))
66
67
  }
68
+
69
+ /**
70
+ * The box each gutter label takes, in unscrolled tree-area px: from the
71
+ * label's column to the right edge of the gutter, across the clade's rows. The
72
+ * live view sets DOM text in it, and the SVG export a `<text>`.
73
+ */
74
+ export function cladeLabelBoxes(
75
+ model: Pick<
76
+ MsaViewModel,
77
+ | 'resolvedClades'
78
+ | 'cladeGutterWidth'
79
+ | 'rowHeight'
80
+ | 'effectiveTreeAreaWidth'
81
+ | 'fontSize'
82
+ >,
83
+ ) {
84
+ const {
85
+ resolvedClades,
86
+ cladeGutterWidth,
87
+ rowHeight,
88
+ effectiveTreeAreaWidth,
89
+ fontSize,
90
+ } = model
91
+ const gutterLeft = effectiveTreeAreaWidth - cladeGutterWidth
92
+ return cladeGutterWidth === 0
93
+ ? []
94
+ : resolvedClades.flatMap((clade, index) => {
95
+ const { label, markColor, rows } = clade
96
+ if (!label || !cladeInGutter(clade)) {
97
+ return []
98
+ }
99
+ const left = gutterLeft + cladeLabelOffset(clade)
100
+ return [
101
+ {
102
+ key: `${rows[0]}-${rows[1]}-${index}`,
103
+ label,
104
+ color: markColor,
105
+ left,
106
+ top: rows[0] * rowHeight,
107
+ width: effectiveTreeAreaWidth - left - bracketGap,
108
+ height: cladeHeight(clade, rowHeight),
109
+ horizontal: cladeLabelHorizontal(clade, rowHeight, fontSize),
110
+ },
111
+ ]
112
+ })
113
+ }
@@ -36,7 +36,7 @@ function inYBlock(y: number, offsetY: number, by: number, pad: number) {
36
36
  /**
37
37
  * Every node whose subtree reaches into the block, skipping the subtrees that
38
38
  * do not. A subtree's leaves are one contiguous run of rows, recorded by
39
- * clusterLayout as xMin/xMax, so a block of a 200k-tip tree visits a few dozen
39
+ * layoutTree as xMin/xMax, so a block of a 200k-tip tree visits a few dozen
40
40
  * nodes.
41
41
  *
42
42
  * A link out of a visited node is drawn even when its child is pruned, so a
@@ -706,18 +706,7 @@ export function renderTreeCanvas({
706
706
 
707
707
  // memoized on the model and shared across the tree/bubble/label passes (and
708
708
  // across all tree blocks) rather than re-traversing the hierarchy in each
709
- const {
710
- maxBranchLength,
711
- maxDepthToLeaf,
712
- showBranchLenEffective,
713
- effectiveTreeWidth,
714
- } = model
715
- // pixel x of the tips. A phylogram scales branch lengths so the longest
716
- // root-to-tip path lands at maxBranchLength; a cladogram ignores lengths and
717
- // spreads topological depth across the whole tree area instead. Reusing
718
- // maxBranchLength for the cladogram would pin every node to x=0 for a tree
719
- // with no branch lengths -- which is exactly what forces cladogram mode.
720
- const tipX = showBranchLenEffective ? maxBranchLength : effectiveTreeWidth
709
+ const { maxDepthToLeaf, effectiveTreeWidth: tipX } = model
721
710
  // built once for all three passes: each of them asks about a different node on
722
711
  // every block of every redraw, and `collapsed` is a list
723
712
  const collapsedSet = new Set(model.collapsed)
@@ -1,4 +1,5 @@
1
1
  import { descendants } from '../../hierarchy.ts'
2
+ import { makeOffscreenCanvas } from '../msa/msaRaster.ts'
2
3
  import { getNodeX } from './renderTreeCanvas.ts'
3
4
 
4
5
  import type { HierarchyNode } from '../../hierarchy.ts'
@@ -62,10 +63,10 @@ export function drawTreeOverview({
62
63
  continue
63
64
  }
64
65
  const sx = nodeX(source)
65
- const sy = rowY(source.x!)
66
+ const sy = rowY(source.x)
66
67
  for (const target of source.children) {
67
68
  const tx = nodeX(target)
68
- const ty = rowY(target.x!)
69
+ const ty = rowY(target.x)
69
70
  if (Math.abs(tx - sx) < 1 && Math.abs(ty - sy) < 1) {
70
71
  continue
71
72
  }
@@ -158,17 +159,8 @@ function overviewKeys({
158
159
  ]
159
160
  }
160
161
 
161
- function makeCanvas(width: number, height: number) {
162
- const canvas =
163
- typeof OffscreenCanvas !== 'undefined'
164
- ? new OffscreenCanvas(width, height)
165
- : typeof document !== 'undefined'
166
- ? Object.assign(document.createElement('canvas'), { width, height })
167
- : undefined
168
- const ctx = canvas?.getContext('2d')
169
- return ctx && typeof (ctx as CanvasRenderingContext2D).stroke === 'function'
170
- ? { canvas: canvas as RasterCanvas, ctx: ctx as CanvasRenderingContext2D }
171
- : undefined
162
+ function canStroke(ctx: unknown): ctx is CanvasRenderingContext2D {
163
+ return typeof (ctx as CanvasRenderingContext2D | null)?.stroke === 'function'
172
164
  }
173
165
 
174
166
  /**
@@ -198,9 +190,10 @@ export function treeOverviewImage({
198
190
  if (prev && keys.every((key, i) => key === prev.keys[i])) {
199
191
  return prev.canvas
200
192
  }
201
- const made = makeCanvas(
193
+ const made = makeOffscreenCanvas(
202
194
  Math.max(1, Math.round(width * scale)),
203
195
  Math.max(1, Math.round(height * scale)),
196
+ canStroke,
204
197
  )
205
198
  if (made) {
206
199
  made.ctx.scale(scale, scale)
package/src/element.ts CHANGED
@@ -49,10 +49,6 @@ const ownAttributes = [
49
49
  // the attributes a <nightingale-manager> writes on the elements it holds
50
50
  const nightingaleAttributes = ['display-start', 'display-end', 'highlight']
51
51
 
52
- function location(uri: string | null) {
53
- return uri ? { uri, locationType: 'UriLocation' as const } : undefined
54
- }
55
-
56
52
  function numberAttribute(value: string | null | undefined) {
57
53
  const n = value ? Number(value) : Number.NaN
58
54
  return Number.isFinite(n) ? n : undefined
@@ -188,9 +184,9 @@ export function defineMsaElement(tagName = 'jbrowse-msa') {
188
184
  const theme = this.getAttribute('theme')
189
185
  return {
190
186
  ...this.#data,
191
- msaFilehandle: location(this.getAttribute('msa-url')),
192
- treeFilehandle: location(this.getAttribute('tree-url')),
193
- gffFilehandle: location(this.getAttribute('gff-url')),
187
+ msaUrl: this.getAttribute('msa-url') ?? undefined,
188
+ treeUrl: this.getAttribute('tree-url') ?? undefined,
189
+ gffUrl: this.getAttribute('gff-url') ?? undefined,
194
190
  colorScheme:
195
191
  this.#data.colorScheme ??
196
192
  this.getAttribute('color-scheme') ??
@@ -0,0 +1,134 @@
1
+ import { legendRows } from './components/msa/legendRows.ts'
2
+ import { treeScaleBarHeight } from './constants.ts'
3
+
4
+ import type { LegendRow } from './components/msa/legendRows.ts'
5
+ import type { MsaViewModel } from './model.ts'
6
+ import type { Theme } from '@mui/material'
7
+
8
+ export interface ExportSvgOptions {
9
+ theme: Theme
10
+ includeMinimap?: boolean
11
+ includeTracks?: boolean
12
+ exportType: 'entire' | 'viewport'
13
+ }
14
+
15
+ // legend geometry, shared by the width calculation and the renderer so the
16
+ // reserved column on the right exactly fits the drawn legends
17
+ export const LEGEND_PAD = 8
18
+ export const LEGEND_ROW_H = 16
19
+ export const LEGEND_SWATCH = 12
20
+ export const LEGEND_FONT = 12
21
+ export const LEGEND_CHAR_W = 7
22
+
23
+ export const LEGEND_MAX_W = 360
24
+ export const LEGEND_TEXT_X = LEGEND_PAD + LEGEND_SWATCH + 6
25
+
26
+ export function getLegendWidth(rows: LegendRow[]) {
27
+ const maxLen = rows.reduce((a, r) => Math.max(a, r.label.length), 0)
28
+ const w = LEGEND_TEXT_X + LEGEND_PAD + maxLen * LEGEND_CHAR_W
29
+ return Math.min(LEGEND_MAX_W, Math.max(120, w))
30
+ }
31
+
32
+ export function legendHeight(rows: LegendRow[]) {
33
+ return LEGEND_PAD * 2 + rows.length * LEGEND_ROW_H
34
+ }
35
+
36
+ // resolved sizes/offsets (in svg user units) for the chosen export, shared by
37
+ // every layer; 'entire' renders the whole alignment unscrolled, 'viewport'
38
+ // mirrors the live scroll position
39
+ export interface Layout {
40
+ width: number
41
+ msaAreaWidth: number
42
+ height: number
43
+ contentHeight: number
44
+ trackHeight: number
45
+ // the band the minimap, the row panel headers and the tree overview stacked
46
+ // on the scale bar share across the top
47
+ topHeight: number
48
+ offsetX: number
49
+ offsetY: number
50
+ includeMinimap: boolean
51
+ legendWidth: number
52
+ }
53
+
54
+ export function getLayout(model: MsaViewModel, opts: ExportSvgOptions): Layout {
55
+ const {
56
+ scrollX,
57
+ scrollY,
58
+ totalWidth,
59
+ totalHeight,
60
+ effectiveTreeAreaWidth,
61
+ rowPanelsWidth,
62
+ rowPanelsHeaderHeight,
63
+ totalTrackAreaHeight,
64
+ minimapHeight,
65
+ msaAreaHeight,
66
+ msaCanvasWidth,
67
+ showHorizontalScrollbar,
68
+ treeOverviewHeight,
69
+ treeScaleBar,
70
+ } = model
71
+ const trackHeight = opts.includeTracks ? totalTrackAreaHeight : 0
72
+ // the minimap reflects the live viewport scroll position, so it's only
73
+ // meaningful for a viewport export, never for the entire alignment -- and
74
+ // only when the live view shows one at all, since a minimap over an alignment
75
+ // that already fits marks a viewport wider than the bar
76
+ const includeMinimap =
77
+ opts.exportType === 'viewport' &&
78
+ !!opts.includeMinimap &&
79
+ showHorizontalScrollbar
80
+ const rows = legendRows(model.legends)
81
+ const legendWidth = rows.length > 0 ? getLegendWidth(rows) : 0
82
+ const topHeight = Math.max(
83
+ includeMinimap ? minimapHeight : 0,
84
+ rowPanelsHeaderHeight,
85
+ treeOverviewHeight + (treeScaleBar ? treeScaleBarHeight : 0),
86
+ )
87
+ const gutterWidth = effectiveTreeAreaWidth + rowPanelsWidth
88
+
89
+ // width stays content-only; the legend occupies an extra column added at the
90
+ // svg root in MsaSvg. The viewport export takes the alignment canvas's own
91
+ // size rather than the widget's -- the widget box also covers the header, the
92
+ // resize handle and the scrollbars, and rendering that box exports rows and
93
+ // columns the scrollbars hide on screen
94
+ return opts.exportType === 'entire'
95
+ ? {
96
+ width: totalWidth + gutterWidth,
97
+ msaAreaWidth: totalWidth,
98
+ height: totalHeight + trackHeight + topHeight,
99
+ contentHeight: totalHeight,
100
+ trackHeight,
101
+ topHeight,
102
+ offsetX: 0,
103
+ offsetY: 0,
104
+ includeMinimap,
105
+ legendWidth,
106
+ }
107
+ : {
108
+ width: msaCanvasWidth + gutterWidth,
109
+ msaAreaWidth: msaCanvasWidth,
110
+ height: msaAreaHeight + topHeight + trackHeight,
111
+ contentHeight: msaAreaHeight,
112
+ trackHeight,
113
+ topHeight,
114
+ offsetX: -scrollX,
115
+ offsetY: -scrollY,
116
+ includeMinimap,
117
+ legendWidth,
118
+ }
119
+ }
120
+
121
+ /**
122
+ * the exported page's size: the layout plus the legend column, and as tall as
123
+ * the legend where the key outgrows a short alignment
124
+ */
125
+ export function exportPageSize(model: MsaViewModel, layout: Layout) {
126
+ const { width, height, legendWidth, topHeight } = layout
127
+ return {
128
+ width: width + legendWidth,
129
+ height:
130
+ legendWidth > 0
131
+ ? Math.max(height, topHeight + legendHeight(legendRows(model.legends)))
132
+ : height,
133
+ }
134
+ }
@@ -12,7 +12,9 @@
12
12
  * leaves the rest undefined -- every emitted transform then carries `NaN` in its
13
13
  * x components, which no assertion on a drawn x-position can survive.
14
14
  */
15
- export class HeadlessDOMMatrix {
15
+ import { CHAR_WIDTH_RATIO } from './measureTextCanvas.ts'
16
+
17
+ class HeadlessDOMMatrix {
16
18
  a: number
17
19
  b: number
18
20
  c: number
@@ -50,7 +52,7 @@ export class HeadlessDOMMatrix {
50
52
  }
51
53
  }
52
54
 
53
- export class HeadlessDOMPoint {
55
+ class HeadlessDOMPoint {
54
56
  constructor(
55
57
  public x = 0,
56
58
  public y = 0,
@@ -69,10 +71,11 @@ export class HeadlessDOMPoint {
69
71
  * its own jsdom passes that window instead, because Node has no global
70
72
  * HTMLCanvasElement to patch.
71
73
  *
72
- * `measureText` reports 0.6em per character, close enough to a real sans-serif
73
- * average for layout and, more usefully, exactly predictable: a caller can
74
- * compute the width a renderer will see rather than hardcoding a number
75
- * measured from one machine's fonts.
74
+ * Without `measureContext`, `measureText` reports 0.6em per character, close
75
+ * enough to a real sans-serif average for layout and exactly predictable: a
76
+ * test can compute the width a renderer will see. A caller with a real canvas,
77
+ * such as react-msaview-cli with @napi-rs/canvas, passes one of its 2D
78
+ * contexts as `measureContext` to measure with the installed fonts.
76
79
  *
77
80
  * `contextExtras` adds to the context a test hands out -- the raster test
78
81
  * supplies the ImageData methods that make the export take its image path.
@@ -80,6 +83,7 @@ export class HeadlessDOMPoint {
80
83
  export function installHeadlessRenderEnv(
81
84
  win: unknown = globalThis,
82
85
  contextExtras: Partial<CanvasRenderingContext2D> = {},
86
+ measureContext?: MeasureContext,
83
87
  ) {
84
88
  const g = globalThis as Record<string, unknown>
85
89
  g.DOMMatrix = HeadlessDOMMatrix
@@ -96,14 +100,23 @@ export function installHeadlessRenderEnv(
96
100
  set font(v: string) {
97
101
  font = v
98
102
  },
99
- measureText: (t: string) => ({
100
- width: t.length * (Number.parseFloat(font) || 10) * CHAR_WIDTH_RATIO,
101
- }),
103
+ measureText: (t: string) => {
104
+ if (measureContext) {
105
+ measureContext.font = font
106
+ return { width: measureContext.measureText(t).width }
107
+ }
108
+ return {
109
+ width:
110
+ t.length * (Number.parseFloat(font) || 10) * CHAR_WIDTH_RATIO,
111
+ }
112
+ },
102
113
  },
103
114
  contextExtras,
104
115
  ) as unknown as CanvasRenderingContext2D
105
116
  } as unknown as typeof HTMLCanvasElement.prototype.getContext
106
117
  }
107
118
 
108
- /** the per-character width factor `measureText` above reports */
109
- export const CHAR_WIDTH_RATIO = 0.6
119
+ export interface MeasureContext {
120
+ font: string
121
+ measureText(text: string): { width: number }
122
+ }
package/src/hierarchy.ts CHANGED
@@ -61,56 +61,6 @@ export function hierarchy<T>(
61
61
  return coreHierarchy(data, childrenAccessor)
62
62
  }
63
63
 
64
- export function clusterLayout<T>(
65
- root: HierarchyNode<T>,
66
- sizeX: number,
67
- sizeY: number,
68
- ) {
69
- const leafNodes = leaves(root)
70
- const n = leafNodes.length
71
- const step = sizeX / n
72
-
73
- for (let i = 0; i < n; i++) {
74
- leafNodes[i]!.x = (i + 0.5) * step
75
- }
76
-
77
- // x of an internal node is the mean of its children's x, so process in
78
- // post-order (children before parents). The subtree span comes from the same
79
- // pass, for the block culls in the tree renderer.
80
- const nodes = descendants(root)
81
- for (let i = nodes.length - 1; i >= 0; i--) {
82
- const node = nodes[i]!
83
- if (node.children) {
84
- let sum = 0
85
- let min = Infinity
86
- let max = -Infinity
87
- for (const child of node.children) {
88
- sum += child.x!
89
- min = Math.min(min, child.xMin!)
90
- max = Math.max(max, child.xMax!)
91
- }
92
- node.x = sum / node.children.length
93
- node.xMin = min
94
- node.xMax = max
95
- } else {
96
- node.xMin = node.x
97
- node.xMax = node.x
98
- }
99
- }
100
-
101
- const rootHeight = root.height
102
- const stack = [{ node: root, depth: 0 }]
103
- while (stack.length > 0) {
104
- const { node, depth } = stack.pop()!
105
- node.y = rootHeight === 0 ? sizeY : (depth / rootHeight) * sizeY
106
- if (node.children) {
107
- for (const child of node.children) {
108
- stack.push({ node: child, depth: depth + 1 })
109
- }
110
- }
111
- }
112
- }
113
-
114
64
  export interface TreeScale {
115
65
  readonly rowHeight: number
116
66
  readonly treeWidth: number
@@ -186,10 +136,10 @@ export class LaidOutNode<T> implements HierarchyNode<T> {
186
136
  }
187
137
 
188
138
  /**
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.
139
+ * The tree laid out on new nodes: tips one row apart, a parent at the mean of
140
+ * its children, and branch length measured from the root, whose own branch is
141
+ * not drawn. A collapsed node records the farthest tip of its hidden subtree,
142
+ * where the base of its triangle goes.
193
143
  */
194
144
  export function layoutTree<T extends { length?: number }>(
195
145
  root: HierarchyNode<T>,
@@ -329,39 +279,6 @@ export function calcDepthToLeaf<T>(node: HierarchyNode<T>): number {
329
279
  return node.depthToLeaf!
330
280
  }
331
281
 
332
- // Max root-to-leaf sum of branch lengths within the subtree at d. Negative
333
- // lengths are floored at zero, the same as setBrLength does when it lays the
334
- // tree out, so the extent this reports is the one that gets drawn
335
- export function maxLength(d: HierarchyNode): number {
336
- const nodes = descendants(d)
337
- const pathLen = new Map<HierarchyNode, number>()
338
- for (let i = nodes.length - 1; i >= 0; i--) {
339
- const n = nodes[i]!
340
- let childMax = 0
341
- if (n.children) {
342
- for (const child of n.children) {
343
- childMax = Math.max(childMax, pathLen.get(child)!)
344
- }
345
- }
346
- pathLen.set(n, Math.max(n.data.length || 0, 0) + childMax)
347
- }
348
- return pathLen.get(d)!
349
- }
350
-
351
- export function setBrLength(d: HierarchyNode, y0: number, k: number) {
352
- const stack = [{ node: d, y0 }]
353
- while (stack.length > 0) {
354
- const { node, y0: acc } = stack.pop()!
355
- const next = acc + Math.max(node.data.length || 0, 0)
356
- node.len = next * k
357
- if (node.children) {
358
- for (const child of node.children) {
359
- stack.push({ node: child, y0: next })
360
- }
361
- }
362
- }
363
- }
364
-
365
282
  /**
366
283
  * Every leaf by name, with a name two leaves share mapping to undefined: a tip
367
284
  * set naming it cannot say which tip it means.
@@ -416,7 +333,7 @@ export function mrca<T extends { name: string }>(
416
333
 
417
334
  /**
418
335
  * The deepest node whose subtree covers a run of rows, given in the row-space
419
- * coordinates clusterLayout writes to xMin/xMax. A subtree's leaves are one
336
+ * coordinates layoutTree writes to xMin/xMax. A subtree's leaves are one
420
337
  * contiguous run, so at most one child per level covers the run and the descent
421
338
  * visits the tree's depth rather than its nodes.
422
339
  */
package/src/index.ts CHANGED
@@ -11,10 +11,8 @@ export { renderToSvg } from './renderToSvg.tsx'
11
11
  export type { ExportSvgOptions } from './renderToSvg.tsx'
12
12
  // renderToSvg outside a browser needs DOM bits jsdom omits; react-msaview-cli
13
13
  // and the README figure generator both drive it that way
14
- export {
15
- CHAR_WIDTH_RATIO,
16
- installHeadlessRenderEnv,
17
- } from './headlessRenderEnv.ts'
14
+ export { installHeadlessRenderEnv } from './headlessRenderEnv.ts'
15
+ export { CHAR_WIDTH_RATIO } from './measureTextCanvas.ts'
18
16
  // the row count above which calculateNeighborJoiningTreeFromMSA throws, for
19
17
  // hosts gating their own menu item
20
18
  export { maxNeighborJoiningRows } from './constants.ts'
@@ -29,6 +27,8 @@ export type {
29
27
  export { default as MSAView } from './components/Loading.tsx'
30
28
  export { default as MSAViewer } from './components/MSAViewer.tsx'
31
29
  export type { MSAViewerProps } from './components/MSAViewer.tsx'
30
+ // the snapshot MSAViewer builds from its props, for the CLI's --props
31
+ export { resolveTheme, viewerSnapshot } from './viewerSnapshot.ts'
32
32
  export { mount } from './mount.tsx'
33
33
  export { defineMsaElement } from './element.ts'
34
34
  export { useMsaSvgFigure } from './useMsaSvgFigure.ts'
@@ -37,7 +37,7 @@ export type { MountedViewer } from './mount.tsx'
37
37
  export { type MsaViewModel, default as MSAModelF } from './model.ts'
38
38
  export type { MSAFormat, MSAParserType } from 'msa-parsers'
39
39
  export type { HierarchyNode, LaidOutNode, TreeScale } from './hierarchy.ts'
40
- // InterProScanResults is the EBI wire format that setDomains still accepts;
40
+ // InterProScanResults is the EBI wire format interProScanToAnnotations reads;
41
41
  // Annotation is the source-agnostic shape everything else is written against
42
42
  export type { InterProScanResults } from 'msa-parsers'
43
43
  // ColumnCounts, ColumnStats, DomainBand and TidyDomainAnnotation surface in the
@@ -16,16 +16,18 @@ function getCtx() {
16
16
  return ctxHandle
17
17
  }
18
18
 
19
- // Rough average glyph width as a fraction of the font size for the sans-serif
20
- // stack the tree labels use. Only reached where there is no canvas to measure
21
- // with (jsdom, SSR); estimating the label gutter beats throwing out of the
22
- // layout getters that read this on every render.
23
- const fallbackGlyphWidthRatio = 0.6
19
+ /**
20
+ * Rough average glyph width as a fraction of the font size for the sans-serif
21
+ * stack the labels use. measureTextCanvas falls back on it where there is no
22
+ * canvas to measure with (jsdom, SSR), and the headless render environment's
23
+ * `measureText` reports it.
24
+ */
25
+ export const CHAR_WIDTH_RATIO = 0.6
24
26
 
25
27
  export function measureTextCanvas(text: string, fontSize: number) {
26
28
  const ctx = getCtx()
27
29
  if (!ctx) {
28
- return text.length * fontSize * fallbackGlyphWidthRatio
30
+ return text.length * fontSize * CHAR_WIDTH_RATIO
29
31
  }
30
32
  setFontSize(ctx, fontSize)
31
33
  return ctx.measureText(text).width