react-msaview 5.2.0 → 5.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 (141) hide show
  1. package/README.md +41 -8
  2. package/bundle/index.js +17 -17
  3. package/bundle/index.js.LICENSE.txt +1 -1
  4. package/bundle/index.js.map +1 -1
  5. package/dist/components/ConservationTrack.js +2 -13
  6. package/dist/components/ConservationTrack.js.map +1 -1
  7. package/dist/components/MSAView.js +8 -1
  8. package/dist/components/MSAView.js.map +1 -1
  9. package/dist/components/ResizeHandles.d.ts +3 -0
  10. package/dist/components/ResizeHandles.js +60 -105
  11. package/dist/components/ResizeHandles.js.map +1 -1
  12. package/dist/components/TextTrack.js +2 -1
  13. package/dist/components/TextTrack.js.map +1 -1
  14. package/dist/components/Track.js +1 -1
  15. package/dist/components/Track.js.map +1 -1
  16. package/dist/components/VerticalScrollbar.js +2 -2
  17. package/dist/components/VerticalScrollbar.js.map +1 -1
  18. package/dist/components/dialogs/UserProvidedDomainsDialog.js +1 -2
  19. package/dist/components/dialogs/UserProvidedDomainsDialog.js.map +1 -1
  20. package/dist/components/header/ColorSchemeMenu.js +1 -1
  21. package/dist/components/header/ColorSchemeMenu.js.map +1 -1
  22. package/dist/components/header/FileMenu.js +1 -1
  23. package/dist/components/header/FileMenu.js.map +1 -1
  24. package/dist/components/header/HeaderInfoArea.js +28 -8
  25. package/dist/components/header/HeaderInfoArea.js.map +1 -1
  26. package/dist/components/header/MSASettingsMenu.js +1 -1
  27. package/dist/components/header/MSASettingsMenu.js.map +1 -1
  28. package/dist/components/header/TreeSettingsMenu.js +1 -1
  29. package/dist/components/header/TreeSettingsMenu.js.map +1 -1
  30. package/dist/components/header/ZoomControls.js +7 -2
  31. package/dist/components/header/ZoomControls.js.map +1 -1
  32. package/dist/components/header/ZoomMenu.js +9 -0
  33. package/dist/components/header/ZoomMenu.js.map +1 -1
  34. package/dist/components/minimap/Minimap.js +6 -12
  35. package/dist/components/minimap/Minimap.js.map +1 -1
  36. package/dist/components/minimap/MinimapSVG.js +7 -13
  37. package/dist/components/minimap/MinimapSVG.js.map +1 -1
  38. package/dist/components/minimap/minimapLayout.d.ts +9 -0
  39. package/dist/components/minimap/minimapLayout.js +15 -0
  40. package/dist/components/minimap/minimapLayout.js.map +1 -0
  41. package/dist/components/msa/MSACanvas.js +6 -1
  42. package/dist/components/msa/MSACanvas.js.map +1 -1
  43. package/dist/components/msa/MSACanvasBlock.js +5 -1
  44. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  45. package/dist/components/msa/MSAMouseoverCanvas.js +2 -2
  46. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  47. package/dist/components/msa/renderMSABlock.js +5 -7
  48. package/dist/components/msa/renderMSABlock.js.map +1 -1
  49. package/dist/components/msa/renderMSAMouseover.js +3 -2
  50. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  51. package/dist/components/tracks/renderTracksSvg.js +2 -1
  52. package/dist/components/tracks/renderTracksSvg.js.map +1 -1
  53. package/dist/components/tree/renderTreeCanvas.js +2 -2
  54. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  55. package/dist/constants.d.ts +4 -0
  56. package/dist/constants.js +7 -0
  57. package/dist/constants.js.map +1 -1
  58. package/dist/flatToTree.js +6 -0
  59. package/dist/flatToTree.js.map +1 -1
  60. package/dist/hierarchy.d.ts +1 -1
  61. package/dist/hierarchy.js +136 -102
  62. package/dist/hierarchy.js.map +1 -1
  63. package/dist/index.d.ts +1 -0
  64. package/dist/index.js +1 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/launchInterProScan.js +22 -9
  67. package/dist/launchInterProScan.js.map +1 -1
  68. package/dist/measureTextCanvas.js +2 -1
  69. package/dist/measureTextCanvas.js.map +1 -1
  70. package/dist/model.d.ts +212 -150
  71. package/dist/model.js +239 -205
  72. package/dist/model.js.map +1 -1
  73. package/dist/parseAsn1.js +8 -2
  74. package/dist/parseAsn1.js.map +1 -1
  75. package/dist/renderToSvg.js +27 -24
  76. package/dist/renderToSvg.js.map +1 -1
  77. package/dist/setFontSize.d.ts +3 -0
  78. package/dist/setFontSize.js +14 -0
  79. package/dist/setFontSize.js.map +1 -0
  80. package/dist/useDevicePixelRatio.d.ts +1 -0
  81. package/dist/useDevicePixelRatio.js +27 -0
  82. package/dist/useDevicePixelRatio.js.map +1 -0
  83. package/dist/useWheelScroll.d.ts +3 -1
  84. package/dist/useWheelScroll.js +64 -22
  85. package/dist/useWheelScroll.js.map +1 -1
  86. package/dist/util.d.ts +4 -0
  87. package/dist/util.js +36 -0
  88. package/dist/util.js.map +1 -1
  89. package/dist/version.d.ts +1 -1
  90. package/dist/version.js +1 -1
  91. package/dist/webpack.js +0 -1
  92. package/dist/webpack.js.map +1 -1
  93. package/dist/wheelZoom.d.ts +4 -0
  94. package/dist/wheelZoom.js +43 -0
  95. package/dist/wheelZoom.js.map +1 -0
  96. package/package.json +5 -5
  97. package/src/components/ConservationTrack.tsx +2 -18
  98. package/src/components/MSAView.tsx +10 -1
  99. package/src/components/ResizeHandles.tsx +101 -113
  100. package/src/components/TextTrack.tsx +2 -1
  101. package/src/components/Track.tsx +1 -1
  102. package/src/components/VerticalScrollbar.tsx +2 -2
  103. package/src/components/dialogs/UserProvidedDomainsDialog.tsx +1 -2
  104. package/src/components/header/ColorSchemeMenu.tsx +1 -0
  105. package/src/components/header/FileMenu.tsx +1 -0
  106. package/src/components/header/HeaderInfoArea.tsx +32 -7
  107. package/src/components/header/MSASettingsMenu.tsx +1 -0
  108. package/src/components/header/TreeSettingsMenu.tsx +1 -0
  109. package/src/components/header/ZoomControls.tsx +14 -1
  110. package/src/components/header/ZoomMenu.tsx +10 -0
  111. package/src/components/minimap/Minimap.tsx +6 -15
  112. package/src/components/minimap/MinimapSVG.tsx +9 -22
  113. package/src/components/minimap/minimapLayout.ts +17 -0
  114. package/src/components/msa/MSACanvas.tsx +9 -0
  115. package/src/components/msa/MSACanvasBlock.tsx +7 -1
  116. package/src/components/msa/MSAMouseoverCanvas.tsx +4 -3
  117. package/src/components/msa/renderMSABlock.ts +6 -9
  118. package/src/components/msa/renderMSAMouseover.ts +3 -1
  119. package/src/components/tracks/renderTracksSvg.ts +2 -1
  120. package/src/components/tree/renderTreeCanvas.ts +2 -2
  121. package/src/constants.ts +8 -0
  122. package/src/flatToTree.test.ts +30 -0
  123. package/src/flatToTree.ts +10 -1
  124. package/src/hierarchy.test.ts +53 -1
  125. package/src/hierarchy.ts +139 -113
  126. package/src/index.ts +1 -0
  127. package/src/launchInterProScan.ts +33 -11
  128. package/src/measureTextCanvas.ts +3 -1
  129. package/src/model.ts +312 -242
  130. package/src/parseAsn1.test.ts +6 -0
  131. package/src/parseAsn1.ts +11 -3
  132. package/src/renderToSvg.tsx +79 -67
  133. package/src/setFontSize.test.ts +33 -0
  134. package/src/setFontSize.ts +20 -0
  135. package/src/useDevicePixelRatio.ts +29 -0
  136. package/src/useWheelScroll.ts +79 -21
  137. package/src/util.ts +35 -0
  138. package/src/version.ts +1 -1
  139. package/src/webpack.ts +0 -1
  140. package/src/wheelZoom.test.ts +46 -0
  141. package/src/wheelZoom.ts +47 -0
