react-msaview 5.0.13 → 5.0.16

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.
@@ -0,0 +1,205 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ function calcDepthToLeaf(node: any): number {
4
+ if (node.depthToLeaf !== undefined) {
5
+ return node.depthToLeaf
6
+ }
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
+ }
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
+ }
25
+ }
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
37
+ }
38
+ const depthToLeaf = calcDepthToLeaf(node)
39
+ return ((maxDepthToLeaf - depthToLeaf) / maxDepthToLeaf) * maxBranchLen
40
+ }
41
+
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
+ })
86
+ })
87
+
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
+ })
125
+ })
126
+
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
+ })
204
+ })
205
+ })
@@ -1,5 +1,6 @@
1
1
  import { descendants, links } from '../../hierarchy.ts'
2
2
 
3
+ import type { HierarchyNode } from '../../hierarchy.ts'
3
4
  import type { MsaViewModel } from '../../model.ts'
4
5
  import type { Theme } from '@mui/material'
5
6
 
@@ -9,6 +10,53 @@ const extendBounds = 5
9
10
  const radius = 2.5
10
11
  const d = radius * 2
11
12
 
13
+ // Cladogram positioning algorithm based on ape package's plot.phylo
14
+ // Uses topological depth (steps to tips) instead of branch length for x-positioning
15
+ // This ensures all leaf nodes align at the same x-coordinate (rightmost position)
16
+ // See: https://github.com/emmanuelparadis/ape/blob/master/R/plot.phylo.R
17
+ function calcDepthToLeaf(node: HierarchyNode): number {
18
+ if (node.depthToLeaf !== undefined) {
19
+ return node.depthToLeaf
20
+ }
21
+ if (!node.children || node.children.length === 0) {
22
+ node.depthToLeaf = 0
23
+ } else {
24
+ let maxDepth = 0
25
+ for (const child of node.children) {
26
+ maxDepth = Math.max(maxDepth, 1 + calcDepthToLeaf(child))
27
+ }
28
+ node.depthToLeaf = maxDepth
29
+ }
30
+ return node.depthToLeaf
31
+ }
32
+
33
+ function findMaxBranchLen(node: HierarchyNode): number {
34
+ let maxLen = node.len || 0
35
+ if (node.children) {
36
+ for (const child of node.children) {
37
+ maxLen = Math.max(maxLen, findMaxBranchLen(child))
38
+ }
39
+ }
40
+ return maxLen
41
+ }
42
+
43
+ // Calculate node x-coordinate for both phylogram (with branch lengths) and cladogram (topology only) modes
44
+ // For cladograms: x = (maxDepthToLeaf - nodeDepthToLeaf) / maxDepthToLeaf * maxWidth
45
+ // This positions: leaves at maxWidth (rightmost), root at 0 (leftmost), internal nodes proportionally in between
46
+ // Matches ape's: xx <- max(xx) - xx (where xx is depth from each node to tips)
47
+ function getNodeX(
48
+ node: HierarchyNode,
49
+ showBranchLen: boolean,
50
+ maxBranchLen: number,
51
+ maxDepthToLeaf: number,
52
+ ): number | undefined {
53
+ if (showBranchLen) {
54
+ return node.len
55
+ }
56
+ const depthToLeaf = calcDepthToLeaf(node)
57
+ return ((maxDepthToLeaf - depthToLeaf) / maxDepthToLeaf) * maxBranchLen
58
+ }
59
+
12
60
  interface ClickEntry {
13
61
  name: string
14
62
  id: string
@@ -47,15 +95,14 @@ export function renderTree({
47
95
  const { hierarchy, showBranchLenEffective: showBranchLen, blockSize } = model
48
96
  const by = blockSizeYOverride || blockSize
49
97
  ctx.strokeStyle = theme.palette.text.primary
98
+ const maxBranchLen = findMaxBranchLen(hierarchy)
99
+ const maxDepthToLeaf = calcDepthToLeaf(hierarchy)
50
100
  for (const link of links(hierarchy)) {
51
101
  const { source, target } = link
52
- if (target.height === 0 && !showBranchLen) {
53
- continue
54
- }
55
102
  const sy = source.x!
56
103
  const ty = target.x!
57
- const tx = showBranchLen ? target.len : target.y
58
- const sx = showBranchLen ? source.len : source.y
104
+ const tx = getNodeX(target, showBranchLen, maxBranchLen, maxDepthToLeaf)
105
+ const sx = getNodeX(source, showBranchLen, maxBranchLen, maxDepthToLeaf)
59
106
  if (tx === undefined || sx === undefined) {
60
107
  continue
61
108
  }
@@ -96,8 +143,10 @@ export function renderNodeBubbles({
96
143
  marginLeft: ml,
97
144
  } = model
98
145
  const by = blockSizeYOverride || blockSize
146
+ const maxBranchLen = findMaxBranchLen(hierarchy)
147
+ const maxDepthToLeaf = calcDepthToLeaf(hierarchy)
99
148
  for (const node of descendants(hierarchy)) {
100
- const x = showBranchLen ? node.len : node.y
149
+ const x = getNodeX(node, showBranchLen, maxBranchLen, maxDepthToLeaf)
101
150
  if (x === undefined) {
102
151
  continue
103
152
  }
@@ -105,7 +154,7 @@ export function renderNodeBubbles({
105
154
  const y = node.x!
106
155
  const { id, name } = data
107
156
  if (
108
- node.height > 1 &&
157
+ node.height >= 1 &&
109
158
  y > offsetY - extendBounds &&
110
159
  y < offsetY + by + extendBounds
111
160
  ) {
@@ -156,6 +205,7 @@ export function renderTreeLabels({
156
205
  marginLeft,
157
206
  leaves,
158
207
  noTree,
208
+ hierarchy,
159
209
  } = model
160
210
  const by = blockSizeYOverride || blockSize
161
211
  const emHeight = ctx.measureText('M').width
@@ -165,13 +215,13 @@ export function renderTreeLabels({
165
215
  } else {
166
216
  ctx.textAlign = 'start'
167
217
  }
218
+ const maxBranchLen = findMaxBranchLen(hierarchy)
219
+ const maxDepthToLeaf = calcDepthToLeaf(hierarchy)
168
220
  for (const node of leaves) {
169
221
  const {
170
222
  data: { name, id },
171
223
  } = node
172
- const len = node.len
173
224
  const y = node.x!
174
- const x = node.y!
175
225
 
176
226
  const displayName = treeMetadata[name]?.genome || name
177
227
  if (y > offsetY - extendBounds && y < offsetY + by + extendBounds) {
@@ -179,7 +229,7 @@ export function renderTreeLabels({
179
229
  const yp = y + fontSize / 4
180
230
  let xp = 0
181
231
  if (!noTree) {
182
- xp = (showBranchLen ? len : x) || 0
232
+ xp = getNodeX(node, showBranchLen, maxBranchLen, maxDepthToLeaf) || 0
183
233
  }
184
234
 
185
235
  const { width } = ctx.measureText(displayName)
package/src/hierarchy.ts CHANGED
@@ -10,6 +10,7 @@ export interface HierarchyNode<T = NodeWithIds> {
10
10
  x?: number
11
11
  y?: number
12
12
  len?: number
13
+ depthToLeaf?: number
13
14
  _children?: HierarchyNode<T>[] | null
14
15
  }
15
16
 
package/src/model.ts CHANGED
@@ -337,6 +337,7 @@ function stateModelFactory() {
337
337
  /**
338
338
  * #volatile
339
339
  */
340
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
340
341
  error: undefined as unknown,
341
342
 
342
343
  /**
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '5.0.13'
1
+ export const version = '5.0.16'