react-msaview 5.0.13 → 5.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 (89) hide show
  1. package/bundle/index.js +135 -35
  2. package/bundle/index.js.LICENSE.txt +1 -1
  3. package/bundle/index.js.map +1 -1
  4. package/dist/clustalX.d.ts +5 -0
  5. package/dist/clustalX.js +88 -0
  6. package/dist/clustalX.js.map +1 -0
  7. package/dist/components/SequenceTextArea.js +6 -6
  8. package/dist/components/SequenceTextArea.js.map +1 -1
  9. package/dist/components/TextTrack.js +3 -5
  10. package/dist/components/TextTrack.js.map +1 -1
  11. package/dist/components/Track.d.ts +2 -1
  12. package/dist/components/Track.js +4 -3
  13. package/dist/components/Track.js.map +1 -1
  14. package/dist/components/VerticalScrollbar.js +6 -31
  15. package/dist/components/VerticalScrollbar.js.map +1 -1
  16. package/dist/components/dialogs/InterProScanDialog.js +5 -3
  17. package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
  18. package/dist/components/header/HeaderStatusArea.js +4 -2
  19. package/dist/components/header/HeaderStatusArea.js.map +1 -1
  20. package/dist/components/header/MSASettingsMenu.js +2 -27
  21. package/dist/components/header/MSASettingsMenu.js.map +1 -1
  22. package/dist/components/header/SettingsMenu.js +4 -69
  23. package/dist/components/header/SettingsMenu.js.map +1 -1
  24. package/dist/components/header/TreeSettingsMenu.js +3 -41
  25. package/dist/components/header/TreeSettingsMenu.js.map +1 -1
  26. package/dist/components/header/settingsMenuItems.d.ts +4 -0
  27. package/dist/components/header/settingsMenuItems.js +59 -0
  28. package/dist/components/header/settingsMenuItems.js.map +1 -0
  29. package/dist/components/import/ImportFormExamples.js +1 -1
  30. package/dist/components/import/ImportFormExamples.js.map +1 -1
  31. package/dist/components/minimap/Minimap.js +6 -31
  32. package/dist/components/minimap/Minimap.js.map +1 -1
  33. package/dist/components/msa/MSACanvasBlock.js +31 -33
  34. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  35. package/dist/components/msa/renderMSABlock.js +15 -9
  36. package/dist/components/msa/renderMSABlock.js.map +1 -1
  37. package/dist/components/tree/TreeCanvas.js +12 -14
  38. package/dist/components/tree/TreeCanvas.js.map +1 -1
  39. package/dist/components/tree/renderTreeCanvas.d.ts +9 -3
  40. package/dist/components/tree/renderTreeCanvas.js +29 -14
  41. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  42. package/dist/fetchUtils.d.ts +2 -1
  43. package/dist/fetchUtils.js +16 -2
  44. package/dist/fetchUtils.js.map +1 -1
  45. package/dist/hierarchy.d.ts +3 -0
  46. package/dist/hierarchy.js +25 -0
  47. package/dist/hierarchy.js.map +1 -1
  48. package/dist/launchInterProScan.d.ts +2 -1
  49. package/dist/launchInterProScan.js +31 -15
  50. package/dist/launchInterProScan.js.map +1 -1
  51. package/dist/model.d.ts +150 -494
  52. package/dist/model.js +24 -116
  53. package/dist/model.js.map +1 -1
  54. package/dist/useColorContrast.d.ts +10 -0
  55. package/dist/useColorContrast.js +13 -0
  56. package/dist/useColorContrast.js.map +1 -0
  57. package/dist/useDragScroll.d.ts +12 -0
  58. package/dist/useDragScroll.js +47 -0
  59. package/dist/useDragScroll.js.map +1 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/dist/version.js.map +1 -1
  63. package/package.json +6 -6
  64. package/src/clustalX.test.ts +76 -0
  65. package/src/clustalX.ts +131 -0
  66. package/src/components/SequenceTextArea.tsx +6 -6
  67. package/src/components/TextTrack.tsx +3 -8
  68. package/src/components/Track.tsx +6 -9
  69. package/src/components/VerticalScrollbar.tsx +13 -40
  70. package/src/components/dialogs/InterProScanDialog.tsx +7 -1
  71. package/src/components/header/HeaderStatusArea.tsx +6 -1
  72. package/src/components/header/MSASettingsMenu.tsx +3 -27
  73. package/src/components/header/SettingsMenu.tsx +7 -79
  74. package/src/components/header/TreeSettingsMenu.tsx +4 -48
  75. package/src/components/header/settingsMenuItems.ts +68 -0
  76. package/src/components/import/ImportFormExamples.tsx +1 -1
  77. package/src/components/minimap/Minimap.tsx +13 -38
  78. package/src/components/msa/MSACanvasBlock.tsx +53 -36
  79. package/src/components/msa/renderMSABlock.ts +16 -9
  80. package/src/components/tree/TreeCanvas.tsx +13 -35
  81. package/src/components/tree/renderTreeCanvas.test.ts +205 -0
  82. package/src/components/tree/renderTreeCanvas.ts +46 -11
  83. package/src/fetchUtils.ts +16 -2
  84. package/src/hierarchy.ts +28 -0
  85. package/src/launchInterProScan.ts +34 -10
  86. package/src/model.ts +33 -143
  87. package/src/useColorContrast.ts +18 -0
  88. package/src/useDragScroll.ts +55 -0
  89. package/src/version.ts +1 -1