@@ -37,6 +37,16 @@ const ZoomMenu = observer(function ({ model }: { model: MsaViewModel }) {
37
37
  model.resetZoom()
38
38
  },
39
39
  },
40
+ {
41
+ label: 'Scroll zoom',
42
+ type: 'checkbox',
43
+ checked: model.scrollZoom,
44
+ onClick: () => {
45
+ model.setScrollZoom(!model.scrollZoom)
46
+ },
47
+ helpText:
48
+ 'When enabled, scrolling the mouse wheel zooms in and out without holding ctrl. Hold shift to pan instead. Ctrl/⌘+wheel always zooms.',
49
+ },
40
50
  ]}
41
51
  >
42
52
  <MoreVert />
@@ -2,20 +2,16 @@ import React, { useCallback, useState } from 'react'
2
2
 
3
3
  import { observer } from 'mobx-react'
4
4
 
5
+ import { MINIMAP_BAR_HEIGHT, getMinimapLayout } from './minimapLayout.ts'
5
6
  import { useDragScroll } from '../../useDragScroll.ts'
6
7
 
7
8
  import type { MsaViewModel } from '../../model.ts'
8
9
 
9
10
  const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
10
11
  const [hovered, setHovered] = useState(false)
11
- const { scrollX, msaAreaWidth, minimapHeight, colWidth, numColumns } = model
12
- const unit = msaAreaWidth / numColumns / colWidth
13
- const left = -scrollX
14
- const right = left + msaAreaWidth
15
- const s = left * unit
16
- const e = right * unit
12
+ const { minimapHeight } = model
13
+ const { unit, s, w, polygonHeight, polygonPoints } = getMinimapLayout(model)
17
14
  const fill = 'rgba(66, 119, 127, 0.3)'
