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.
Files changed (57) hide show
  1. package/bundle/index.js +22 -22
  2. package/bundle/index.js.LICENSE.txt +1 -1
  3. package/bundle/index.js.map +1 -1
  4. package/dist/components/Checkbox2.js +3 -1
  5. package/dist/components/Checkbox2.js.map +1 -1
  6. package/dist/components/Loading.js +6 -4
  7. package/dist/components/Loading.js.map +1 -1
  8. package/dist/components/MSAViewer.d.ts +3 -1
  9. package/dist/components/MSAViewer.js +3 -4
  10. package/dist/components/MSAViewer.js.map +1 -1
  11. package/dist/components/dialogs/InterProScanDialog.js +2 -1
  12. package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
  13. package/dist/components/msa/renderMSABlock.js +4 -6
  14. package/dist/components/msa/renderMSABlock.js.map +1 -1
  15. package/dist/components/renderCtx.d.ts +1 -1
  16. package/dist/components/tracks/renderTracksSvg.js +1 -1
  17. package/dist/components/tracks/renderTracksSvg.js.map +1 -1
  18. package/dist/components/tree/TreeNodeMenu.js +1 -1
  19. package/dist/components/tree/TreeNodeMenu.js.map +1 -1
  20. package/dist/components/tree/renderTreeCanvas.d.ts +10 -0
  21. package/dist/components/tree/renderTreeCanvas.js +84 -12
  22. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  23. package/dist/fetchUtils.d.ts +19 -0
  24. package/dist/fetchUtils.js +28 -0
  25. package/dist/fetchUtils.js.map +1 -1
  26. package/dist/hierarchy.d.ts +9 -1
  27. package/dist/hierarchy.js +37 -0
  28. package/dist/hierarchy.js.map +1 -1
  29. package/dist/model/msaModel.d.ts +7 -0
  30. package/dist/model/msaModel.js +14 -0
  31. package/dist/model/msaModel.js.map +1 -1
  32. package/dist/model.d.ts +9 -4
  33. package/dist/model.js +95 -32
  34. package/dist/model.js.map +1 -1
  35. package/dist/neighborJoining.js +44 -30
  36. package/dist/neighborJoining.js.map +1 -1
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/package.json +5 -5
  40. package/src/components/Checkbox2.tsx +7 -1
  41. package/src/components/Loading.tsx +15 -5
  42. package/src/components/MSAViewer.tsx +5 -3
  43. package/src/components/dialogs/InterProScanDialog.tsx +2 -1
  44. package/src/components/msa/renderMSABlock.ts +2 -8
  45. package/src/components/renderCtx.ts +1 -0
  46. package/src/components/tracks/renderTracksSvg.ts +1 -1
  47. package/src/components/tree/TreeNodeMenu.tsx +1 -1
  48. package/src/components/tree/renderTreeCanvas.ts +117 -11
  49. package/src/fetchUtils.test.ts +66 -0
  50. package/src/fetchUtils.ts +48 -0
  51. package/src/hierarchy.test.ts +56 -0
  52. package/src/hierarchy.ts +44 -1
  53. package/src/impgRender.test.tsx +125 -0
  54. package/src/model/msaModel.ts +21 -0
  55. package/src/model.ts +99 -55
  56. package/src/neighborJoining.ts +44 -31
  57. 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 = collapsed.includes(id) ? 'black' : 'white'
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
- clickMap?.insert({
148
- minX: x - radius + ml,
149
- maxX: x - radius + d + ml,
150
- minY: y - radius,
151
- maxY: y - radius + d,
152
- branch: true,
153
- id,
154
- name,
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
- if (y > offsetY - extendBounds && y < offsetY + by + extendBounds) {
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
 
@@ -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
+ })
@@ -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
  }