@@ -1,18 +1,13 @@
1
- import React, { useEffect, useRef, useState } from 'react'
1
+ import React, { useCallback, useState } from 'react'
2
2
 
3
3
  import { observer } from 'mobx-react'
4
4
 
5
- import type { MsaViewModel } from '../../model.ts'
5
+ import { useDragScroll } from '../../useDragScroll.ts'
6
6
 
7
- interface ClickCoord {
8
- clientX: number
9
- scrollX: number
10
- }
7
+ import type { MsaViewModel } from '../../model.ts'
11
8
 
12
9
  const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
13
- const [mouseDown, setMouseDown] = useState<ClickCoord>()
14
10
  const [hovered, setHovered] = useState(false)
15
- const scheduled = useRef(false)
16
11
  const { scrollX, msaAreaWidth, minimapHeight, colWidth, numColumns } = model
17
12
  const unit = msaAreaWidth / numColumns / colWidth
18
13
  const left = -scrollX
@@ -22,32 +17,15 @@ const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
22
17
  const fill = 'rgba(66, 119, 127, 0.3)'
23
18
  const w = Math.max(e - s, 20)
24
19
 
25
- useEffect(() => {
26
- function fn(event: MouseEvent) {
27
- if (mouseDown !== undefined) {
28
- if (!scheduled.current) {
29
- scheduled.current = true
30
- window.requestAnimationFrame(() => {
31
- model.setScrollX(
32
- mouseDown.scrollX - (event.clientX - mouseDown.clientX) / unit,
33
- )
34
- scheduled.current = false
35
- })
36
- }
37
- }
38
- }
39
- function fn2() {
40
- setMouseDown(undefined)
41
- }
42
- if (mouseDown !== undefined) {
43
- document.addEventListener('mousemove', fn)
44
- document.addEventListener('mouseup', fn2)
45
- return () => {
46
- document.removeEventListener('mousemove', fn)
47
- document.removeEventListener('mousemove', fn2)
48
- }
49
- }
50
- }, [model, unit, mouseDown])
20
+ const { startDrag } = useDragScroll(
21
+ 'x',
22
+ useCallback(
23
+ (delta: number, startScroll: number) => {
24
+ model.setScrollX(startScroll - delta / unit)
25
+ },
26
+ [model, unit],
27
+ ),
28
+ )
51
29
 
52
30
  const barHeight = 12
53
31
  const polygonHeight = minimapHeight - barHeight
@@ -84,10 +62,7 @@ const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
84
62
  setHovered(false)
85
63
  }}
86
64
  onMouseDown={event => {
87
- setMouseDown({
88
- clientX: event.clientX,
89
- scrollX: model.scrollX,
90
- })
65
+ startDrag(event, model.scrollX)
91
66
  }}
92
67
  />
93
68
 