18
- const w = Math.max(e - s, 20)
19
15
 
20
16
  const { startDrag } = useDragScroll(
21
17
  'x',
@@ -27,8 +23,6 @@ const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
27
23
  ),
28
24
  )
29
25
 
30
- const barHeight = 12
31
- const polygonHeight = minimapHeight - barHeight
32
26
  return (
33
27
  <div
34
28
  style={{
@@ -39,7 +33,7 @@ const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
39
33
  >
40
34
  <div
41
35
  style={{
42
- height: barHeight,
36
+ height: MINIMAP_BAR_HEIGHT,
43
37
  boxSizing: 'border-box',
44
38
  border: '1px solid #555',
45
39
  }}
@@ -51,7 +45,7 @@ const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
51
45
  left: Math.max(0, s),
52
46
  background: hovered ? 'rgba(66,119,127,0.6)' : fill,
53
47
  cursor: 'pointer',
54
- height: barHeight,
48
+ height: MINIMAP_BAR_HEIGHT,
55
49
  width: w,
56
50
  zIndex: 100,
57
51
  }}
@@ -67,10 +61,7 @@ const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
67
61
  />
68
62
 
69
63
  <svg height={polygonHeight} style={{ width: '100%' }}>
70
- <polygon
71
- fill={fill}
72
- points={`${s + w},0 ${s},0 0,${polygonHeight} ${msaAreaWidth},${polygonHeight}`}
73
- />
64
+ <polygon fill={fill} points={polygonPoints} />
74
65
  </svg>
75
66
  </div>
76
67
  )
@@ -2,26 +2,13 @@ import React from 'react'
2
2
 
3
3
  import { observer } from 'mobx-react'
4
4
 
5
+ import { MINIMAP_BAR_HEIGHT, getMinimapLayout } from './minimapLayout.ts'
6
+
5
7
  import type { MsaViewModel } from '../../model.ts'
