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.
- package/bundle/index.js +135 -35
- package/bundle/index.js.LICENSE.txt +1 -1
- package/bundle/index.js.map +1 -1
- package/dist/clustalX.d.ts +5 -0
- package/dist/clustalX.js +88 -0
- package/dist/clustalX.js.map +1 -0
- package/dist/components/SequenceTextArea.js +6 -6
- package/dist/components/SequenceTextArea.js.map +1 -1
- package/dist/components/TextTrack.js +3 -5
- package/dist/components/TextTrack.js.map +1 -1
- package/dist/components/Track.d.ts +2 -1
- package/dist/components/Track.js +4 -3
- package/dist/components/Track.js.map +1 -1
- package/dist/components/VerticalScrollbar.js +6 -31
- package/dist/components/VerticalScrollbar.js.map +1 -1
- package/dist/components/dialogs/InterProScanDialog.js +5 -3
- package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
- package/dist/components/header/HeaderStatusArea.js +4 -2
- package/dist/components/header/HeaderStatusArea.js.map +1 -1
- package/dist/components/header/MSASettingsMenu.js +2 -27
- package/dist/components/header/MSASettingsMenu.js.map +1 -1
- package/dist/components/header/SettingsMenu.js +4 -69
- package/dist/components/header/SettingsMenu.js.map +1 -1
- package/dist/components/header/TreeSettingsMenu.js +3 -41
- package/dist/components/header/TreeSettingsMenu.js.map +1 -1
- package/dist/components/header/settingsMenuItems.d.ts +4 -0
- package/dist/components/header/settingsMenuItems.js +59 -0
- package/dist/components/header/settingsMenuItems.js.map +1 -0
- package/dist/components/import/ImportFormExamples.js +1 -1
- package/dist/components/import/ImportFormExamples.js.map +1 -1
- package/dist/components/minimap/Minimap.js +6 -31
- package/dist/components/minimap/Minimap.js.map +1 -1
- package/dist/components/msa/MSACanvasBlock.js +31 -33
- package/dist/components/msa/MSACanvasBlock.js.map +1 -1
- package/dist/components/msa/renderMSABlock.js +15 -9
- package/dist/components/msa/renderMSABlock.js.map +1 -1
- package/dist/components/tree/TreeCanvas.js +12 -14
- package/dist/components/tree/TreeCanvas.js.map +1 -1
- package/dist/components/tree/renderTreeCanvas.d.ts +9 -3
- package/dist/components/tree/renderTreeCanvas.js +29 -14
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/fetchUtils.d.ts +2 -1
- package/dist/fetchUtils.js +16 -2
- package/dist/fetchUtils.js.map +1 -1
- package/dist/hierarchy.d.ts +3 -0
- package/dist/hierarchy.js +25 -0
- package/dist/hierarchy.js.map +1 -1
- package/dist/launchInterProScan.d.ts +2 -1
- package/dist/launchInterProScan.js +31 -15
- package/dist/launchInterProScan.js.map +1 -1
- package/dist/model.d.ts +150 -494
- package/dist/model.js +24 -116
- package/dist/model.js.map +1 -1
- package/dist/useColorContrast.d.ts +10 -0
- package/dist/useColorContrast.js +13 -0
- package/dist/useColorContrast.js.map +1 -0
- package/dist/useDragScroll.d.ts +12 -0
- package/dist/useDragScroll.js +47 -0
- package/dist/useDragScroll.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +6 -6
- package/src/clustalX.test.ts +76 -0
- package/src/clustalX.ts +131 -0
- package/src/components/SequenceTextArea.tsx +6 -6
- package/src/components/TextTrack.tsx +3 -8
- package/src/components/Track.tsx +6 -9
- package/src/components/VerticalScrollbar.tsx +13 -40
- package/src/components/dialogs/InterProScanDialog.tsx +7 -1
- package/src/components/header/HeaderStatusArea.tsx +6 -1
- package/src/components/header/MSASettingsMenu.tsx +3 -27
- package/src/components/header/SettingsMenu.tsx +7 -79
- package/src/components/header/TreeSettingsMenu.tsx +4 -48
- package/src/components/header/settingsMenuItems.ts +68 -0
- package/src/components/import/ImportFormExamples.tsx +1 -1
- package/src/components/minimap/Minimap.tsx +13 -38
- package/src/components/msa/MSACanvasBlock.tsx +53 -36
- package/src/components/msa/renderMSABlock.ts +16 -9
- package/src/components/tree/TreeCanvas.tsx +13 -35
- package/src/components/tree/renderTreeCanvas.test.ts +205 -0
- package/src/components/tree/renderTreeCanvas.ts +46 -11
- package/src/fetchUtils.ts +16 -2
- package/src/hierarchy.ts +28 -0
- package/src/launchInterProScan.ts +34 -10
- package/src/model.ts +33 -143
- package/src/useColorContrast.ts +18 -0
- package/src/useDragScroll.ts +55 -0
- package/src/version.ts +1 -1
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useCallback, useState } from 'react'
|
|
2
2
|
|
|
3
3
|
import { observer } from 'mobx-react'
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { useDragScroll } from '../../useDragScroll.ts'
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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 =
|
|
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 (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
58
|
-
const sx = showBranchLen
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|