react-msaview 5.6.1 → 5.6.3

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 (94) hide show
  1. package/bundle/index.js +95 -95
  2. package/bundle/index.js.map +4 -4
  3. package/dist/clustalX.d.ts +4 -3
  4. package/dist/clustalX.js +25 -4
  5. package/dist/clustalX.js.map +1 -1
  6. package/dist/columnCounts.d.ts +37 -0
  7. package/dist/columnCounts.js +148 -0
  8. package/dist/columnCounts.js.map +1 -0
  9. package/dist/components/MSAView.js +1 -8
  10. package/dist/components/MSAView.js.map +1 -1
  11. package/dist/components/getVisibleLeaves.d.ts +6 -0
  12. package/dist/components/getVisibleLeaves.js.map +1 -0
  13. package/dist/components/msa/MSACanvasBlock.js +17 -54
  14. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  15. package/dist/components/msa/renderBoxFeatureCanvasBlock.d.ts +2 -1
  16. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +21 -19
  17. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  18. package/dist/components/msa/renderMSABlock.js +97 -96
  19. package/dist/components/msa/renderMSABlock.js.map +1 -1
  20. package/dist/components/msa/renderMSAMouseover.js +9 -32
  21. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  22. package/dist/components/msa/useMsaBlockMouse.d.ts +23 -0
  23. package/dist/components/msa/useMsaBlockMouse.js +45 -0
  24. package/dist/components/msa/useMsaBlockMouse.js.map +1 -0
  25. package/dist/components/tree/TreeCanvas.js +14 -19
  26. package/dist/components/tree/TreeCanvas.js.map +1 -1
  27. package/dist/components/tree/TreeCanvasBlock.js +40 -111
  28. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  29. package/dist/components/tree/renderTreeCanvas.d.ts +2 -0
  30. package/dist/components/tree/renderTreeCanvas.js +15 -3
  31. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  32. package/dist/components/tree/useTreeHover.d.ts +24 -0
  33. package/dist/components/tree/useTreeHover.js +44 -0
  34. package/dist/components/tree/useTreeHover.js.map +1 -0
  35. package/dist/hierarchy.js +15 -9
  36. package/dist/hierarchy.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/model/msaModel.js +4 -3
  39. package/dist/model/msaModel.js.map +1 -1
  40. package/dist/model/treeModel.js +9 -8
  41. package/dist/model/treeModel.js.map +1 -1
  42. package/dist/model.d.ts +52 -10
  43. package/dist/model.js +208 -93
  44. package/dist/model.js.map +1 -1
  45. package/dist/propertyConservation.d.ts +2 -1
  46. package/dist/propertyConservation.js +36 -25
  47. package/dist/propertyConservation.js.map +1 -1
  48. package/dist/seqPosToGlobalCol.d.ts +7 -0
  49. package/dist/seqPosToGlobalCol.js +17 -0
  50. package/dist/seqPosToGlobalCol.js.map +1 -1
  51. package/dist/stripDefault.d.ts +21 -0
  52. package/dist/stripDefault.js +26 -0
  53. package/dist/stripDefault.js.map +1 -0
  54. package/dist/types.d.ts +16 -0
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/package.json +3 -3
  58. package/src/clustalX.test.ts +9 -11
  59. package/src/clustalX.ts +27 -28
  60. package/src/columnCounts.test.ts +58 -0
  61. package/src/columnCounts.ts +170 -0
  62. package/src/components/MSAView.tsx +1 -9
  63. package/src/components/{msa/getVisibleLeaves.ts → getVisibleLeaves.ts} +1 -1
  64. package/src/components/msa/MSACanvasBlock.tsx +42 -110
  65. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +25 -18
  66. package/src/components/msa/renderMSABlock.ts +113 -125
  67. package/src/components/msa/renderMSAMouseover.ts +11 -34
  68. package/src/components/msa/useMsaBlockMouse.ts +81 -0
  69. package/src/components/tree/TreeCanvas.tsx +16 -23
  70. package/src/components/tree/TreeCanvasBlock.tsx +62 -143
  71. package/src/components/tree/renderTreeCanvas.test.ts +100 -190
  72. package/src/components/tree/renderTreeCanvas.ts +14 -3
  73. package/src/components/tree/useTreeHover.ts +69 -0
  74. package/src/domainBands.test.ts +98 -0
  75. package/src/hierarchy.ts +15 -9
  76. package/src/index.ts +7 -0
  77. package/src/model/msaModel.ts +4 -6
  78. package/src/model/treeModel.ts +9 -14
  79. package/src/model.ts +241 -112
  80. package/src/propertyConservation.test.ts +2 -13
  81. package/src/propertyConservation.ts +40 -31
  82. package/src/rowSpace.test.ts +46 -0
  83. package/src/seqPosToGlobalCol.ts +18 -0
  84. package/src/stripDefault.test.ts +48 -0
  85. package/src/stripDefault.ts +35 -0
  86. package/src/types.ts +23 -0
  87. package/src/version.ts +1 -1
  88. package/dist/components/msa/getVisibleLeaves.d.ts +0 -6
  89. package/dist/components/msa/getVisibleLeaves.js.map +0 -1
  90. package/dist/useDevicePixelRatio.d.ts +0 -1
  91. package/dist/useDevicePixelRatio.js +0 -27
  92. package/dist/useDevicePixelRatio.js.map +0 -1
  93. package/src/useDevicePixelRatio.ts +0 -29
  94. /package/dist/components/{msa/getVisibleLeaves.js → getVisibleLeaves.js} +0 -0
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef, useState } from 'react'
1
+ import React, { useState } from 'react'
2
2
 