@@ -1,16 +1,30 @@
1
- import React, { useEffect, useMemo, useRef, useState } from 'react'
1
+ import React, { useEffect, useRef, useState } from 'react'
2
2
 
3
3
  import { BaseTooltip } from '@jbrowse/core/ui'
4
- import { useTheme } from '@mui/material'
5
4
  import { autorun } from 'mobx'
6
5
  import { observer } from 'mobx-react'
7
6
 
8
7
  import { renderBoxFeatureCanvasBlock } from './renderBoxFeatureCanvasBlock.ts'
9
8
  import { renderMSABlock } from './renderMSABlock.ts'
10
- import { colorContrast } from '../../util.ts'
9
+ import { useColorContrast } from '../../useColorContrast.ts'
11
10
 
12
11
  import type { MsaViewModel } from '../../model.ts'
13
12
 
13
+ function eventToColRow(
14
+ event: React.MouseEvent,
15
+ el: HTMLCanvasElement,
16
+ offsetX: number,
17
+ offsetY: number,
18
+ colWidth: number,
19
+ rowHeight: number,
20
+ ) {
21
+ const { left, top } = el.getBoundingClientRect()
22
+ return {
23
+ col: Math.floor((event.clientX - left + offsetX) / colWidth),
24
+ row: Math.floor((event.clientY - top + offsetY) / rowHeight),
25
+ }
26
+ }
27
+
14
28
  const MSACanvasBlock = observer(function ({
15
29
  model,
16
30
  offsetX,
@@ -31,12 +45,7 @@ const MSACanvasBlock = observer(function ({
31
45
  mouseClickRow,
32
46
  highResScaleFactor,
33
47
  } = model
34
- const theme = useTheme()
35
-
36
- const contrastScheme = useMemo(
37
- () => colorContrast(colorScheme, theme),
38
- [colorScheme, theme],
39
- )
48
+ const { theme, contrastScheme } = useColorContrast(colorScheme)
40
49
 
41
50
  const ref = useRef<HTMLCanvasElement>(null)
42
51
  useEffect(() => {
@@ -87,36 +96,44 @@ const MSACanvasBlock = observer(function ({
87
96
  <canvas
88
97
  ref={ref}
89
98
  onMouseMove={event => {
90
- if (!ref.current) {
91
- return
92
- }
93
- setMousePosition({ x: event.clientX, y: event.clientY })
94
- const { left, top } = ref.current.getBoundingClientRect()
95
- const mouseX = event.clientX - left + offsetX
96
- const mouseY = event.clientY - top + offsetY
97
- const x = Math.floor(mouseX / colWidth)
98
- const y = Math.floor(mouseY / rowHeight)
99
-
100
- // Only set mouse position if within valid MSA bounds
101
- if (x >= 0 && x < model.numColumns && y >= 0 && y < model.numRows) {
102
- model.setMousePos(x, y)
103
- } else {
104
- model.setMousePos(undefined, undefined)
99
+ if (ref.current) {
100
+ setMousePosition({ x: event.clientX, y: event.clientY })
101
+ const { col, row } = eventToColRow(
102
+ event,
103
+ ref.current,
104
+ offsetX,
105
+ offsetY,
106
+ colWidth,
107
+ rowHeight,
108
+ )
109
+ // Only set mouse position if within valid MSA bounds
110
+ if (
111
+ col >= 0 &&
112
+ col < model.numColumns &&
113
+ row >= 0 &&
114
+ row < model.numRows
115
+ ) {
116
+ model.setMousePos(col, row)
117
+ } else {
118
+ model.setMousePos(undefined, undefined)
119
+ }
105
120
  }
106
121
  }}
107
122
  onClick={event => {
108
- if (!ref.current) {
109
- return
110
- }
111
- const { left, top } = ref.current.getBoundingClientRect()
112
- const mouseX = event.clientX - left + offsetX
113
- const mouseY = event.clientY - top + offsetY
114
- const x = Math.floor(mouseX / colWidth)
115
- const y = Math.floor(mouseY / rowHeight)
116
- if (x === mouseClickCol && y === mouseClickRow) {
117
- model.setMouseClickPos(undefined, undefined)
118
- } else {
119
- model.setMouseClickPos(x, y)
123
+ if (ref.current) {
124
+ const { col, row } = eventToColRow(
125
+ event,
126
+ ref.current,
127
+ offsetX,
128
+ offsetY,
129
+ colWidth,
130
+ rowHeight,
131
+ )
132
+ if (col === mouseClickCol && row === mouseClickRow) {
133
+ model.setMouseClickPos(undefined, undefined)
134
+ } else {
135
+ model.setMouseClickPos(col, row)
136
+ }
120
137
  }
121
138
  }}
122
139
  onMouseLeave={() => {
@@ -41,7 +41,13 @@ export function renderMSABlock({
41
41
  ctx.scale(k, k)
42
42
  ctx.translate(-offsetX, rowHeight / 2 - offsetY)
43
43
  ctx.textAlign = 'center'
44
- ctx.font = ctx.font.replace(/\d+px/, `${bgColor ? '' : 'bold '}${fontSize}px`)
44
+ // match an optional existing "bold " so re-renders don't accumulate it
45
+ // (an invalid "bold bold 16px" string would be silently ignored by canvas,
46
+ // freezing the font size against zoom changes)
47
+ ctx.font = ctx.font.replace(
48
+ /(?:bold )?\d+px/,
49
+ `${bgColor ? '' : 'bold '}${fontSize}px`,
50
+ )
45
51
 
46
52
  const yStart = Math.max(0, Math.floor((offsetY - rowHeight) / rowHeight))
47
53
  const yEnd = Math.max(0, Math.ceil((offsetY + by + rowHeight) / rowHeight))
@@ -130,14 +136,15 @@ function drawTiles({
130
136
  const isMatchingReference =
131
137
  referenceSeq && name !== relativeTo && letter === referenceSeq[j]
132
138
 
133
- const color = isClustalX
134
- ? model.colClustalX[xStart + j]![letter]
135
- : isPercentIdentity
136
- ? (() => {
137
- const consensus = model.colConsensus[xStart + j]!
138
- return letter === consensus.letter ? consensus.color : undefined
139
- })()
140
- : colorScheme[letter.toUpperCase()]
139
+ let color: string | undefined
140
+ if (isClustalX) {
141
+ color = model.colClustalX[xStart + j]![letter]
142
+ } else if (isPercentIdentity) {
143
+ const consensus = model.colConsensus[xStart + j]!
144
+ color = letter === consensus.letter ? consensus.color : undefined
145
+ } else {
146
+ color = colorScheme[letter.toUpperCase()]
147
+ }
141
148
  if (bgColor || isClustalX || isPercentIdentity) {
142
149
  // Use a very light background for matching positions in relative mode
143
150
  const finalColor = isMatchingReference
@@ -43,35 +43,23 @@ const TreeCanvas = observer(function ({ model }: { model: MsaViewModel }) {
43
43
  ctx.resetTransform()
44
44
  ctx.clearRect(0, 0, w, h)
45
45
 
46
- if (relativeTo) {
47
- const referenceLeaf = leaves.find(
48
- leaf => leaf.data.name === relativeTo,
49
- )
50
- if (referenceLeaf) {
51
- ctx.fillStyle = referenceColor
52
- ctx.fillRect(
53
- 0,
54
- referenceLeaf.x! + sy - rowHeight / 2,
55
- w,
56
- rowHeight,
57
- )
46
+ const leafByName = new Map(leaves.map(leaf => [leaf.data.name, leaf]))
47
+ const fillRow = (name: string) => {
48
+ const leaf = leafByName.get(name)
49
+ if (leaf) {
50
+ ctx.fillRect(0, leaf.x! + sy - rowHeight / 2, w, rowHeight)
58
51
  }
59
52
  }
60
53
 
54
+ if (relativeTo) {
55
+ ctx.fillStyle = referenceColor
56
+ fillRow(relativeTo)
57
+ }
58
+
61
59
  if (hoveredTreeNode) {
62
60
  ctx.fillStyle = treeHoverColor
63
61
  for (const descendantName of hoveredTreeNode.descendantNames) {
64
- const matchingLeaf = leaves.find(
65
- leaf => leaf.data.name === descendantName,
66
- )
67
- if (matchingLeaf) {
68
- ctx.fillRect(
69
- 0,
70
- matchingLeaf.x! + sy - rowHeight / 2,
71
- w,
72
- rowHeight,
73
- )
74
- }
62
+ fillRow(descendantName)
75
63
  }
76
64
  }
77
65
 
@@ -80,18 +68,8 @@ const TreeCanvas = observer(function ({ model }: { model: MsaViewModel }) {
80
68
  mouseOverRowName !== relativeTo &&
81
69
  !hoveredTreeNode?.descendantNames.includes(mouseOverRowName)
82
70
  ) {
83
- const matchingLeaf = leaves.find(
84
- leaf => leaf.data.name === mouseOverRowName,
85
- )
86
- if (matchingLeaf) {
87
- ctx.fillStyle = treeHoverColor
88
- ctx.fillRect(
89
- 0,
90
- matchingLeaf.x! + sy - rowHeight / 2,
91
- w,
92
- rowHeight,
93
- )
94
- }
71
+ ctx.fillStyle = treeHoverColor
72
+ fillRow(mouseOverRowName)
95
73
  }
96
74
  }
97
75
  })
@@ -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
- import { descendants, links } from '../../hierarchy.ts'
1
+ import { calcDepthToLeaf, 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,23 @@ const extendBounds = 5
9
10
  const radius = 2.5
10
11
  const d = radius * 2
11
12
 
13
+ // Calculate node x-coordinate for both phylogram (with branch lengths) and cladogram (topology only) modes
14
+ // For cladograms: x = (maxDepthToLeaf - nodeDepthToLeaf) / maxDepthToLeaf * maxWidth
15
+ // This positions: leaves at maxWidth (rightmost), root at 0 (leftmost), internal nodes proportionally in between
16
+ // Matches ape's: xx <- max(xx) - xx (where xx is depth from each node to tips)
17
+ function getNodeX(
18
+ node: HierarchyNode,
19
+ showBranchLen: boolean,
20
+ maxBranchLen: number,
21
+ maxDepthToLeaf: number,
22
+ ): number | undefined {
23
+ if (showBranchLen) {
24
+ return node.len
25
+ }
26
+ const depthToLeaf = calcDepthToLeaf(node)
27
+ return ((maxDepthToLeaf - depthToLeaf) / maxDepthToLeaf) * maxBranchLen
28
+ }
29
+
12
30
  interface ClickEntry {
13
31
  name: string
14
32
  id: string
@@ -36,12 +54,16 @@ export function renderTree({
36
54
  ctx,
37
55
  model,
38
56
  theme,
57
+ maxBranchLen,
58
+ maxDepthToLeaf,
39
59
  blockSizeYOverride,
40
60
  }: {
41
61
  offsetY: number
42
62
  ctx: CanvasRenderingContext2D
43
63
  model: MsaViewModel
44
64
  theme: Theme
65
+ maxBranchLen: number
66
+ maxDepthToLeaf: number
45
67
  blockSizeYOverride?: number
46
68
  }) {
47
69
  const { hierarchy, showBranchLenEffective: showBranchLen, blockSize } = model
@@ -49,13 +71,10 @@ export function renderTree({
49
71
  ctx.strokeStyle = theme.palette.text.primary
50
72
  for (const link of links(hierarchy)) {
51
73
  const { source, target } = link
52
- if (target.height === 0 && !showBranchLen) {
53
- continue
54
- }
55
74
  const sy = source.x!
56
75
  const ty = target.x!
57
- const tx = showBranchLen ? target.len : target.y
58
- const sx = showBranchLen ? source.len : source.y
76
+ const tx = getNodeX(target, showBranchLen, maxBranchLen, maxDepthToLeaf)
77
+ const sx = getNodeX(source, showBranchLen, maxBranchLen, maxDepthToLeaf)
59
78
  if (tx === undefined || sx === undefined) {
60
79
  continue
61
80
  }
@@ -80,12 +99,16 @@ export function renderNodeBubbles({
80
99
  clickMap,
81
100
  offsetY,
82
101
  model,
102
+ maxBranchLen,
103
+ maxDepthToLeaf,
83
104
  blockSizeYOverride,
84
105
  }: {
85
106
  ctx: CanvasRenderingContext2D
86
107
  clickMap?: ClickMapIndex
87
108
  offsetY: number
88
109
  model: MsaViewModel
110
+ maxBranchLen: number
111
+ maxDepthToLeaf: number
89
112
  blockSizeYOverride?: number
90
113
  }) {
91
114
  const {
@@ -97,7 +120,7 @@ export function renderNodeBubbles({
97
120
  } = model
98
121
  const by = blockSizeYOverride || blockSize
99
122
  for (const node of descendants(hierarchy)) {
100
- const x = showBranchLen ? node.len : node.y
123
+ const x = getNodeX(node, showBranchLen, maxBranchLen, maxDepthToLeaf)
101
124
  if (x === undefined) {
102
125
  continue
103
126
  }
@@ -105,7 +128,7 @@ export function renderNodeBubbles({
105
128
  const y = node.x!
106
129
  const { id, name } = data
107
130
  if (
108
- node.height > 1 &&
131
+ node.height >= 1 &&
109
132
  y > offsetY - extendBounds &&
110
133
  y < offsetY + by + extendBounds
111
134
  ) {
@@ -135,6 +158,8 @@ export function renderTreeLabels({
135
158
  offsetY,
136
159
  ctx,
137
160
  clickMap,
161
+ maxBranchLen,
162
+ maxDepthToLeaf,
138
163
  blockSizeYOverride,
139
164
  }: {
140
165
  model: MsaViewModel
@@ -142,6 +167,8 @@ export function renderTreeLabels({
142
167
  ctx: CanvasRenderingContext2D
143
168
  clickMap?: ClickMapIndex
144
169
  theme: Theme
170
+ maxBranchLen: number
171
+ maxDepthToLeaf: number
145
172
  blockSizeYOverride?: number
146
173
  }) {
147
174
  const {
@@ -169,9 +196,7 @@ export function renderTreeLabels({
169
196
  const {
170
197
  data: { name, id },
171
198
  } = node
172
- const len = node.len
173
199
  const y = node.x!
174
- const x = node.y!
175
200
 
176
201
  const displayName = treeMetadata[name]?.genome || name
177
202
  if (y > offsetY - extendBounds && y < offsetY + by + extendBounds) {
@@ -179,7 +204,7 @@ export function renderTreeLabels({
179
204
  const yp = y + fontSize / 4
180
205
  let xp = 0
181
206
  if (!noTree) {
182
- xp = (showBranchLen ? len : x) || 0
207
+ xp = getNodeX(node, showBranchLen, maxBranchLen, maxDepthToLeaf) || 0
183
208
  }
184
209
 
185
210
  const { width } = ctx.measureText(displayName)
@@ -269,12 +294,18 @@ export function renderTreeCanvas({
269
294
  const font = ctx.font
270
295
  ctx.font = font.replace(/\d+px/, `${fontSize}px`)
271
296
 
297
+ // memoized on the model and shared across the tree/bubble/label passes (and
298
+ // across all tree blocks) rather than re-traversing the hierarchy in each
299
+ const { maxBranchLength: maxBranchLen, maxDepthToLeaf } = model
300
+
272
301
  if (!noTree && drawTree) {
273
302
  renderTree({
274
303
  ctx,
275
304
  offsetY,
276
305
  model,
277
306
  theme,
307
+ maxBranchLen,
308
+ maxDepthToLeaf,
278
309
  blockSizeYOverride,
279
310
  })
280
311
 
@@ -284,6 +315,8 @@ export function renderTreeCanvas({
284
315
  offsetY,
285
316
  clickMap,
286
317
  model,
318
+ maxBranchLen,
319
+ maxDepthToLeaf,
287
320
  blockSizeYOverride,
288
321
  })
289
322
  }
@@ -296,6 +329,8 @@ export function renderTreeCanvas({
296
329
  model,
297
330
  clickMap,
298
331
  theme,
332
+ maxBranchLen,
333
+ maxDepthToLeaf,
299
334
  blockSizeYOverride,
300
335
  })
301
336
  }