react-msaview 5.7.2 → 5.8.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 (54) hide show
  1. package/bundle/index.js +99 -101
  2. package/bundle/index.js.map +4 -4
  3. package/dist/components/SequenceTextArea.js +2 -2
  4. package/dist/components/SequenceTextArea.js.map +1 -1
  5. package/dist/components/dialogs/UserProvidedDomainsDialog.js +6 -1
  6. package/dist/components/dialogs/UserProvidedDomainsDialog.js.map +1 -1
  7. package/dist/components/header/getDomainsMenu.js +1 -1
  8. package/dist/components/header/getDomainsMenu.js.map +1 -1
  9. package/dist/components/import/ImportForm.js +7 -11
  10. package/dist/components/import/ImportForm.js.map +1 -1
  11. package/dist/components/import/ImportFormExamples.js +7 -11
  12. package/dist/components/import/ImportFormExamples.js.map +1 -1
  13. package/dist/components/import/util.d.ts +1 -1
  14. package/dist/components/import/util.js +1 -1
  15. package/dist/components/import/util.js.map +1 -1
  16. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +2 -2
  17. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  18. package/dist/components/tree/renderTreeCanvas.d.ts +9 -9
  19. package/dist/components/tree/renderTreeCanvas.js +36 -24
  20. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  21. package/dist/flatToTree.d.ts +3 -1
  22. package/dist/flatToTree.js +16 -9
  23. package/dist/flatToTree.js.map +1 -1
  24. package/dist/hierarchy.d.ts +12 -17
  25. package/dist/hierarchy.js +12 -145
  26. package/dist/hierarchy.js.map +1 -1
  27. package/dist/model.d.ts +1 -0
  28. package/dist/model.js +17 -3
  29. package/dist/model.js.map +1 -1
  30. package/dist/neighborJoining.js +11 -2
  31. package/dist/neighborJoining.js.map +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/package.json +4 -3
  35. package/src/colSpace.test.ts +40 -0
  36. package/src/components/SequenceTextArea.tsx +2 -2
  37. package/src/components/dialogs/UserProvidedDomainsDialog.tsx +8 -1
  38. package/src/components/header/getDomainsMenu.ts +1 -1
  39. package/src/components/import/ImportForm.tsx +6 -9
  40. package/src/components/import/ImportFormExamples.tsx +11 -14
  41. package/src/components/import/util.ts +1 -1
  42. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +3 -2
  43. package/src/components/tree/renderTreeCanvas.test.ts +54 -1
  44. package/src/components/tree/renderTreeCanvas.ts +41 -28
  45. package/src/flatToTree.test.ts +21 -0
  46. package/src/flatToTree.ts +21 -10
  47. package/src/hierarchy.ts +44 -179
  48. package/src/largeTree.test.ts +32 -0
  49. package/src/model.ts +17 -3
  50. package/src/neighborJoining.test.ts +21 -6
  51. package/src/neighborJoining.ts +12 -2
  52. package/src/parseAsn1.test.ts +16 -0
  53. package/src/stripDefaultSnapshot.test.ts +19 -0
  54. package/src/version.ts +1 -1
package/src/hierarchy.ts CHANGED
@@ -1,12 +1,28 @@
1
- import type { NodeWithIds } from './types.ts'
1
+ import {
2
+ descendants,
3
+ find,
4
+ forEachDescendant,
5
+ forEachLink,
6
+ hierarchy as coreHierarchy,
7
+ leaves,
8
+ links,
9
+ sort,
10
+ sum,
11
+ } from '@gmod/newick'
2
12
 