3
3
  import { makeStyles } from '@jbrowse/core/util/tss-react'
4
4
  import { useTheme } from '@mui/material'
@@ -6,11 +6,10 @@ import { observer } from 'mobx-react'
6
6
 
7
7
  import TreeBranchMenu from './TreeBranchMenu.tsx'
8
8
  import TreeNodeMenu from './TreeNodeMenu.tsx'
9
- import { ClickMapIndex } from './clickMap.ts'
10
9
  import { padding, renderTreeCanvas } from './renderTreeCanvas.ts'
10
+ import { useTreeHover } from './useTreeHover.ts'
11
11
  import { useCanvasAutorun } from '../../useCanvasAutorun.ts'
12
12
 
13
- import type { ClickEntry } from './clickMap.ts'
14
13
  import type { MsaViewModel } from '../../model.ts'
15
14
 
16
15
  const useStyles = makeStyles()(theme => ({
@@ -28,9 +27,15 @@ const useStyles = makeStyles()(theme => ({
28
27
  overflow: 'hidden',
29
28
  textOverflow: 'ellipsis',
30
29
  },
30
+ hover: {
31
+ position: 'absolute',
32
+ pointerEvents: 'none',
33
+ zIndex: 100,
34
+ background: 'rgba(0,0,0,0.1)',
35
+ },
31
36
  }))
32
37
 
33
- interface TooltipData {
38
+ interface MenuData {
34
39
  name: string
35
40
  id: string
36
41
  x: number
@@ -46,88 +51,35 @@ const TreeCanvasBlock = observer(function ({
46
51
  }) {
47
52
  const { classes } = useStyles()
48
53
  const theme = useTheme()
49
- const clickMap = useRef(new ClickMapIndex())
50
- const mouseoverRef = useRef<HTMLCanvasElement>(null)
51
- const [branchMenu, setBranchMenu] = useState<TooltipData>()
52
- const [toggleNodeMenu, setToggleNodeMenu] = useState<TooltipData>()
53
- const [hoverElt, setHoverElt] = useState<ClickEntry>()
54
- const [tooltipInfo, setTooltipInfo] = useState<{
55
- name: string
56
- x: number
57
- y: number
58
- }>()
54
+ const [branchMenu, setBranchMenu] = useState<MenuData>()
55
+ const [nodeMenu, setNodeMenu] = useState<MenuData>()
56
+ const { clickMap, hovered, hitTest, onMouseMove, onMouseLeave } =
57
+ useTreeHover({ model, offsetY })
59
58
 
60
59
  const { scrollY, treeAreaWidth, blockSize, highResScaleFactor } = model
61
-
62
60
  const width = treeAreaWidth + padding
63
61
  const height = blockSize
64
- const w2 = width * highResScaleFactor
65
- const h2 = height * highResScaleFactor
66
62
 
67
63
  const ref = useCanvasAutorun(
68
64
  ctx => {
69
65
  ctx.resetTransform()
70
- ctx.clearRect(0, 0, w2, h2)
66
+ ctx.clearRect(
67
+ 0,
68
+ 0,
69
+ width * highResScaleFactor,
70
+ height * highResScaleFactor,
71
+ )
71
72
  renderTreeCanvas({
72
73
  ctx,
73
74
  model,
74
75
  offsetY,
75
- clickMap: clickMap.current,
76
+ clickMap,
76
77
  theme,
77
78
  })
78
79
  },
79
- [model, blockSize, highResScaleFactor, treeAreaWidth, offsetY, theme],
80
+ [model, clickMap, offsetY, theme],
80
81
  )
81
82
 
82
- useEffect(() => {
83
- const ctx = mouseoverRef.current?.getContext('2d')
84
- if (!ctx) {
85
- return
86
- }
87
-
88
- ctx.resetTransform()
89
- ctx.clearRect(0, 0, treeAreaWidth + padding, blockSize)
90
- ctx.translate(0, -offsetY)
91
-
92
- // Highlight tree element being directly hovered
93
- if (hoverElt) {
94
- const { minX, maxX, minY, maxY } = hoverElt
95
-
96
- ctx.fillStyle = 'rgba(0,0,0,0.1)'
97
- ctx.fillRect(minX, minY, maxX - minX, maxY - minY)
98
- }
99
- }, [hoverElt, offsetY, blockSize, treeAreaWidth])
100
-
101
- function hoverBranchClickMap(event: React.MouseEvent) {
102
- const x = event.nativeEvent.offsetX
103
- const y = event.nativeEvent.offsetY
104
-
105
- const [entry] = clickMap.current.search({
106
- minX: x,
107
- maxX: x + 1,
108
- minY: y + offsetY,
109
- maxY: y + 1 + offsetY,
110
- })
111
-
112
- return entry?.branch
113
- ? { ...entry, x: event.clientX, y: event.clientY }
114
- : undefined
115
- }
116
-
117
- function hoverNameClickMap(event: React.MouseEvent) {
118
- const x = event.nativeEvent.offsetX
119
- const y = event.nativeEvent.offsetY
120
- const [entry] = clickMap.current.search({
121
- minX: x,
122
- maxX: x + 1,
123
- minY: y + offsetY,
124
- maxY: y + 1 + offsetY,
125
- })
126
-
127
- return entry && !entry.branch
128
- ? { ...entry, x: event.clientX, y: event.clientY }
129
- : undefined
130
- }
131
83
  const style = {
132
84
  width,
133
85
  height,
@@ -135,9 +87,10 @@ const TreeCanvasBlock = observer(function ({
135
87
  left: 0,
136
88
  position: 'absolute',
137
89
  } as const
90
+
138
91
  return (
139
92
  <>
140
- {branchMenu?.id ? (
93
+ {branchMenu ? (
141
94
  <TreeBranchMenu
142
95
  node={branchMenu}
143
96
  model={model}
@@ -147,98 +100,64 @@ const TreeCanvasBlock = observer(function ({
147
100
  />
148
101
  ) : null}
149
102
 
150
- {toggleNodeMenu?.id ? (
103
+ {nodeMenu ? (
151
104
  <TreeNodeMenu
152
- node={toggleNodeMenu}
105
+ node={nodeMenu}
153
106
  model={model}
154
107
  onClose={() => {
155
- setToggleNodeMenu(undefined)
108
+ setNodeMenu(undefined)
156
109
  }}
157
110
  />
158
111
  ) : null}
159
112
 
160
113
  <canvas
161
- width={w2}
162
- height={h2}
163
- style={style}
114
+ ref={ref}
115
+ width={width * highResScaleFactor}
116
+ height={height * highResScaleFactor}
117
+ style={{ ...style, cursor: hovered ? 'pointer' : 'default' }}
164
118
  onMouseMove={event => {
165
- if (!ref.current) {
166
- return
167
- }
168
-
169
- const hoveredLeaf = hoverNameClickMap(event)
170
- const hoveredBranch = hoverBranchClickMap(event)
171
- const hoveredAny = hoveredLeaf || hoveredBranch
172
-
173
- ref.current.style.cursor = hoveredAny ? 'pointer' : 'default'
174
- setHoverElt(hoveredLeaf) // Only show direct hover highlight for leaf nodes
175
-
176
- // Set tooltip info
177
- if (hoveredAny) {
178
- setTooltipInfo({
179
- name: hoveredAny.name,
119
+ onMouseMove(event)
120
+ }}
121
+ onMouseLeave={() => {
122
+ onMouseLeave()
123
+ }}
124
+ onClick={event => {
125
+ const entry = hitTest(event)
126
+ if (entry) {
127
+ const menu = {
180
128
  x: event.clientX,
181
129
  y: event.clientY,
182
- })
183
- } else {
184
- setTooltipInfo(undefined)
185
- }
186
-
187
- // Handle tree node hover for multi-row highlighting
188
- if (hoveredAny) {
189
- model.setHoveredTreeNode(hoveredAny.id)
190
-
191
- // For leaf nodes, also set single row highlight for backward compatibility
192
- if (hoveredLeaf?.name) {
193
- const rowIndex = model.rowNamesSet.get(hoveredLeaf.name)
194
- if (rowIndex !== undefined) {
195
- model.setMousePos(undefined, rowIndex)
196
- }
130
+ id: entry.id,
131
+ name: entry.name,
132
+ }
133
+ if (entry.branch) {
134
+ setBranchMenu(menu)
135
+ } else {
136
+ setNodeMenu(menu)
197
137
  }
198
- } else {
199
- // Clear all highlighting when not hovering over any tree node
200
- model.setHoveredTreeNode(undefined)
201
- model.setMousePos(undefined, undefined)
202
138
  }
203
139
  }}
204
- onClick={event => {
205
- const { clientX: x, clientY: y } = event
140
+ />
206
141
 
207
- const data = hoverBranchClickMap(event)
208
- if (data?.id) {
209
- setBranchMenu({ x, y, id: data.id, name: data.name })
210
- }
142
+ {/* direct-hover highlight, drawn only for leaf labels */}
143
+ {hovered && !hovered.branch ? (
144
+ <div
145
+ className={classes.hover}
146
+ style={{
147
+ left: hovered.minX,
148
+ top: hovered.minY + scrollY,
149
+ width: hovered.maxX - hovered.minX,
150
+ height: hovered.maxY - hovered.minY,
151
+ }}
152
+ />
153
+ ) : null}
211
154
 
212
- const data2 = hoverNameClickMap(event)
213
- if (data2?.id) {
214
- setToggleNodeMenu({ ...data2, x, y })
215
- }
216
- }}
217
- onMouseLeave={() => {
218
- setHoverElt(undefined)
219
- setTooltipInfo(undefined)
220
- // Clear all highlighting when leaving tree area
221
- model.setHoveredTreeNode(undefined)
222
- model.setMousePos(undefined, undefined)
223
- }}
224
- ref={ref}
225
- />
226
- <canvas
227
- style={{
228
- ...style,
229
- pointerEvents: 'none',
230
- zIndex: 100,
231
- }}
232
- width={width}
233
- height={height}
234
- ref={mouseoverRef}
235
- />
236
- {tooltipInfo ? (
155
+ {hovered ? (
237
156
  <div
238
157
  className={classes.tooltip}
239
- style={{ left: tooltipInfo.x + 12, top: tooltipInfo.y + 12 }}
158
+ style={{ left: hovered.clientX + 12, top: hovered.clientY + 12 }}
240
159
  >
241
- {tooltipInfo.name}
160
+ {hovered.name}
242
161
  </div>
243
162
  ) : null}
244
163
  </>
@@ -1,205 +1,115 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- function calcDepthToLeaf(node: any): number {
4
- if (node.depthToLeaf !== undefined) {
5
- return node.depthToLeaf
3
+ import { getNodeX } from './renderTreeCanvas.ts'
4
+ import { calcDepthToLeaf, findMaxBranchLen } from '../../hierarchy.ts'
5
+
6
+ import type { HierarchyNode } from '../../hierarchy.ts'
7
+
8
+ function leaf(id: string, len?: number): HierarchyNode {
9
+ return {
10
+ data: { id, name: id, children: [] },
11
+ children: null,
12
+ parent: null,
13
+ depth: 0,
14
+ height: 0,
15
+ len,
6
16
  }
7
- if (!node.children || node.children.length === 0) {
8
- node.depthToLeaf = 0
9
- } else {
10
- let maxDepth = 0
11
- for (const child of node.children) {
12
- maxDepth = Math.max(maxDepth, 1 + calcDepthToLeaf(child))
13
- }
14
- node.depthToLeaf = maxDepth
15
- }
16
- return node.depthToLeaf
17
17
  }
18
18
 
19
- function findMaxBranchLen(node: any): number {
20
- let maxLen = node.len || 0
21
- if (node.children) {
22
- for (const child of node.children) {
23
- maxLen = Math.max(maxLen, findMaxBranchLen(child))
24
- }
19
+ function internal(
20
+ id: string,
21
+ children: HierarchyNode[],
22
+ len?: number,
23
+ ): HierarchyNode {
24
+ const node: HierarchyNode = {
25
+ data: { id, name: id, children: children.map(c => c.data) },
26
+ children,
27
+ parent: null,
28
+ depth: 0,
29
+ height: 1,
30
+ len,
25
31
  }
26
- return maxLen
27
- }
28
-
29
- function getNodeX(
30
- node: any,
31
- showBranchLen: boolean,
32
- maxBranchLen: number,
33
- maxDepthToLeaf: number,
34
- ): number | undefined {
35
- if (showBranchLen) {
36
- return node.len
32
+ for (const child of children) {
33
+ child.parent = node
37
34
  }
38
- const depthToLeaf = calcDepthToLeaf(node)
39
- return ((maxDepthToLeaf - depthToLeaf) / maxDepthToLeaf) * maxBranchLen
35
+ return node
40
36
  }
41
37
 
42
- describe('Tree rendering positioning', () => {
43
- describe('calcDepthToLeaf', () => {
44
- it('should return 0 for leaf nodes', () => {
45
- const leaf: any = { id: 'leaf', data: { id: 'leaf' }, children: null }
46
- expect(calcDepthToLeaf(leaf)).toBe(0)
47
- })
48
-
49
- it('should return 1 for nodes with only leaf children', () => {
50
- const leaf1: any = { id: 'leaf1', data: { id: 'leaf1' }, children: null }
51
- const leaf2: any = { id: 'leaf2', data: { id: 'leaf2' }, children: null }
52
- const parent: any = {
53
- id: 'parent',
54
- data: { id: 'parent' },
55
- children: [leaf1, leaf2],
56
- }
57
- expect(calcDepthToLeaf(parent)).toBe(1)
58
- })
59
-
60
- it('should return correct depth for nested tree', () => {
61
- const leaf1: any = { id: 'leaf1', data: { id: 'leaf1' }, children: null }
62
- const leaf2: any = { id: 'leaf2', data: { id: 'leaf2' }, children: null }
63
- const intermediate: any = {
64
- id: 'int',
65
- data: { id: 'int' },
66
- children: [leaf1, leaf2],
67
- }
68
- const root: any = {
69
- id: 'root',
70
- data: { id: 'root' },
71
- children: [intermediate],
72
- }
73
-
74
- expect(calcDepthToLeaf(leaf1)).toBe(0)
75
- expect(calcDepthToLeaf(intermediate)).toBe(1)
76
- expect(calcDepthToLeaf(root)).toBe(2)
77
- })
78
-
79
- it('should cache result', () => {
80
- const leaf: any = { id: 'leaf', data: { id: 'leaf' }, children: null }
81
- const depth1 = calcDepthToLeaf(leaf)
82
- const depth2 = calcDepthToLeaf(leaf)
83
- expect(depth1).toBe(depth2)
84
- expect(leaf.depthToLeaf).toBeDefined()
85
- })
38
+ describe('calcDepthToLeaf', () => {
39
+ it('is 0 for a leaf', () => {
40
+ expect(calcDepthToLeaf(leaf('a'))).toBe(0)
41
+ })
42
+
43
+ it('counts steps to the deepest tip', () => {
44
+ const tips = [leaf('a'), leaf('b')]
45
+ const intermediate = internal('int', tips)
46
+ const root = internal('root', [intermediate])
47
+
48
+ expect(calcDepthToLeaf(tips[0]!)).toBe(0)
49
+ expect(calcDepthToLeaf(intermediate)).toBe(1)
50
+ expect(calcDepthToLeaf(root)).toBe(2)
51
+ })
52
+
53
+ it('memoizes without re-walking the subtree', () => {
54
+ // the renderer asks for every node's depth on every pass, so the memo has to
55
+ // short-circuit the traversal itself, not just the arithmetic. A sentinel
56
+ // planted on an already-computed child must survive a second root call.
57
+ const child = leaf('a')
58
+ const root = internal('root', [child])
59
+ expect(calcDepthToLeaf(root)).toBe(1)
60
+
61
+ child.depthToLeaf = 99
62
+ expect(calcDepthToLeaf(root)).toBe(1)
63
+ expect(child.depthToLeaf).toBe(99)
64
+ })
65
+ })
66
+
67
+ describe('findMaxBranchLen', () => {
68
+ it('uses the node itself when it is a leaf', () => {
69
+ expect(findMaxBranchLen(leaf('a', 1.5))).toBe(1.5)
70
+ })
71
+
72
+ it('takes the max across descendants', () => {
73
+ expect(
74
+ findMaxBranchLen(internal('p', [leaf('a', 0.5), leaf('b', 1.5)], 0.3)),
75
+ ).toBe(1.5)
76
+ })
77
+
78
+ it('treats a missing len as 0', () => {
79
+ expect(findMaxBranchLen(leaf('a'))).toBe(0)
80
+ })
81
+ })
82
+
83
+ describe('getNodeX cladogram positioning', () => {
84
+ it('aligns every tip at the rightmost x', () => {
85
+ const tips = [leaf('a'), leaf('b')]
86
+ calcDepthToLeaf(internal('root', tips))
87
+
88
+ const xs = tips.map(tip => getNodeX(tip, false, 100, 1))
89
+ expect(xs).toEqual([100, 100])
90
+ })
91
+
92
+ it('puts the root at the leftmost x and internal nodes in between', () => {
93
+ const tips = [leaf('a'), leaf('b')]
94
+ const intermediate = internal('int', tips)
95
+ const root = internal('root', [intermediate])
96
+ calcDepthToLeaf(root)
97
+
98
+ const xRoot = getNodeX(root, false, 100, 2)!
99
+ const xInt = getNodeX(intermediate, false, 100, 2)!
100
+ const xTip = getNodeX(tips[0]!, false, 100, 2)!
101
+
102
+ expect(xRoot).toBe(0)
103
+ expect(xInt).toBeGreaterThan(xRoot)
104
+ expect(xTip).toBeGreaterThan(xInt)
105
+ expect(xTip).toBe(100)
86
106
  })
87
107
 
88
- describe('findMaxBranchLen', () => {
89
- it('should return node len for leaf', () => {
90
- const leaf: any = {
91
- id: 'leaf',
92
- data: { id: 'leaf' },
93
- len: 1.5,
94
- children: null,
95
- }
96
- expect(findMaxBranchLen(leaf)).toBe(1.5)
97
- })
98
-
99
- it('should return max len from descendants', () => {
100
- const leaf1: any = {
101
- id: 'leaf1',
102
- data: { id: 'leaf1' },
103
- len: 0.5,
104
- children: null,
105
- }
106
- const leaf2: any = {
107
- id: 'leaf2',
108
- data: { id: 'leaf2' },
109
- len: 1.5,
110
- children: null,
111
- }
112
- const parent: any = {
113
- id: 'parent',
114
- data: { id: 'parent' },
115
- len: 0.3,
116
- children: [leaf1, leaf2],
117
- }
118
- expect(findMaxBranchLen(parent)).toBe(1.5)
119
- })
120
-
121
- it('should handle undefined len', () => {
122
- const leaf: any = { id: 'leaf', data: { id: 'leaf' }, children: null }
123
- expect(findMaxBranchLen(leaf)).toBe(0)
124
- })
108
+ it('collapses to the root x when there is no topological depth', () => {
109
+ expect(getNodeX(leaf('a'), false, 100, 0)).toBe(0)
125
110
  })
126
111
 
127
- describe('getNodeX cladogram positioning', () => {
128
- it('should position all leaves at rightmost for cladogram', () => {
129
- const leaf1: any = { id: 'leaf1', data: { id: 'leaf1' }, children: null }
130
- const leaf2: any = { id: 'leaf2', data: { id: 'leaf2' }, children: null }
131
- const root: any = {
132
- id: 'root',
133
- data: { id: 'root' },
134
- children: [leaf1, leaf2],
135
- }
136
-
137
- calcDepthToLeaf(root)
138
- const maxBranchLen = 100
139
- const maxDepthToLeaf = 1
140
-
141
- const x1 = getNodeX(leaf1, false, maxBranchLen, maxDepthToLeaf)
142
- const x2 = getNodeX(leaf2, false, maxBranchLen, maxDepthToLeaf)
143
-
144
- expect(x1).toBe(maxBranchLen)
145
- expect(x2).toBe(maxBranchLen)
146
- expect(x1).toBe(x2)
147
- })
148
-
149
- it('should position root at leftmost for cladogram', () => {
150
- const leaf1: any = { id: 'leaf1', data: { id: 'leaf1' }, children: null }
151
- const leaf2: any = { id: 'leaf2', data: { id: 'leaf2' }, children: null }
152
- const root: any = {
153
- id: 'root',
154
- data: { id: 'root' },
155
- children: [leaf1, leaf2],
156
- }
157
-
158
- calcDepthToLeaf(root)
159
- const maxBranchLen = 100
160
- const maxDepthToLeaf = 1
161
-
162
- const xRoot = getNodeX(root, false, maxBranchLen, maxDepthToLeaf)
163
- expect(xRoot).toBe(0)
164
- })
165
-
166
- it('should position internal nodes between root and leaves', () => {
167
- const leaf1: any = { id: 'leaf1', data: { id: 'leaf1' }, children: null }
168
- const leaf2: any = { id: 'leaf2', data: { id: 'leaf2' }, children: null }
169
- const intermediate: any = {
170
- id: 'int',
171
- data: { id: 'int' },
172
- children: [leaf1, leaf2],
173
- }
174
- const root: any = {
175
- id: 'root',
176
- data: { id: 'root' },
177
- children: [intermediate],
178
- }
179
-
180
- calcDepthToLeaf(root)
181
- const maxBranchLen = 100
182
- const maxDepthToLeaf = 2
183
-
184
- const xRoot = getNodeX(root, false, maxBranchLen, maxDepthToLeaf)!
185
- const xInt = getNodeX(intermediate, false, maxBranchLen, maxDepthToLeaf)!
186
- const xLeaf = getNodeX(leaf1, false, maxBranchLen, maxDepthToLeaf)!
187
-
188
- expect(xRoot).toBe(0)
189
- expect(xInt).toBeGreaterThan(xRoot)
190
- expect(xLeaf).toBeGreaterThan(xInt)
191
- expect(xLeaf).toBe(maxBranchLen)
192
- })
193
-
194
- it('should use branch length when showBranchLen is true', () => {
195
- const leaf: any = {
196
- id: 'leaf',
197
- data: { id: 'leaf' },
198
- len: 2.5,
199
- children: null,
200
- }
201
- const x = getNodeX(leaf, true, 100, 1)
202
- expect(x).toBe(2.5)
203
- })
112
+ it('uses branch length in phylogram mode', () => {
113
+ expect(getNodeX(leaf('a', 2.5), true, 100, 1)).toBe(2.5)
204
114
  })
205
115
  })
@@ -4,6 +4,7 @@ import {
4
4
  forEachLink,
5
5
  } from '../../hierarchy.ts'
6
6
  import { setFontSize } from '../../setFontSize.ts'
7
+ import { getVisibleLeaves } from '../getVisibleLeaves.ts'
7
8
 
8
9
  import type { ClickMapIndex } from './clickMap.ts'
9
10
  import type { HierarchyNode } from '../../hierarchy.ts'
@@ -27,7 +28,7 @@ function inYBlock(y: number, offsetY: number, by: number) {
27
28
  // For cladograms: x = (maxDepthToLeaf - nodeDepthToLeaf) / maxDepthToLeaf * maxWidth
28
29
  // This positions: leaves at maxWidth (rightmost), root at 0 (leftmost), internal nodes proportionally in between
29
30
  // Matches ape's: xx <- max(xx) - xx (where xx is depth from each node to tips)
30
- function getNodeX(
31
+ export function getNodeX(
31
32
  node: HierarchyNode,
32
33
  showBranchLen: boolean,
33
34
  maxBranchLen: number,
@@ -253,20 +254,27 @@ export function renderTreeLabels({
253
254
  treeAreaWidth,
254
255
  treeAreaWidthMinusMargin,
255
256
  marginLeft,
256
- leaves,
257
257
  noTree,
258
258
  labelWidthMap,
259
259
  collapsed,
260
260
  } = model
261
+ // labels only exist for leaves, which are laid out top to bottom, so take the
262
+ // same slice the MSA renderer uses instead of walking every tip per block
263
+ const visibleLeaves = getVisibleLeaves({
264
+ model,
265
+ offsetY,
266
+ blockSizeY: blockSizeYOverride ?? model.blockSize,
267
+ })
261
268
  const by = blockSizeYOverride ?? blockSize
262
269
  const emHeight = ctx.measureText('M').width
263
270
  if (labelsAlignRight) {
264
271
  ctx.textAlign = 'right'
265
272
  ctx.setLineDash([1, 3])
273
+ ctx.strokeStyle = theme.palette.text.primary
266
274
  } else {
267
275
  ctx.textAlign = 'start'
268
276
  }
269
- for (const node of leaves) {
277
+ for (const node of visibleLeaves) {
270
278
  const {
271
279
  data: { name, id },
272
280
  } = node
@@ -292,6 +300,9 @@ export function renderTreeLabels({
292
300
  const smallPadding = 2
293
301
  const offset = treeAreaWidthMinusMargin - smallPadding
294
302
  if (drawTree && !noTree) {
303
+ // beginPath is load-bearing: without it every leader line re-strokes
304
+ // all the ones before it, which is quadratic in the label count
305
+ ctx.beginPath()
295
306
  ctx.moveTo(xp + radius + 2, y)
296
307
  ctx.lineTo(offset - smallPadding - width, y)
297
308
  ctx.stroke()