6
8
 
7
9
  const MinimapSVG = observer(({ model }: { model: MsaViewModel }) => {
8
- const {
9
- scrollX,
10
- msaAreaWidth: W,
11
- minimapHeight: H,
12
- colWidth,
13
- numColumns,
14
- } = model
15
-
16
- const BAR_HEIGHT = 12
17
- const H2 = H - BAR_HEIGHT
18
-
19
- const unit = W / numColumns / colWidth
20
- const left = -scrollX
21
- const right = left + W
22
- const s = left * unit
23
- const e = right * unit
24
- const w = Math.max(e - s, 20)
10
+ const { msaAreaWidth } = model
11
+ const { s, w, polygonPoints } = getMinimapLayout(model)
25
12
  const fillColor = 'rgb(66, 119, 127)'
26
13
  const fillOpacity = 0.3
27
14
 
@@ -30,8 +17,8 @@ const MinimapSVG = observer(({ model }: { model: MsaViewModel }) => {
30
17
  <rect
31
18
  x={0}
32
19
  y={0}
33
- width={W}
34
- height={BAR_HEIGHT}
20
+ width={msaAreaWidth}
21
+ height={MINIMAP_BAR_HEIGHT}
35
22
  stroke="#555"
36
23
  fill="none"
37
24
  />
@@ -39,16 +26,16 @@ const MinimapSVG = observer(({ model }: { model: MsaViewModel }) => {
39
26
  x={Math.max(0, s)}
40
27
  y={0}
41
28
  width={w}
42
- height={BAR_HEIGHT}
29
+ height={MINIMAP_BAR_HEIGHT}
43
30
  fill={fillColor}
44
31
  fillOpacity={fillOpacity}
45
32
  stroke="#555"
46
33
  />
47
- <g transform={`translate(0 ${BAR_HEIGHT})`}>
34
+ <g transform={`translate(0 ${MINIMAP_BAR_HEIGHT})`}>
48
35
  <polygon
49
36
  fill={fillColor}
50
37
  fillOpacity={fillOpacity}
51
- points={`${s + w},0 ${s},0 0,${H2} ${W},${H2}`}
38
+ points={polygonPoints}
52
39
  />
53
40
  </g>
54
41
  </>
@@ -0,0 +1,17 @@
1
+ import type { MsaViewModel } from '../../model.ts'
2
+
3
+ export const MINIMAP_BAR_HEIGHT = 12
4
+
5
+ // viewport-rectangle and trapezoid geometry shared by the interactive Minimap
6
+ // and the static MinimapSVG (used for SVG export)
7
+ export function getMinimapLayout(model: MsaViewModel) {
8
+ const { scrollX, msaAreaWidth, minimapHeight, colWidth, numColumns } = model
9
+ const unit = msaAreaWidth / numColumns / colWidth
10
+ const left = -scrollX
11
+ const s = left * unit
12
+ const e = (left + msaAreaWidth) * unit
13
+ const w = Math.max(e - s, 20)
14
+ const polygonHeight = minimapHeight - MINIMAP_BAR_HEIGHT
15
+ const polygonPoints = `${s + w},0 ${s},0 0,${polygonHeight} ${msaAreaWidth},${polygonHeight}`
16
+ return { unit, s, w, polygonHeight, polygonPoints }
17
+ }
@@ -16,6 +16,7 @@ const MSACanvas = observer(function ({ model }: { model: MsaViewModel }) {
16
16
  height,
17
17
  msaAreaWidth,
18
18
  blocks2d,
19
+ scrollZoom,
19
20
  } = model
20
21
  const ref = useRef<HTMLDivElement>(null)
21
22
  const onScrollX = useCallback(
@@ -30,10 +31,18 @@ const MSACanvas = observer(function ({ model }: { model: MsaViewModel }) {
30
31
  },
31
32
  [model],
32
33
  )
34
+ const onZoom = useCallback(
35
+ (scaleFactor: number, offsetX: number, offsetY: number) => {
36
+ model.zoomToPos(scaleFactor, offsetX, offsetY)
37
+ },
38
+ [model],
39
+ )
33
40
  const { onMouseDown, onMouseUp } = useWheelScroll({
34
41
  ref,
35
42
  onScrollX,
36
43
  onScrollY,
44
+ onZoom,
45
+ scrollZoom,
37
46
  })
38
47
 
39
48
  return (
@@ -97,7 +97,6 @@ const MSACanvasBlock = observer(function ({
97
97
  ref={ref}
98
98
  onMouseMove={event => {
99
99
  if (ref.current) {
100
- setMousePosition({ x: event.clientX, y: event.clientY })
101
100
  const { col, row } = eventToColRow(
102
101
  event,
103
102
  ref.current,
@@ -117,6 +116,13 @@ const MSACanvasBlock = observer(function ({
117
116
  } else {
118
117
  model.setMousePos(undefined, undefined)
119
118
  }
119
+ // only track client coords when an insertion is hovered (the only
120
+ // consumer is the tooltip below); avoids a re-render on every move
121
+ setMousePosition(
122
+ model.hoveredInsertion
123
+ ? { x: event.clientX, y: event.clientY }
124
+ : undefined,
125
+ )
120
126
  }
121
127
  }}
122
128
  onClick={event => {
@@ -14,7 +14,8 @@ const MSAMouseoverCanvas = observer(function ({
14
14
  model: MsaViewModel
15
15
  }) {
16
16
  const ref = useRef<HTMLCanvasElement>(null)
17
- const { height, msaAreaWidth, verticalScrollbarWidth } = model
17
+ const { height, msaAreaWidth, verticalScrollbarWidth, highResScaleFactor } =
18
+ model
18
19
  const width = msaAreaWidth - verticalScrollbarWidth
19
20
  useEffect(() => {
20
21
  const ctx = ref.current?.getContext('2d')
@@ -31,8 +32,8 @@ const MSAMouseoverCanvas = observer(function ({
31
32
  <canvas
32
33
  ref={ref}
33
34
  id="mouseover"
34
- width={width}
35
- height={height}
35
+ width={width * highResScaleFactor}
36
+ height={height * highResScaleFactor}
36
37
  style={{
37
38
  position: 'absolute',
38
39
  top: 0,
@@ -1,5 +1,6 @@
1
1
  import { getVisibleLeaves } from './getVisibleLeaves.ts'
2
2
  import { visibleColRange } from './visibleColRange.ts'
3
+ import { setFontSize } from '../../setFontSize.ts'
3
4
  import { adjustColorForContrast } from '../../util.ts'
4
5
 
5
6
  import type { HierarchyNode } from '../../hierarchy.ts'
@@ -45,13 +46,7 @@ export function renderMSABlock({
45
46
  ctx.scale(k, k)
46
47
  ctx.translate(-offsetX, rowHeight / 2 - offsetY)
47
48
  ctx.textAlign = 'center'
48
- // match an optional existing "bold " so re-renders don't accumulate it
49
- // (an invalid "bold bold 16px" string would be silently ignored by canvas,
50
- // freezing the font size against zoom changes)
51
- ctx.font = ctx.font.replace(
52
- /(?:bold )?\d+px/,
53
- `${bgColor ? '' : 'bold '}${fontSize}px`,
54
- )
49
+ setFontSize(ctx, fontSize, !bgColor)
55
50
 
56
51
  const { xStart, xEnd } = visibleColRange({
57
52
  offsetX,
@@ -127,6 +122,8 @@ function drawTilesAndText({
127
122
  rowHeight,
128
123
  relativeTo,
129
124
  showMsaLetters,
125
+ colClustalX,
126
+ colConsensus,
130
127
  } = model
131
128
 
132
129
  const isClustalX = colorSchemeName === 'clustalx_protein_dynamic'
@@ -158,9 +155,9 @@ function drawTilesAndText({
158
155
 
159
156
  let color: string | undefined
160
157
  if (isClustalX) {
161
- color = model.colClustalX[xStart + j]![letter]
158
+ color = colClustalX[xStart + j]![letter]
162
159
  } else if (isPercentIdentity) {
163
- const consensus = model.colConsensus[xStart + j]!
160
+ const consensus = colConsensus[xStart + j]!
164
161
  color = letter === consensus.letter ? consensus.color : undefined
165
162
  } else {
166
163
  color = colorScheme[letter]
@@ -27,10 +27,12 @@ export function renderMouseover({
27
27
  relativeTo,
28
28
  hoveredTreeNode,
29
29
  highlightedColumns,
30
+ highResScaleFactor,
30
31
  } = model
31
32
  const width = msaAreaWidth - verticalScrollbarWidth
32
33
  ctx.resetTransform()
33
- ctx.clearRect(0, 0, width, height)
34
+ ctx.clearRect(0, 0, width * highResScaleFactor, height * highResScaleFactor)
35
+ ctx.scale(highResScaleFactor, highResScaleFactor)
34
36
 
35
37
  // Highlight reference row (relativeTo) persistently
36
38
  if (relativeTo) {
@@ -1,3 +1,4 @@
1
+ import { setFontSize } from '../../setFontSize.ts'
1
2
  import { visibleColRange } from '../msa/visibleColRange.ts'
2
3
 
3
4
  import type { MsaViewModel } from '../../model.ts'
@@ -152,7 +153,7 @@ export function renderTextTrack({
152
153
  ctx.scale(k, k)
153
154
  ctx.translate(-offsetX, offsetY)
154
155
  ctx.textAlign = 'center'
155
- ctx.font = ctx.font.replace(/\d+px/, `${fontSize}px`)
156
+ setFontSize(ctx, fontSize)
156
157
 
157
158
  drawTextTrackContent({
158
159
  ctx,
@@ -3,6 +3,7 @@ import {
3
3
  forEachDescendant,
4
4
  forEachLink,
5
5
  } from '../../hierarchy.ts'
6
+ import { setFontSize } from '../../setFontSize.ts'
6
7
 
7
8
  import type { HierarchyNode } from '../../hierarchy.ts'
8
9
  import type { MsaViewModel } from '../../model.ts'
@@ -288,8 +289,7 @@ export function renderTreeCanvas({
288
289
  ctx.scale(k, k)
289
290
  ctx.translate(marginLeft, -offsetY)
290
291
 
291
- const font = ctx.font
292
- ctx.font = font.replace(/\d+px/, `${fontSize}px`)
292
+ setFontSize(ctx, fontSize)
293
293
 
294
294
  // memoized on the model and shared across the tree/bubble/label passes (and
295
295
  // across all tree blocks) rather than re-traversing the hierarchy in each
package/src/constants.ts CHANGED
@@ -10,6 +10,14 @@ export const defaultHideGaps = true
10
10
  export const defaultAllowedGappyness = 100
11
11
  export const defaultSubFeatureRows = false
12
12
  export const defaultDrawMsaLetters = true
13
+ export const defaultScrollZoom = false
14
+
15
+ // Zoom limits, px per cell. maxCellSize caps both smooth zoom and the stepwise
16
+ // zoom-in buttons (which otherwise grow unbounded). minColWidth/minRowHeight are
17
+ // the smooth-zoom floors.
18
+ export const minColWidth = 0.2
19
+ export const minRowHeight = 1
20
+ export const maxCellSize = 80
13
21
 
14
22
  // MSA model defaults
15
23
  export const defaultBgColor = true
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { flatToTree } from './flatToTree.ts'
4
+
5
+ describe('flatToTree', () => {
6
+ test('builds a tree from a flat parent list', () => {
7
+ const root = flatToTree([
8
+ { id: 0 },
9
+ { id: 1, parent: 0 },
10
+ { id: 2, parent: 0 },
11
+ { id: 3, parent: 1 },
12
+ ])
13
+ expect(root.id).toBe('0')
14
+ expect(root.children.map(c => c.id)).toEqual(['1', '2'])
15
+ expect(root.children[0]!.children.map(c => c.id)).toEqual(['3'])
16
+ })
17
+
18
+ test('throws on empty input rather than returning undefined', () => {
19
+ expect(() => flatToTree([])).toThrow(/no root/)
20
+ })
21
+
22
+ test('throws when every node has a parent (cycle) rather than returning undefined', () => {
23
+ expect(() =>
24
+ flatToTree([
25
+ { id: 0, parent: 1 },
26
+ { id: 1, parent: 0 },
27
+ ]),
28
+ ).toThrow(/no root/)
29
+ })
30
+ })
package/src/flatToTree.ts CHANGED
@@ -35,5 +35,14 @@ export function flatToTree(items: FlatItem[]): TreeNode {
35
35
  }
36
36
  }
37
37
 
38
- return root!
38
+ if (root === undefined) {
39
+ // no node lacks a (resolvable) parent: empty input, or every node's parent
40
+ // points back into the set (a cycle). returning undefined here would crash
41
+ // cryptically downstream, so fail with a diagnosable message instead
42
+ throw new Error(
43
+ 'could not build tree from flat node list: no root found (input was empty or contains a parent cycle)',
44
+ )
45
+ }
46
+
47
+ return root
39
48
  }
@@ -1,9 +1,36 @@
1
1
  import { describe, expect, test } from 'vitest'
2
2
 
3
- import { collapse, find, hierarchy, leaves, sort, sum } from './hierarchy.ts'
3
+ import {
4
+ calcDepthToLeaf,
5
+ collapse,
6
+ find,
7
+ findMaxBranchLen,
8
+ hierarchy,
9
+ leaves,
10
+ maxLength,
11
+ setBrLength,
12
+ sort,
13
+ sum,
14
+ } from './hierarchy.ts'
4
15
 
5
16
  import type { NodeWithIds } from './types.ts'
6
17
 
18
+ // Builds a fully unbalanced "caterpillar" tree of the given depth: each internal
19
+ // node has one leaf child and one internal child. Recursion over this would blow
20
+ // the call stack; the iterative traversals must handle it.
21
+ function makeDeepTree(depth: number): NodeWithIds {
22
+ let node: NodeWithIds = { id: 'leaf0', name: 'leaf0', children: [] }
23
+ for (let i = 1; i <= depth; i++) {
24
+ node = {
25
+ id: `node${i}`,
26
+ name: `node${i}`,
27
+ length: 1,
28
+ children: [{ id: `leaf${i}`, name: `leaf${i}`, children: [] }, node],
29
+ }
30
+ }
31
+ return node
32
+ }
33
+
7
34
  function makeTree(): NodeWithIds {
8
35
  return {
9
36
  id: 'root',
@@ -118,3 +145,28 @@ describe('sum', () => {
118
145
  expect(find(h, n => n.data.id === 'A')?.value).toBe(2)
119
146
  })
120
147
  })
148
+
149
+ describe('deep (caterpillar) trees do not overflow the stack', () => {
150
+ const depth = 100_000
151
+
152
+ test('build, height, leaf count, and ordering', () => {
153
+ const h = hierarchy(makeDeepTree(depth), d => d.children)
154
+ expect(h.height).toBe(depth)
155
+ const l = leaves(h)
156
+ expect(l.length).toBe(depth + 1)
157
+ // each level contributes its own leafN before descending; deepest leaf last
158
+ expect(l[0]!.data.name).toBe(`leaf${depth}`)
159
+ expect(l.at(-1)!.data.name).toBe('leaf0')
160
+ })
161
+
162
+ test('accumulation helpers (sum, find, maxLength, setBrLength, depth)', () => {
163
+ const h = hierarchy(makeDeepTree(depth), d => d.children)
164
+ sum(h, d => (d.children.length > 0 ? 0 : 1))
165
+ expect(h.value).toBe(depth + 1)
166
+ expect(find(h, n => n.data.id === 'leaf0')?.data.name).toBe('leaf0')
167
+ expect(maxLength(h)).toBe(depth)
168
+ expect(calcDepthToLeaf(h)).toBe(depth)
169
+ setBrLength(h, 0, 1)
170
+ expect(findMaxBranchLen(h)).toBe(depth)
171
+ })
172
+ })