3
- export interface HierarchyNode<T = NodeWithIds> {
4
- data: T
13
+ import type { NodeWithIds } from './types.ts'
14
+ import type { HierarchyNode as CoreHierarchyNode } from '@gmod/newick'
15
+
16
+ /**
17
+ * A hierarchy node plus the fields this viewer hangs off it during layout.
18
+ *
19
+ * The traversals in `@gmod/newick` are generic over the node type rather than
20
+ * over its data, so they take and return this extended node rather than the base
21
+ * one, and no call site has to cast.
22
+ */
23
+ export interface HierarchyNode<T = NodeWithIds> extends CoreHierarchyNode<T> {
5
24
  children: HierarchyNode<T>[] | null
6
25
  parent: HierarchyNode<T> | null
7
- depth: number
8
- height: number
9
- value?: number
10
26
  x?: number
11
27
  y?: number
12
28
  len?: number
@@ -18,183 +34,32 @@ export interface HierarchyNode<T = NodeWithIds> {
18
34
  collapsedTipXFar?: number
19
35
  }
20
36
 
21
- export interface HierarchyLink<T = NodeWithIds> {
22
- source: HierarchyNode<T>
23
- target: HierarchyNode<T>
24
- }
25
-
26
- // All traversals below are iterative (explicit stack / reversed-preorder) rather
27
- // than recursive: phylogenetic trees can be deeply unbalanced (a caterpillar tree
28
- // has depth ~= leaf count), which overflows the JS call stack on recursion.
29
-
30
- // Pre-order: every parent precedes all of its descendants. Iterating the result
31
- // in reverse therefore yields a valid post-order (children before parents), which
32
- // the accumulation helpers rely on.
33
- export function descendants<T>(node: HierarchyNode<T>): HierarchyNode<T>[] {
34
- const result: HierarchyNode<T>[] = []
35
- const stack = [node]
36
- while (stack.length > 0) {
37
- const n = stack.pop()!
38
- result.push(n)
39
- if (n.children) {
40
- for (let i = n.children.length - 1; i >= 0; i--) {
41
- stack.push(n.children[i]!)
42
- }
43
- }
44
- }
45
- return result
46
- }
47
-
48
- function computeHeight<T>(node: HierarchyNode<T>) {
49
- const nodes = descendants(node)
50
- for (let i = nodes.length - 1; i >= 0; i--) {
51
- const n = nodes[i]!
52
- let h = 0
53
- if (n.children) {
54
- for (const child of n.children) {
55
- if (child.height + 1 > h) {
56
- h = child.height + 1
57
- }
58
- }
59
- }
60
- n.height = h
61
- }
62
- }
63
-
37
+ // The generic traversals live in @gmod/newick now, shared with the tree sidebar
38
+ // in jbrowse-components. They are iterative there for the reason they were here:
39
+ // a phylogenetic tree can be a caterpillar, whose depth equals its leaf count,
40
+ // and the recursive form overflows the stack somewhere past 5000 tips.
41
+ export {
42
+ descendants,
43
+ find,
44
+ forEachDescendant,
45
+ forEachLink,
46
+ leaves,
47
+ links,
48
+ sort,
49
+ sum,
50
+ }
51
+ export type { HierarchyLink } from '@gmod/newick'
52
+
53
+ // coreHierarchy builds base nodes, and the layout fields above are written onto
54
+ // them afterwards by this package. Every one of those fields is optional, so the
55
+ // two node types are mutually assignable and this needs no cast -- it exists
56
+ // only to declare the wider return type, which is what lets those later
57
+ // assignments typecheck.
64
58
  export function hierarchy<T>(
65
59
  data: T,
66
60
  childrenAccessor: (d: T) => T[] | undefined,
67
61
  ): HierarchyNode<T> {
68
- const root: HierarchyNode<T> = {
69
- data,
70
- children: null,
71
- parent: null,
72
- depth: 0,
73
- height: 0,
74
- }
75
- const stack = [root]
76
- while (stack.length > 0) {
77
- const node = stack.pop()!
78
- const kids = childrenAccessor(node.data)
79
- if (kids?.length) {
80
- node.children = kids.map(d => ({
81
- data: d,
82
- children: null,
83
- parent: node,
84
- depth: node.depth + 1,
85
- height: 0,
86
- }))
87
- for (const child of node.children) {
88
- stack.push(child)
89
- }
90
- }
91
- }
92
- computeHeight(root)
93
- return root
94
- }
95
-
96
- export function sum<T>(
97
- node: HierarchyNode<T>,
98
- valueFn: (d: T) => number,
99
- ): HierarchyNode<T> {
100
- const nodes = descendants(node)
101
- for (let i = nodes.length - 1; i >= 0; i--) {
102
- const n = nodes[i]!
103
- let s = valueFn(n.data)
104
- if (n.children) {
105
- for (const child of n.children) {
106
- s += child.value!
107
- }
108
- }
109
- n.value = s
110
- }
111
- return node
112
- }
113
-
114
- export function sort<T>(
115
- node: HierarchyNode<T>,
116
- compareFn: (a: HierarchyNode<T>, b: HierarchyNode<T>) => number,
117
- ): HierarchyNode<T> {
118
- const stack = [node]
119
- while (stack.length > 0) {
120
- const n = stack.pop()!
121
- if (n.children) {
122
- n.children.sort(compareFn)
123
- for (const child of n.children) {
124
- stack.push(child)
125
- }
126
- }
127
- }
128
- return node
129
- }
130
-
131
- export function find<T>(
132
- node: HierarchyNode<T>,
133
- predicate: (n: HierarchyNode<T>) => boolean,
134
- ): HierarchyNode<T> | undefined {
135
- const stack = [node]
136
- let found: HierarchyNode<T> | undefined
137
- while (stack.length > 0 && found === undefined) {
138
- const n = stack.pop()!
139
- if (predicate(n)) {
140
- found = n
141
- } else if (n.children) {
142
- for (let i = n.children.length - 1; i >= 0; i--) {
143
- stack.push(n.children[i]!)
144
- }
145
- }
146
- }
147
- return found
148
- }
149
-
150
- export function leaves<T>(node: HierarchyNode<T>): HierarchyNode<T>[] {
151
- const result: HierarchyNode<T>[] = []
152
- const stack = [node]
153
- while (stack.length > 0) {
154
- const n = stack.pop()!
155
- if (n.children) {
156
- for (let i = n.children.length - 1; i >= 0; i--) {
157
- stack.push(n.children[i]!)
158
- }
159
- } else {
160
- result.push(n)
161
- }
162
- }
163
- return result
164
- }
165
-
166
- export function links<T>(node: HierarchyNode<T>): HierarchyLink<T>[] {
167
- const result: HierarchyLink<T>[] = []
168
- forEachLink(node, (source, target) => {
169
- result.push({ source, target })
170
- })
171
- return result
172
- }
173
-
174
- export function forEachLink<T>(
175
- node: HierarchyNode<T>,
176
- cb: (source: HierarchyNode<T>, target: HierarchyNode<T>) => void,
177
- ) {
178
- const stack = [node]
179
- while (stack.length > 0) {
180
- const n = stack.pop()!
181
- if (n.children) {
182
- for (let i = n.children.length - 1; i >= 0; i--) {
183
- const child = n.children[i]!
184
- cb(n, child)
185
- stack.push(child)
186
- }
187
- }
188
- }
189
- }
190
-
191
- export function forEachDescendant<T>(
192
- node: HierarchyNode<T>,
193
- cb: (n: HierarchyNode<T>) => void,
194
- ) {
195
- for (const n of descendants(node)) {
196
- cb(n)
197
- }
62
+ return coreHierarchy(data, childrenAccessor)
198
63
  }
199
64
 
200
65
  export function clusterLayout<T>(
@@ -0,0 +1,32 @@
1
+ // @vitest-environment jsdom
2
+ import { expect, test } from 'vitest'
3
+
4
+ import MSAModelF from './model.ts'
5
+
6
+ // jsdom has no 2d context, and measureTextCanvas throws without one. Only the
7
+ // width matters here, so a stub keeps the test about tree size.
8
+ HTMLCanvasElement.prototype.getContext = (() => ({
9
+ font: '12px sans-serif',
10
+ measureText: (t: string) => ({ width: t.length * 7 }),
11
+ })) as unknown as typeof HTMLCanvasElement.prototype.getContext
12
+
13
+ // The import form ships a "230k COVID-19 samples (tree only)" example, so a tree
14
+ // this size is a supported input rather than a hypothetical. Every leaf gets a
15
+ // measured label, and anything that then reduces over them per row has to do it
16
+ // without passing one argument per row.
17
+ function flatNewick(n: number) {
18
+ return `(${Array.from({ length: n }, (_, i) => `s${i}:0.1`).join(',')});`
19
+ }
20
+
21
+ test('a tree far past the argument limit still lays out', () => {
22
+ const model = MSAModelF().create({
23
+ type: 'MsaView',
24
+ data: { tree: flatNewick(200_000) },
25
+ })
26
+ model.setWidth(1000)
27
+
28
+ expect(model.numRows).toBe(200_000)
29
+ // 's199999' is the longest name, at 7 chars * 7px
30
+ expect(model.labelsWidth).toBe(49)
31
+ expect(model.treeWidth).toBeGreaterThan(0)
32
+ })
package/src/model.ts CHANGED
@@ -1438,15 +1438,28 @@ function stateModelFactory() {
1438
1438
  : new Map(
1439
1439
  leaves.map(node => {
1440
1440
  const { name } = node.data
1441
- const displayName = treeMetadata[name]?.genome ?? name
1441
+ // `||`, matching renderTreeLabels: an empty genome falls back
1442
+ // to the row name, and measuring '' would size the gutter (and
1443
+ // the label's click target) to nothing
1444
+ const displayName = treeMetadata[name]?.genome || name
1442
1445
  return [name, measureTextCanvas(displayName, fontSize)] as const
1443
1446
  }),
1444
1447
  )
1445
1448
  },
1446
1449
 
1447
1450
  get labelsWidth() {
1448
- const widths = this.labelWidthMap
1449
- return widths.size === 0 ? 0 : Math.max(...widths.values())
1451
+ // a loop, not Math.max(...widths.values()): spreading a map of every
1452
+ // leaf passes one argument per row, and the argument limit is somewhere
1453
+ // around 125k -- so the bundled 230k-tip COVID tree threw
1454
+ // "RangeError: Maximum call stack size exceeded" out of a getter the
1455
+ // treeWidth autorun reads on load
1456
+ let max = 0
1457
+ for (const width of this.labelWidthMap.values()) {
1458
+ if (width > max) {
1459
+ max = width
1460
+ }
1461
+ }
1462
+ return max
1450
1463
  },
1451
1464
 
1452
1465
  /**
@@ -2298,6 +2311,7 @@ function stateModelFactory() {
2298
2311
  ...(rest.treeMetadataFilehandle
2299
2312
  ? {}
2300
2313
  : { treeMetadata: data.treeMetadata }),
2314
+ ...(rest.gffFilehandle ? {} : { gff: data.gff }),
2301
2315
  },
2302
2316
  }))
2303
2317
  }
@@ -3,6 +3,8 @@ import { describe, expect, test } from 'vitest'
3
3
 
4
4
  import { calculateNeighborJoiningTree } from './neighborJoining.ts'
5
5
 
6
+ import type { NewickNode } from '@gmod/newick'
7
+
6
8
  describe('calculateNeighborJoiningTree', () => {
7
9
  test('generates valid Newick tree for 2 sequences', () => {
8
10
  const rows: [string, string][] = [
@@ -81,6 +83,21 @@ describe('calculateNeighborJoiningTree', () => {
81
83
  expect(tree).toContain('seq3')
82
84
  })
83
85
 
86
+ test('treats a dot gap the same as a dash gap', () => {
87
+ // stockholm and a3m pad with '.', so the two spellings have to score alike
88
+ const withDashes: [string, string][] = [
89
+ ['seq1', 'MK-AYLSMFG'],
90
+ ['seq2', 'MKAAYLSMFG'],
91
+ ['seq3', 'MKA-YLSMFG'],
92
+ ]
93
+ const withDots = withDashes.map(
94
+ ([name, seq]) => [name, seq.replaceAll('-', '.')] as [string, string],
95
+ )
96
+ expect(calculateNeighborJoiningTree(withDots)).toBe(
97
+ calculateNeighborJoiningTree(withDashes),
98
+ )
99
+ })
100
+
84
101
  test('names with special characters round-trip through Newick parsing', () => {
85
102
  const rows: [string, string][] = [
86
103
  ['EU105457.1|chr09:67680268..67675529_LTR/Copia', 'MKAA'],
@@ -89,12 +106,10 @@ describe('calculateNeighborJoiningTree', () => {
89
106
  const newick = calculateNeighborJoiningTree(rows)
90
107
  const tree = parseNewick(newick)
91
108
 
92
- function getLeafNames(node: Record<string, unknown>): string[] {
93
- const children = node.children as Record<string, unknown>[] | undefined
94
- if (!children?.length) {
95
- return [node.name as string]
96
- }
97
- return children.flatMap(c => getLeafNames(c))
109
+ function getLeafNames(node: NewickNode): (string | undefined)[] {
110
+ return node.children?.length
111
+ ? node.children.flatMap(c => getLeafNames(c))
112
+ : [node.name]
98
113
  }
99
114
  const names = getLeafNames(tree)
100
115
  expect(names).toContain('EU105457.1|chr09:67680268..67675529_LTR/Copia')
@@ -44,6 +44,14 @@ function getBlosum62Score(a: string, b: string) {
44
44
  return BLOSUM62.get(a.toUpperCase())?.get(b.toUpperCase()) ?? -4
45
45
  }
46
46
 
47
+ // stockholm and a3m write gaps as '.', not just '-'. Scoring a '.' as a residue
48
+ // charges the pair the unknown-symbol penalty on every padded column, so two
49
+ // sequences that differ only in where an unrelated row forced padding come out
50
+ // as divergent.
51
+ function isGap(c: string) {
52
+ return c === '-' || c === '.'
53
+ }
54
+
47
55
  function computePairwiseDistance(seq1: string, seq2: string) {
48
56
  if (seq1.length !== seq2.length) {
49
57
  throw new Error('Sequences must have the same length (aligned)')
@@ -58,11 +66,13 @@ function computePairwiseDistance(seq1: string, seq2: string) {
58
66
  const a = seq1[i]!
59
67
  const b = seq2[i]!
60
68
 
61
- if (a === '-' && b === '-') {
69
+ const aGap = isGap(a)
70
+ const bGap = isGap(b)
71
+ if (aGap && bGap) {
62
72
  continue
63
73
  }
64
74
 
65
- if (a === '-' || b === '-') {
75
+ if (aGap || bGap) {
66
76
  mismatches++
67
77
  continue
68
78
  }
@@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs'
2
2
 
3
3
  import { expect, test } from 'vitest'
4
4
 
5
+ import { flatToTree } from './flatToTree.ts'
6
+ import MSAModelF from './model.ts'
5
7
  import { parseAsn1 } from './parseAsn1.ts'
6
8
 
7
9
  const r = readFileSync(
@@ -18,3 +20,17 @@ test('throws a clear error on input missing required sections', () => {
18
20
  /missing/,
19
21
  )
20
22
  })
23
+
24
+ test('the tree keeps the labels and branch lengths the file carries', () => {
25
+ // the parsed features have to survive into the tree the viewer draws, or a
26
+ // BLAST tree renders as a lengthless list of node ids
27
+ const model = MSAModelF().create({ type: 'MsaView', data: { tree: r } })
28
+ model.setWidth(1000)
29
+
30
+ expect(model.numRows).toBe(101)
31
+ expect(model.rowNames[0]).toBe(
32
+ 'sodium/glucose cotransporter 4 [Gouania willdenowi]',
33
+ )
34
+ expect(model.allBranchesLength0).toBe(false)
35
+ expect(flatToTree(parseAsn1(r)).length).toBeUndefined()
36
+ })
@@ -55,3 +55,22 @@ test('inline data is stripped when a filehandle can refetch it', () => {
55
55
  expect(snap.data.msa).toBeUndefined()
56
56
  expect(snap.data.tree).toBe('(a,b);')
57
57
  })
58
+
59
+ test('inline gff survives the snapshot when no gffFilehandle can refetch it', () => {
60
+ const gff = 's1\tx\tprotein_match\t1\t3\t.\t.\t.\tName=PF1'
61
+ const withInlineGff = MsaView.create({
62
+ type: 'MsaView',
63
+ data: { msa: '>s1\nACGT', gff },
64
+ })
65
+ expect(getSnapshot(withInlineGff).data.gff).toBe(gff)
66
+
67
+ const withFilehandle = MsaView.create({
68
+ type: 'MsaView',
69
+ data: { msa: '>s1\nACGT', gff },
70
+ gffFilehandle: {
71
+ uri: 'http://example.com/x.gff',
72
+ locationType: 'UriLocation',
73
+ },
74
+ })
75
+ expect(getSnapshot(withFilehandle).data.gff).toBeUndefined()
76
+ })
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '5.7.2'
1
+ export const version = '5.8.0'