react-msaview 5.3.0 → 5.4.1
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 +22 -22
- package/bundle/index.js.LICENSE.txt +1 -1
- package/bundle/index.js.map +1 -1
- package/dist/components/Checkbox2.js +3 -1
- package/dist/components/Checkbox2.js.map +1 -1
- package/dist/components/Loading.js +6 -4
- package/dist/components/Loading.js.map +1 -1
- package/dist/components/MSAViewer.d.ts +3 -1
- package/dist/components/MSAViewer.js +3 -4
- package/dist/components/MSAViewer.js.map +1 -1
- package/dist/components/dialogs/InterProScanDialog.js +2 -1
- package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
- package/dist/components/msa/renderMSABlock.js +4 -6
- package/dist/components/msa/renderMSABlock.js.map +1 -1
- package/dist/components/renderCtx.d.ts +1 -1
- package/dist/components/tracks/renderTracksSvg.js +1 -1
- package/dist/components/tracks/renderTracksSvg.js.map +1 -1
- package/dist/components/tree/TreeNodeMenu.js +1 -1
- package/dist/components/tree/TreeNodeMenu.js.map +1 -1
- package/dist/components/tree/renderTreeCanvas.d.ts +10 -0
- package/dist/components/tree/renderTreeCanvas.js +84 -12
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/fetchUtils.d.ts +19 -0
- package/dist/fetchUtils.js +28 -0
- package/dist/fetchUtils.js.map +1 -1
- package/dist/hierarchy.d.ts +9 -1
- package/dist/hierarchy.js +37 -0
- package/dist/hierarchy.js.map +1 -1
- package/dist/model/msaModel.d.ts +7 -0
- package/dist/model/msaModel.js +14 -0
- package/dist/model/msaModel.js.map +1 -1
- package/dist/model.d.ts +9 -4
- package/dist/model.js +95 -32
- package/dist/model.js.map +1 -1
- package/dist/neighborJoining.js +44 -30
- package/dist/neighborJoining.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -5
- package/src/components/Checkbox2.tsx +7 -1
- package/src/components/Loading.tsx +15 -5
- package/src/components/MSAViewer.tsx +5 -3
- package/src/components/dialogs/InterProScanDialog.tsx +2 -1
- package/src/components/msa/renderMSABlock.ts +2 -8
- package/src/components/renderCtx.ts +1 -0
- package/src/components/tracks/renderTracksSvg.ts +1 -1
- package/src/components/tree/TreeNodeMenu.tsx +1 -1
- package/src/components/tree/renderTreeCanvas.ts +117 -11
- package/src/fetchUtils.test.ts +66 -0
- package/src/fetchUtils.ts +48 -0
- package/src/hierarchy.test.ts +56 -0
- package/src/hierarchy.ts +44 -1
- package/src/impgRender.test.tsx +125 -0
- package/src/model/msaModel.ts +21 -0
- package/src/model.ts +99 -55
- package/src/neighborJoining.ts +44 -31
- package/src/version.ts +1 -1
|
@@ -29,6 +29,9 @@ function getNodeX(
|
|
|
29
29
|
if (showBranchLen) {
|
|
30
30
|
return node.len
|
|
31
31
|
}
|
|
32
|
+
if (maxDepthToLeaf === 0) {
|
|
33
|
+
return 0
|
|
34
|
+
}
|
|
32
35
|
const depthToLeaf = calcDepthToLeaf(node)
|
|
33
36
|
return ((maxDepthToLeaf - depthToLeaf) / maxDepthToLeaf) * maxBranchLen
|
|
34
37
|
}
|
|
@@ -99,6 +102,85 @@ export function renderTree({
|
|
|
99
102
|
})
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
export function renderCollapsedTriangles({
|
|
106
|
+
ctx,
|
|
107
|
+
clickMap,
|
|
108
|
+
offsetY,
|
|
109
|
+
model,
|
|
110
|
+
theme,
|
|
111
|
+
maxBranchLen,
|
|
112
|
+
maxDepthToLeaf,
|
|
113
|
+
blockSizeYOverride,
|
|
114
|
+
}: {
|
|
115
|
+
ctx: RenderCtx
|
|
116
|
+
clickMap?: ClickMapIndex
|
|
117
|
+
offsetY: number
|
|
118
|
+
model: MsaViewModel
|
|
119
|
+
theme: Theme
|
|
120
|
+
maxBranchLen: number
|
|
121
|
+
maxDepthToLeaf: number
|
|
122
|
+
blockSizeYOverride?: number
|
|
123
|
+
}) {
|
|
124
|
+
const {
|
|
125
|
+
hierarchy,
|
|
126
|
+
showBranchLenEffective: showBranchLen,
|
|
127
|
+
collapsed,
|
|
128
|
+
blockSize,
|
|
129
|
+
rowHeight,
|
|
130
|
+
fontSize,
|
|
131
|
+
marginLeft: ml,
|
|
132
|
+
} = model
|
|
133
|
+
const by = blockSizeYOverride ?? blockSize
|
|
134
|
+
const halfHeight = Math.max(2, rowHeight * 0.42)
|
|
135
|
+
forEachDescendant(hierarchy, node => {
|
|
136
|
+
const { id, name } = node.data
|
|
137
|
+
if (collapsed.includes(id)) {
|
|
138
|
+
const apexX = getNodeX(node, showBranchLen, maxBranchLen, maxDepthToLeaf)
|
|
139
|
+
const y = node.x!
|
|
140
|
+
const inBlock =
|
|
141
|
+
y > offsetY - extendBounds && y < offsetY + by + extendBounds
|
|
142
|
+
// in cladogram mode the hidden tips align at the right edge, otherwise use
|
|
143
|
+
// the branch-length extent recorded in the model's hierarchy getter
|
|
144
|
+
const baseX = showBranchLen
|
|
145
|
+
? (node.collapsedTipXFar ?? maxBranchLen)
|
|
146
|
+
: maxBranchLen
|
|
147
|
+
if (apexX !== undefined && inBlock && baseX > apexX) {
|
|
148
|
+
ctx.beginPath()
|
|
149
|
+
ctx.moveTo(apexX, y)
|
|
150
|
+
ctx.lineTo(baseX, y - halfHeight)
|
|
151
|
+
ctx.lineTo(baseX, y + halfHeight)
|
|
152
|
+
ctx.closePath()
|
|
153
|
+
ctx.fillStyle = theme.palette.action.disabled
|
|
154
|
+
ctx.fill()
|
|
155
|
+
ctx.strokeStyle = theme.palette.text.primary
|
|
156
|
+
ctx.stroke()
|
|
157
|
+
|
|
158
|
+
// node.value carries the leaf count from the pre-collapse sum() pass
|
|
159
|
+
const count = node.value
|
|
160
|
+
if (count !== undefined && rowHeight >= 8) {
|
|
161
|
+
ctx.fillStyle = theme.palette.text.primary
|
|
162
|
+
ctx.textAlign = 'left'
|
|
163
|
+
ctx.fillText(`${count}`, baseX + 3, y + fontSize / 4)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// the whole triangle is a click/hover target that opens the
|
|
167
|
+
// branch menu (Expand this node); the apex bubble's own click entry is
|
|
168
|
+
// skipped for collapsed nodes so this descriptive label wins
|
|
169
|
+
const label = name === id ? 'Collapsed clade' : name
|
|
170
|
+
clickMap?.insert({
|
|
171
|
+
minX: apexX + ml,
|
|
172
|
+
maxX: baseX + ml,
|
|
173
|
+
minY: y - halfHeight,
|
|
174
|
+
maxY: y + halfHeight,
|
|
175
|
+
branch: true,
|
|
176
|
+
id,
|
|
177
|
+
name: count === undefined ? label : `${label} (${count} tips)`,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
102
184
|
export function renderNodeBubbles({
|
|
103
185
|
ctx,
|
|
104
186
|
clickMap,
|
|
@@ -137,22 +219,27 @@ export function renderNodeBubbles({
|
|
|
137
219
|
y > offsetY - extendBounds &&
|
|
138
220
|
y < offsetY + by + extendBounds
|
|
139
221
|
) {
|
|
222
|
+
const isCollapsed = collapsed.includes(id)
|
|
140
223
|
ctx.strokeStyle = 'black'
|
|
141
|
-
ctx.fillStyle =
|
|
224
|
+
ctx.fillStyle = isCollapsed ? 'black' : 'white'
|
|
142
225
|
ctx.beginPath()
|
|
143
226
|
ctx.arc(x, y, radius, 0, 2 * Math.PI)
|
|
144
227
|
ctx.fill()
|
|
145
228
|
ctx.stroke()
|
|
146
229
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
230
|
+
// collapsed nodes get their click/hover target from the triangle pass,
|
|
231
|
+
// which covers the apex bubble and carries a descriptive label
|
|
232
|
+
if (!isCollapsed) {
|
|
233
|
+
clickMap?.insert({
|
|
234
|
+
minX: x - radius + ml,
|
|
235
|
+
maxX: x - radius + d + ml,
|
|
236
|
+
minY: y - radius,
|
|
237
|
+
maxY: y - radius + d,
|
|
238
|
+
branch: true,
|
|
239
|
+
id,
|
|
240
|
+
name,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
156
243
|
}
|
|
157
244
|
})
|
|
158
245
|
}
|
|
@@ -189,6 +276,7 @@ export function renderTreeLabels({
|
|
|
189
276
|
leaves,
|
|
190
277
|
noTree,
|
|
191
278
|
labelWidthMap,
|
|
279
|
+
collapsed,
|
|
192
280
|
} = model
|
|
193
281
|
const by = blockSizeYOverride ?? blockSize
|
|
194
282
|
const emHeight = ctx.measureText('M').width
|
|
@@ -205,7 +293,14 @@ export function renderTreeLabels({
|
|
|
205
293
|
const y = node.x!
|
|
206
294
|
|
|
207
295
|
const displayName = treeMetadata[name]?.genome || name
|
|
208
|
-
|
|
296
|
+
// a collapsed clade is drawn as a triangle + tip count; suppress its leaf
|
|
297
|
+
// label when the "name" is just the auto-generated internal-node id
|
|
298
|
+
const isAnonymousCollapsed = collapsed.includes(id) && name === id
|
|
299
|
+
if (
|
|
300
|
+
!isAnonymousCollapsed &&
|
|
301
|
+
y > offsetY - extendBounds &&
|
|
302
|
+
y < offsetY + by + extendBounds
|
|
303
|
+
) {
|
|
209
304
|
// note: +rowHeight/4 matches with -rowHeight/4 in msa
|
|
210
305
|
const yp = y + fontSize / 4
|
|
211
306
|
let xp = 0
|
|
@@ -306,6 +401,17 @@ export function renderTreeCanvas({
|
|
|
306
401
|
blockSizeYOverride,
|
|
307
402
|
})
|
|
308
403
|
|
|
404
|
+
renderCollapsedTriangles({
|
|
405
|
+
ctx,
|
|
406
|
+
clickMap,
|
|
407
|
+
offsetY,
|
|
408
|
+
model,
|
|
409
|
+
theme,
|
|
410
|
+
maxBranchLen,
|
|
411
|
+
maxDepthToLeaf,
|
|
412
|
+
blockSizeYOverride,
|
|
413
|
+
})
|
|
414
|
+
|
|
309
415
|
if (drawNodeBubbles) {
|
|
310
416
|
renderNodeBubbles({
|
|
311
417
|
ctx,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { openLocation } from '@jbrowse/core/util/io'
|
|
2
|
+
import { describe, expect, test } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { fetchTextWithProgress, isAbortError } from './fetchUtils.ts'
|
|
5
|
+
|
|
6
|
+
import type { FetchStatus } from './fetchUtils.ts'
|
|
7
|
+
|
|
8
|
+
const loc = openLocation({
|
|
9
|
+
uri: 'http://example.com/data.fa',
|
|
10
|
+
locationType: 'UriLocation',
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('fetchTextWithProgress', () => {
|
|
14
|
+
test('reports progress messages then clears status', async () => {
|
|
15
|
+
const statuses: (FetchStatus | undefined)[] = []
|
|
16
|
+
const result = await fetchTextWithProgress(
|
|
17
|
+
loc,
|
|
18
|
+
s => statuses.push(s),
|
|
19
|
+
async (_loc, opts) => {
|
|
20
|
+
opts.statusCallback('Downloading file')
|
|
21
|
+
opts.statusCallback('')
|
|
22
|
+
return 'GREETINGS'
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
expect(result).toBe('GREETINGS')
|
|
26
|
+
expect(statuses.map(s => s?.msg)).toEqual([
|
|
27
|
+
'Downloading file',
|
|
28
|
+
undefined,
|
|
29
|
+
undefined,
|
|
30
|
+
])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('clears status even when the fetch throws', async () => {
|
|
34
|
+
const statuses: (FetchStatus | undefined)[] = []
|
|
35
|
+
await expect(
|
|
36
|
+
fetchTextWithProgress(
|
|
37
|
+
loc,
|
|
38
|
+
s => statuses.push(s),
|
|
39
|
+
async () => {
|
|
40
|
+
throw new Error('network down')
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
).rejects.toThrow('network down')
|
|
44
|
+
expect(statuses.at(-1)).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('onCancel aborts the underlying request', async () => {
|
|
48
|
+
const statuses: (FetchStatus | undefined)[] = []
|
|
49
|
+
const promise = fetchTextWithProgress(
|
|
50
|
+
loc,
|
|
51
|
+
s => statuses.push(s),
|
|
52
|
+
(_loc, opts) =>
|
|
53
|
+
new Promise<string>((_resolve, reject) => {
|
|
54
|
+
opts.statusCallback('Downloading file')
|
|
55
|
+
opts.signal.addEventListener('abort', () => {
|
|
56
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
57
|
+
})
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
statuses.find(s => s?.onCancel)?.onCancel?.()
|
|
62
|
+
|
|
63
|
+
await expect(promise).rejects.toSatisfy(isAbortError)
|
|
64
|
+
expect(statuses.at(-1)).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
})
|
package/src/fetchUtils.ts
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
+
import { fetchAndMaybeUnzipText } from '@jbrowse/core/util'
|
|
2
|
+
|
|
3
|
+
export interface FetchStatus {
|
|
4
|
+
msg: string
|
|
5
|
+
url?: string
|
|
6
|
+
onCancel?: () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type Filehandle = Parameters<typeof fetchAndMaybeUnzipText>[0]
|
|
10
|
+
|
|
11
|
+
type ProgressFetcher = (
|
|
12
|
+
loc: Filehandle,
|
|
13
|
+
opts: { signal: AbortSignal; statusCallback: (msg: string) => void },
|
|
14
|
+
) => Promise<string>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetch text from a filehandle while reporting download/unzip progress through
|
|
18
|
+
* setStatus, and wiring a Cancel handler that aborts the underlying request.
|
|
19
|
+
* The status is always cleared once the fetch settles. The fetcher is injectable
|
|
20
|
+
* for testing.
|
|
21
|
+
*/
|
|
22
|
+
export async function fetchTextWithProgress(
|
|
23
|
+
loc: Filehandle,
|
|
24
|
+
setStatus: (status?: FetchStatus) => void,
|
|
25
|
+
fetcher: ProgressFetcher = fetchAndMaybeUnzipText,
|
|
26
|
+
) {
|
|
27
|
+
const controller = new AbortController()
|
|
28
|
+
try {
|
|
29
|
+
return await fetcher(loc, {
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
statusCallback: msg => {
|
|
32
|
+
setStatus(
|
|
33
|
+
msg
|
|
34
|
+
? {
|
|
35
|
+
msg,
|
|
36
|
+
onCancel: () => {
|
|
37
|
+
controller.abort()
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
: undefined,
|
|
41
|
+
)
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
} finally {
|
|
45
|
+
setStatus(undefined)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
1
49
|
export async function myfetch(url: string, args?: RequestInit) {
|
|
2
50
|
const response = await fetch(url, args)
|
|
3
51
|
|
package/src/hierarchy.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest'
|
|
|
3
3
|
import {
|
|
4
4
|
calcDepthToLeaf,
|
|
5
5
|
collapse,
|
|
6
|
+
collapsedSubtreeLengthExtent,
|
|
6
7
|
find,
|
|
7
8
|
findMaxBranchLen,
|
|
8
9
|
hierarchy,
|
|
@@ -109,6 +110,61 @@ describe('collapse', () => {
|
|
|
109
110
|
collapse(leaf)
|
|
110
111
|
expect(leaves(h).map(n => n.data.name)).toEqual(['A1', 'A2', 'B1', 'B2'])
|
|
111
112
|
})
|
|
113
|
+
|
|
114
|
+
test('preserves the subtree depth so the layout stays stable', () => {
|
|
115
|
+
const h = hierarchy(makeTree(), d => d.children)
|
|
116
|
+
const nodeA = find(h, n => n.data.id === 'A')!
|
|
117
|
+
collapse(nodeA)
|
|
118
|
+
// the collapsed node keeps its real depth (1) rather than becoming a depth-0
|
|
119
|
+
// leaf, so the cladogram apex sits at the true branch point
|
|
120
|
+
expect(nodeA.depthToLeaf).toBe(1)
|
|
121
|
+
// and the root still measures the tree as 2 deep, so collapsing A does not
|
|
122
|
+
// horizontally shift the rest of the tree
|
|
123
|
+
expect(calcDepthToLeaf(h)).toBe(2)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('collapsedSubtreeLengthExtent', () => {
|
|
128
|
+
// root → A(1) → [ A1(2), C(3) → [ C1(1), C2(10) ] ]
|
|
129
|
+
function makeLenTree(): NodeWithIds {
|
|
130
|
+
return {
|
|
131
|
+
id: 'root',
|
|
132
|
+
name: 'root',
|
|
133
|
+
children: [
|
|
134
|
+
{
|
|
135
|
+
id: 'A',
|
|
136
|
+
name: 'A',
|
|
137
|
+
length: 1,
|
|
138
|
+
children: [
|
|
139
|
+
{ id: 'A1', name: 'A1', length: 2, children: [] },
|
|
140
|
+
{
|
|
141
|
+
id: 'C',
|
|
142
|
+
name: 'C',
|
|
143
|
+
length: 3,
|
|
144
|
+
children: [
|
|
145
|
+
{ id: 'C1', name: 'C1', length: 1, children: [] },
|
|
146
|
+
{ id: 'C2', name: 'C2', length: 10, children: [] },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test('min/max cumulative branch length to the tips below a node', () => {
|
|
156
|
+
const h = hierarchy(makeLenTree(), d => d.children)
|
|
157
|
+
const nodeA = find(h, n => n.data.id === 'A')!
|
|
158
|
+
collapse(nodeA)
|
|
159
|
+
// excludes A's own branch: nearest tip A1 = 2, farthest tip C2 = 3 + 10 = 13
|
|
160
|
+
expect(collapsedSubtreeLengthExtent(nodeA)).toEqual({ min: 2, max: 13 })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('returns zero extent for a node with no descendants', () => {
|
|
164
|
+
const h = hierarchy(makeLenTree(), d => d.children)
|
|
165
|
+
const leaf = find(h, n => n.data.id === 'A1')!
|
|
166
|
+
expect(collapsedSubtreeLengthExtent(leaf)).toEqual({ min: 0, max: 0 })
|
|
167
|
+
})
|
|
112
168
|
})
|
|
113
169
|
|
|
114
170
|
describe('leaf removal via parent.children filter', () => {
|
package/src/hierarchy.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface HierarchyNode<T = NodeWithIds> {
|
|
|
12
12
|
len?: number
|
|
13
13
|
depthToLeaf?: number
|
|
14
14
|
_children?: HierarchyNode<T>[] | null
|
|
15
|
+
// pixel x-positions of the nearest/farthest tips of a collapsed subtree, used
|
|
16
|
+
// to draw the collapsed-clade triangle. Set in the model's hierarchy getter.
|
|
17
|
+
collapsedTipXNear?: number
|
|
18
|
+
collapsedTipXFar?: number
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export interface HierarchyLink<T = NodeWithIds> {
|
|
@@ -235,16 +239,55 @@ export function clusterLayout<T>(
|
|
|
235
239
|
|
|
236
240
|
export function collapse<T>(node: HierarchyNode<T>) {
|
|
237
241
|
if (node.children) {
|
|
242
|
+
// memoize the real subtree depth onto the node before detaching its
|
|
243
|
+
// children, so cladogram positioning keeps the node (now the apex of a
|
|
244
|
+
// collapsed-clade triangle) at its true branch point rather than snapping
|
|
245
|
+
// it to the tip-alignment line. This also keeps the rest of the tree's
|
|
246
|
+
// horizontal layout stable when a clade is collapsed.
|
|
247
|
+
calcDepthToLeaf(node)
|
|
238
248
|
node._children = node.children
|
|
239
249
|
node.children = null
|
|
240
250
|
}
|
|
241
251
|
}
|
|
242
252
|
|
|
253
|
+
// Min/max cumulative raw branch length from a collapsed node down to the tips of
|
|
254
|
+
// its detached subtree (excludes the node's own branch). Used to size the
|
|
255
|
+
// collapsed-clade triangle in phylogram mode.
|
|
256
|
+
export function collapsedSubtreeLengthExtent<T extends { length?: number }>(
|
|
257
|
+
node: HierarchyNode<T>,
|
|
258
|
+
) {
|
|
259
|
+
const roots = node._children ?? node.children
|
|
260
|
+
let min = Infinity
|
|
261
|
+
let max = 0
|
|
262
|
+
if (roots) {
|
|
263
|
+
const stack = roots.map(child => ({
|
|
264
|
+
node: child,
|
|
265
|
+
acc: Math.max(child.data.length ?? 0, 0),
|
|
266
|
+
}))
|
|
267
|
+
while (stack.length > 0) {
|
|
268
|
+
const { node: n, acc } = stack.pop()!
|
|
269
|
+
const kids = n.children ?? n._children
|
|
270
|
+
if (kids?.length) {
|
|
271
|
+
for (const child of kids) {
|
|
272
|
+
stack.push({
|
|
273
|
+
node: child,
|
|
274
|
+
acc: acc + Math.max(child.data.length ?? 0, 0),
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
min = Math.min(min, acc)
|
|
279
|
+
max = Math.max(max, acc)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { min: min === Infinity ? 0 : min, max }
|
|
284
|
+
}
|
|
285
|
+
|
|
243
286
|
// Cladogram positioning based on ape's plot.phylo: uses topological depth (max
|
|
244
287
|
// steps to a tip) instead of branch length so all leaves align at the rightmost
|
|
245
288
|
// x. Memoizes onto node.depthToLeaf since the layout walks the tree repeatedly.
|
|
246
289
|
// See https://github.com/emmanuelparadis/ape/blob/master/R/plot.phylo.R
|
|
247
|
-
export function calcDepthToLeaf(node: HierarchyNode): number {
|
|
290
|
+
export function calcDepthToLeaf<T>(node: HierarchyNode<T>): number {
|
|
248
291
|
const nodes = descendants(node)
|
|
249
292
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
250
293
|
const n = nodes[i]!
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// End-to-end check of the impg (https://github.com/pangenome/impg) integration:
|
|
4
|
+
// impg's scripts/faln2html.py drives the viewer exactly as below --
|
|
5
|
+
// MSAModelF().create({ type:'MsaView', data:{ msa } })
|
|
6
|
+
// model.setColorSchemeName('percent_identity_dynamic')
|
|
7
|
+
// model.setBgColor(true); model.setWidth(1300)
|
|
8
|
+
// then renders <MSAView model/>. This exercises that path headlessly via the
|
|
9
|
+
// public renderToSvg export. Polyfills mirror scripts/generateFigures.tsx.
|
|
10
|
+
import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
|
|
11
|
+
import { enableStaticRendering } from 'mobx-react'
|
|
12
|
+
import { beforeAll, expect, test } from 'vitest'
|
|
13
|
+
|
|
14
|
+
import MSAModelF from './model.ts'
|
|
15
|
+
import { renderToSvg } from './renderToSvg.tsx'
|
|
16
|
+
|
|
17
|
+
class Mat {
|
|
18
|
+
constructor(
|
|
19
|
+
public a = 1,
|
|
20
|
+
public b = 0,
|
|
21
|
+
public c = 0,
|
|
22
|
+
public d = 1,
|
|
23
|
+
public e = 0,
|
|
24
|
+
public f = 0,
|
|
25
|
+
) {}
|
|
26
|
+
multiply(o: Mat) {
|
|
27
|
+
return new Mat(
|
|
28
|
+
this.a * o.a + this.c * o.b,
|
|
29
|
+
this.b * o.a + this.d * o.b,
|
|
30
|
+
this.a * o.c + this.c * o.d,
|
|
31
|
+
this.b * o.c + this.d * o.d,
|
|
32
|
+
this.a * o.e + this.c * o.f + this.e,
|
|
33
|
+
this.b * o.e + this.d * o.f + this.f,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
translate(x: number, y = 0) {
|
|
37
|
+
return this.multiply(new Mat(1, 0, 0, 1, x, y))
|
|
38
|
+
}
|
|
39
|
+
scale(x: number, y = x) {
|
|
40
|
+
return this.multiply(new Mat(x, 0, 0, y, 0, 0))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
class Pt {
|
|
44
|
+
constructor(
|
|
45
|
+
public x = 0,
|
|
46
|
+
public y = 0,
|
|
47
|
+
) {}
|
|
48
|
+
matrixTransform(m: Mat) {
|
|
49
|
+
return new Pt(
|
|
50
|
+
m.a * this.x + m.c * this.y + m.e,
|
|
51
|
+
m.b * this.x + m.d * this.y + m.f,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
beforeAll(() => {
|
|
57
|
+
enableStaticRendering(true)
|
|
58
|
+
const g = globalThis as Record<string, unknown>
|
|
59
|
+
g.DOMMatrix = Mat
|
|
60
|
+
g.DOMPoint = Pt
|
|
61
|
+
HTMLCanvasElement.prototype.getContext = function () {
|
|
62
|
+
let font = '10px sans-serif'
|
|
63
|
+
return {
|
|
64
|
+
get font() {
|
|
65
|
+
return font
|
|
66
|
+
},
|
|
67
|
+
set font(v: string) {
|
|
68
|
+
font = v
|
|
69
|
+
},
|
|
70
|
+
measureText(t: string) {
|
|
71
|
+
const size = Number.parseFloat(font) || 10
|
|
72
|
+
return { width: t.length * size * 0.6 } as TextMetrics
|
|
73
|
+
},
|
|
74
|
+
} as unknown as CanvasRenderingContext2D
|
|
75
|
+
} as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// representative `impg query -o fasta-aln` block: PanSN names with region
|
|
79
|
+
// colons, dash gaps, mixed-case (soft-masked) bases
|
|
80
|
+
const impgFastaAln = `>HG002#1#chr1:1000-1040
|
|
81
|
+
ACGTACGT--ACGTNNNNacgtACGTACGTACGTACGT
|
|
82
|
+
>HG003#1#chr1:1000-1040
|
|
83
|
+
ACGTAC-TGGACGTNNNNACGTAC--ACGTacgtACGT
|
|
84
|
+
>CHM13#0#chr1:1000-1040
|
|
85
|
+
ACGTACGTGGACGTNNNNACGT----ACGTACGTACGT`
|
|
86
|
+
|
|
87
|
+
test('renders an impg fasta-aln block with declarative impg config', async () => {
|
|
88
|
+
// everything except width is a declarative MST property, so it can be set in
|
|
89
|
+
// the create() snapshot -- no setColorSchemeName/setBgColor/setMSAFormat
|
|
90
|
+
// action calls needed. msaFormat:'fasta' forces the parser and skips the
|
|
91
|
+
// heuristic fasta-vs-a3m sniffing. width is volatile (tracks container size)
|
|
92
|
+
// so it remains an action.
|
|
93
|
+
const model = MSAModelF().create({
|
|
94
|
+
id: 'impg-test',
|
|
95
|
+
type: 'MsaView',
|
|
96
|
+
height: 400,
|
|
97
|
+
colorSchemeName: 'percent_identity_dynamic',
|
|
98
|
+
bgColor: true,
|
|
99
|
+
msaFormat: 'fasta',
|
|
100
|
+
data: { msa: impgFastaAln },
|
|
101
|
+
})
|
|
102
|
+
model.setWidth(1300)
|
|
103
|
+
|
|
104
|
+
// the declarative config took effect
|
|
105
|
+
expect(model.colorSchemeName).toBe('percent_identity_dynamic')
|
|
106
|
+
expect(model.bgColor).toBe(true)
|
|
107
|
+
expect(model.msaFormat).toBe('fasta')
|
|
108
|
+
|
|
109
|
+
// the alignment parsed and all 3 rows are present at equal width
|
|
110
|
+
expect(model.rowNames).toEqual([
|
|
111
|
+
'HG002#1#chr1:1000-1040',
|
|
112
|
+
'HG003#1#chr1:1000-1040',
|
|
113
|
+
'CHM13#0#chr1:1000-1040',
|
|
114
|
+
])
|
|
115
|
+
expect(model.numColumns).toBe(38)
|
|
116
|
+
|
|
117
|
+
const svg = await renderToSvg(model, {
|
|
118
|
+
theme: createJBrowseTheme(),
|
|
119
|
+
exportType: 'entire',
|
|
120
|
+
includeMinimap: false,
|
|
121
|
+
includeTracks: false,
|
|
122
|
+
})
|
|
123
|
+
expect(svg).toContain('<svg')
|
|
124
|
+
expect(svg).toContain('HG002#1#chr1:1000-1040')
|
|
125
|
+
})
|
package/src/model/msaModel.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { types } from '@jbrowse/mobx-state-tree'
|
|
|
2
2
|
|
|
3
3
|
import { defaultBgColor, defaultColorSchemeName } from '../constants.ts'
|
|
4
4
|
|
|
5
|
+
import type { MSAFormat } from 'msa-parsers'
|
|
6
|
+
|
|
7
|
+
const msaFormats: MSAFormat[] = ['stockholm', 'a3m', 'fasta', 'emf', 'clustal']
|
|
8
|
+
|
|
5
9
|
/**
|
|
6
10
|
* #stateModel MSAModel
|
|
7
11
|
*/
|
|
@@ -19,6 +23,15 @@ export function MSAModelF() {
|
|
|
19
23
|
* default color scheme name
|
|
20
24
|
*/
|
|
21
25
|
colorSchemeName: defaultColorSchemeName,
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* #property
|
|
29
|
+
* force the MSA data to be parsed as a specific format instead of relying
|
|
30
|
+
* on auto-detection (which is ambiguous between e.g. fasta and a3m)
|
|
31
|
+
*/
|
|
32
|
+
msaFormat: types.maybe(
|
|
33
|
+
types.enumeration<MSAFormat>('MSAFormat', msaFormats),
|
|
34
|
+
),
|
|
22
35
|
})
|
|
23
36
|
.actions(self => ({
|
|
24
37
|
/**
|
|
@@ -35,5 +48,13 @@ export function MSAModelF() {
|
|
|
35
48
|
setBgColor(arg: boolean) {
|
|
36
49
|
self.bgColor = arg
|
|
37
50
|
},
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* #action
|
|
54
|
+
* force a specific MSA parser, or pass undefined to auto-detect
|
|
55
|
+
*/
|
|
56
|
+
setMSAFormat(arg?: MSAFormat) {
|
|
57
|
+
self.msaFormat = arg
|
|
58
|
+
},
|
|
38
59
|
}))
|
|
39
60
|
}
|