react-msaview 5.4.0 → 5.5.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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/bundle/index.js +465 -138
  3. package/bundle/index.js.map +7 -1
  4. package/dist/colorSchemes.js +11 -7
  5. package/dist/colorSchemes.js.map +1 -1
  6. package/dist/components/Checkbox2.js +3 -1
  7. package/dist/components/Checkbox2.js.map +1 -1
  8. package/dist/components/MSAViewer.d.ts +7 -1
  9. package/dist/components/MSAViewer.js +11 -10
  10. package/dist/components/MSAViewer.js.map +1 -1
  11. package/dist/components/Track.js +8 -5
  12. package/dist/components/Track.js.map +1 -1
  13. package/dist/components/dialogs/FeatureDialog.js +27 -23
  14. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  15. package/dist/components/dialogs/InterProScanDialog.js +2 -1
  16. package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
  17. package/dist/components/header/MultiAlignmentSelector.js +0 -0
  18. package/dist/components/header/MultiAlignmentSelector.js.map +1 -1
  19. package/dist/components/msa/DomainLegend.d.ts +6 -0
  20. package/dist/components/msa/DomainLegend.js +41 -0
  21. package/dist/components/msa/DomainLegend.js.map +1 -0
  22. package/dist/components/msa/MSACanvasBlock.js +17 -5
  23. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  24. package/dist/components/msa/MSAPanel.js +3 -1
  25. package/dist/components/msa/MSAPanel.js.map +1 -1
  26. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +58 -14
  27. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  28. package/dist/components/msa/renderMSABlock.js +7 -9
  29. package/dist/components/msa/renderMSABlock.js.map +1 -1
  30. package/dist/components/msa/renderMSAMouseover.js +32 -4
  31. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  32. package/dist/components/renderCtx.d.ts +1 -1
  33. package/dist/components/tracks/renderTracksSvg.js +4 -4
  34. package/dist/components/tracks/renderTracksSvg.js.map +1 -1
  35. package/dist/components/tree/renderTreeCanvas.js +3 -0
  36. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  37. package/dist/constants.d.ts +3 -0
  38. package/dist/constants.js +16 -0
  39. package/dist/constants.js.map +1 -1
  40. package/dist/createPaletteMap.d.ts +5 -2
  41. package/dist/createPaletteMap.js +15 -5
  42. package/dist/createPaletteMap.js.map +1 -1
  43. package/dist/hierarchy.js +4 -1
  44. package/dist/hierarchy.js.map +1 -1
  45. package/dist/launchInterProScan.d.ts +2 -0
  46. package/dist/launchInterProScan.js.map +1 -1
  47. package/dist/model/msaModel.js.map +1 -1
  48. package/dist/model.d.ts +100 -3
  49. package/dist/model.js +121 -19
  50. package/dist/model.js.map +1 -1
  51. package/dist/neighborJoining.js +44 -30
  52. package/dist/neighborJoining.js.map +1 -1
  53. package/dist/renderToSvg.js +104 -74
  54. package/dist/renderToSvg.js.map +1 -1
  55. package/dist/{webpack.js → umd.js} +1 -1
  56. package/dist/umd.js.map +1 -0
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +8 -7
  60. package/src/colorSchemes.ts +12 -7
  61. package/src/components/Checkbox2.tsx +7 -1
  62. package/src/components/MSAViewer.tsx +31 -21
  63. package/src/components/Track.tsx +8 -5
  64. package/src/components/dialogs/FeatureDialog.tsx +62 -48
  65. package/src/components/dialogs/InterProScanDialog.tsx +2 -1
  66. package/src/components/header/MultiAlignmentSelector.tsx +0 -0
  67. package/src/components/msa/DomainLegend.tsx +74 -0
  68. package/src/components/msa/MSACanvasBlock.tsx +22 -4
  69. package/src/components/msa/MSAPanel.tsx +2 -0
  70. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +73 -17
  71. package/src/components/msa/renderMSABlock.ts +5 -11
  72. package/src/components/msa/renderMSAMouseover.ts +32 -4
  73. package/src/components/renderCtx.ts +1 -0
  74. package/src/components/tracks/renderTracksSvg.ts +4 -4
  75. package/src/components/tree/renderTreeCanvas.ts +5 -1
  76. package/src/constants.ts +18 -0
  77. package/src/createPaletteMap.ts +18 -5
  78. package/src/hierarchy.ts +4 -1
  79. package/src/launchInterProScan.ts +3 -1
  80. package/src/model/msaModel.ts +3 -1
  81. package/src/model.ts +137 -36
  82. package/src/neighborJoining.ts +44 -31
  83. package/src/renderDomainLegendSvg.test.tsx +134 -0
  84. package/src/renderToSvg.tsx +210 -147
  85. package/src/segmentOverlay.test.ts +70 -0
  86. package/src/version.ts +1 -1
  87. package/bundle/index.js.LICENSE.txt +0 -100
  88. package/dist/components/util.d.ts +0 -1
  89. package/dist/components/util.js +0 -7
  90. package/dist/components/util.js.map +0 -1
  91. package/dist/webpack.js.map +0 -1
  92. package/src/components/util.ts +0 -9
  93. /package/dist/{webpack.d.ts → umd.d.ts} +0 -0
  94. /package/src/{webpack.ts → umd.ts} +0 -0
package/src/model.ts CHANGED
@@ -43,6 +43,8 @@ import {
43
43
  maxCellSize,
44
44
  minColWidth,
45
45
  minRowHeight,
46
+ segmentFeatureTypes,
47
+ segmentShades,
46
48
  } from './constants.ts'
47
49
  import { createPaletteMap } from './createPaletteMap.ts'
48
50
  import { fetchTextWithProgress, isAbortError } from './fetchUtils.ts'
@@ -95,6 +97,14 @@ function parseTreeText(text: string) {
95
97
 
96
98
  /**
97
99
  * #stateModel MsaView
100
+ *
101
+ * The main MSAView state model. Holds the loaded alignment, tree, and optional
102
+ * InterProScan domain annotations, plus all display state (color scheme, zoom,
103
+ * scroll, collapsed clades). It composes in members from `DialogQueueSessionMixin`,
104
+ * `Tree`, and `MSAModel` (see Inherited members below). Data is loaded reactively
105
+ * from the `msaFilehandle` / `treeFilehandle` / `gffFilehandle` properties, or set
106
+ * directly with `setData`. Most state is persisted into the shareable URL.
107
+ *
98
108
  * #example
99
109
  * ```js
100
110
  * import { MSAModelF } from 'react-msaview'
@@ -249,6 +259,15 @@ function stateModelFactory() {
249
259
  * #property
250
260
  */
251
261
  relativeTo: types.maybe(types.string),
262
+ /**
263
+ * #property
264
+ * declarative seed for the highlighted-columns overlay (visible column
265
+ * indices). Unlike the volatile `highlightedColumns` (driven by
266
+ * transient genome-hover sync), this persists in the snapshot/URL so a
267
+ * shared link can open with specific columns highlighted. Applied once
268
+ * in afterCreate.
269
+ */
270
+ highlightColumns: types.frozen<number[] | undefined>(),
252
271
  }),
253
272
  )
254
273
  .volatile(() => ({
@@ -364,12 +383,15 @@ function stateModelFactory() {
364
383
  .actions(self => ({
365
384
  /**
366
385
  * #action
386
+ * draw the alignment with positions numbered relative to the given row's
387
+ * sequence (its node id), instead of in raw MSA-column coordinates
367
388
  */
368
389
  drawRelativeTo(id: string | undefined) {
369
390
  self.relativeTo = id
370
391
  },
371
392
  /**
372
393
  * #action
394
+ * hide columns that are entirely (or mostly, see allowedGappyness) gaps
373
395
  */
374
396
  setHideGaps(arg: boolean) {
375
397
  self.hideGaps = arg
@@ -448,6 +470,7 @@ function stateModelFactory() {
448
470
  },
449
471
  /**
450
472
  * #action
473
+ * toggle the InterProScan protein-domain overlay on the alignment
451
474
  */
452
475
  setShowDomains(arg: boolean) {
453
476
  self.showDomains = arg
@@ -501,6 +524,7 @@ function stateModelFactory() {
501
524
 
502
525
  /**
503
526
  * #action
527
+ * collapse or un-collapse the subtree rooted at the given tree node id
504
528
  */
505
529
  toggleCollapsed(node: string) {
506
530
  if (self.collapsed.includes(node)) {
@@ -512,6 +536,8 @@ function stateModelFactory() {
512
536
 
513
537
  /**
514
538
  * #action
539
+ * show only the subtree rooted at the given node id (pass undefined to
540
+ * show the whole tree again)
515
541
  */
516
542
  setShowOnly(node?: string) {
517
543
  self.showOnly = node
@@ -519,6 +545,8 @@ function stateModelFactory() {
519
545
 
520
546
  /**
521
547
  * #action
548
+ * set the alignment/tree/metadata/domain data directly from strings,
549
+ * bypassing the filehandle loaders
522
550
  */
523
551
  setData(data: {
524
552
  msa?: string
@@ -944,7 +972,10 @@ function stateModelFactory() {
944
972
  get colConsensus() {
945
973
  const { colStats, colStatsSums } = this
946
974
  return colStats.map((stats, i) => {
947
- const total = colStatsSums[i]!
975
+ const total = colStatsSums[i]
976
+ if (!total) {
977
+ return { letter: '', color: undefined }
978
+ }
948
979
  let maxCount = 0
949
980
  let letter = ''
950
981
  for (const [key, val] of Object.entries(stats)) {
@@ -1220,6 +1251,7 @@ function stateModelFactory() {
1220
1251
 
1221
1252
  /**
1222
1253
  * #action
1254
+ * restore the default column width and row height
1223
1255
  */
1224
1256
  resetZoom() {
1225
1257
  self.setColWidth(defaultColWidth)
@@ -1259,12 +1291,8 @@ function stateModelFactory() {
1259
1291
  */
1260
1292
  zoomIn() {
1261
1293
  transaction(() => {
1262
- self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
1263
- self.rowHeight = Math.min(
1264
- maxCellSize,
1265
- Math.ceil(self.rowHeight * 1.5),
1266
- )
1267
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1294
+ this.zoomInHorizontal()
1295
+ this.zoomInVertical()
1268
1296
  })
1269
1297
  },
1270
1298
  /**
@@ -1272,15 +1300,8 @@ function stateModelFactory() {
1272
1300
  */
1273
1301
  zoomOut() {
1274
1302
  transaction(() => {
1275
- self.colWidth = Math.max(
1276
- minColWidth,
1277
- Math.floor(self.colWidth * 0.75),
1278
- )
1279
- self.rowHeight = Math.max(
1280
- minRowHeight,
1281
- Math.floor(self.rowHeight * 0.75),
1282
- )
1283
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1303
+ this.zoomOutHorizontal()
1304
+ this.zoomOutVertical()
1284
1305
  })
1285
1306
  },
1286
1307
  /**
@@ -1314,9 +1335,13 @@ function stateModelFactory() {
1314
1335
  )
1315
1336
 
1316
1337
  const anchoredScrollY = offsetY - rowInView * self.rowHeight
1317
- const overflow = Math.max(0, self.totalHeight - self.height)
1338
+ // maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
1339
+ // overflows, so -maxScrollY is exactly that overflow past the
1340
+ // scrollable MSA viewport (0 when it fits)
1341
+ const overflow = Math.max(0, -self.maxScrollY)
1342
+ const visibleHeight = self.totalHeight - overflow
1318
1343
  const topBias =
1319
- self.height > 0 ? clamp(1 - overflow / self.height, 0, 1) : 0
1344
+ visibleHeight > 0 ? clamp(1 - overflow / visibleHeight, 0, 1) : 1
1320
1345
  self.scrollY = clamp(
1321
1346
  anchoredScrollY * (1 - topBias),
1322
1347
  self.maxScrollY,
@@ -1587,13 +1612,15 @@ function stateModelFactory() {
1587
1612
  val.matches.flatMap(({ signature, locations }) => {
1588
1613
  const { entry } = signature
1589
1614
  return entry
1590
- ? locations.map(({ start, end }) => ({
1615
+ ? locations.map(({ start, end, strand }) => ({
1591
1616
  id,
1592
1617
  name: entry.name,
1593
1618
  accession: entry.accession,
1594
1619
  description: entry.description,
1620
+ featureType: entry.featureType,
1595
1621
  start,
1596
1622
  end,
1623
+ strand,
1597
1624
  }))
1598
1625
  : []
1599
1626
  }),
@@ -1625,8 +1652,38 @@ function stateModelFactory() {
1625
1652
  get verticalScrollbarWidth() {
1626
1653
  return self.showVerticalScrollbar ? 20 : 0
1627
1654
  },
1655
+ /**
1656
+ * #getter
1657
+ * ordinal segment types (exons etc.), ordered by sequence position so
1658
+ * exon-1..exon-14 read left-to-right; colored by alternating shade and
1659
+ * labeled by number rather than each getting a distinct hue + legend row
1660
+ */
1661
+ get segmentDomainTypes() {
1662
+ return [...self.tidyInterProAnnotationTypes.values()]
1663
+ .filter(d => segmentFeatureTypes.has(d.featureType ?? ''))
1664
+ .toSorted((a, b) => a.start - b.start)
1665
+ },
1666
+ /**
1667
+ * #getter
1668
+ * categorical feature types (InterPro domains and the like) that each get
1669
+ * their own color and a legend entry
1670
+ */
1671
+ get categoricalDomainTypes() {
1672
+ return [...self.tidyInterProAnnotationTypes.values()].filter(
1673
+ d => !segmentFeatureTypes.has(d.featureType ?? ''),
1674
+ )
1675
+ },
1628
1676
  get fillPalette() {
1629
- return createPaletteMap([...self.tidyInterProAnnotationTypes.keys()])
1677
+ const segments = Object.fromEntries(
1678
+ this.segmentDomainTypes.map((d, i) => [
1679
+ d.accession,
1680
+ segmentShades[i % segmentShades.length]!,
1681
+ ]),
1682
+ )
1683
+ const categorical = createPaletteMap(
1684
+ this.categoricalDomainTypes.map(d => d.accession),
1685
+ )
1686
+ return { ...segments, ...categorical }
1630
1687
  },
1631
1688
  get strokePalette() {
1632
1689
  return transform(this.fillPalette, ([key, val]) => [
@@ -1635,6 +1692,57 @@ function stateModelFactory() {
1635
1692
  ])
1636
1693
  },
1637
1694
 
1695
+ /**
1696
+ * #getter
1697
+ * accession -> number drawn on each segment band: the trailing number of
1698
+ * the feature name ("exon-3" -> "3"), else its 1-based position
1699
+ */
1700
+ get segmentLabels() {
1701
+ return new Map(
1702
+ this.segmentDomainTypes.map((d, i) => {
1703
+ const m = /(\d+)\s*$/.exec(d.name)
1704
+ return [d.accession, m ? m[1]! : `${i + 1}`]
1705
+ }),
1706
+ )
1707
+ },
1708
+
1709
+ /**
1710
+ * #getter
1711
+ * the domain types currently drawn on the alignment (filtered-on), shared
1712
+ * by the on-screen legend and the SVG export legend. Ordinal segments
1713
+ * (exons) are excluded — they read as a numbered gene model, not a color
1714
+ * key — so this is the categorical types ordered by sequence position
1715
+ */
1716
+ get visibleDomainTypes() {
1717
+ return this.categoricalDomainTypes
1718
+ .filter(d => self.featureFilters.get(d.accession))
1719
+ .toSorted((a, b) => a.start - b.start)
1720
+ },
1721
+
1722
+ /**
1723
+ * #getter
1724
+ * domain annotations under the mouse, hit-tested against the exact visible
1725
+ * column span each box is drawn at (so it matches the overlay across gaps)
1726
+ */
1727
+ get mouseOverDomains() {
1728
+ const { mouseCol } = self
1729
+ const name = self.mouseOverRowName
1730
+ if (name !== undefined && mouseCol !== undefined) {
1731
+ const entry = self.tidyFilteredGatheredInterProAnnotations[name] ?? []
1732
+ return entry.filter(d => {
1733
+ const m1 = self.seqPosToVisibleCol(name, d.start - 1)
1734
+ const m2 = self.seqPosToVisibleCol(name, d.end)
1735
+ return (
1736
+ m1 !== undefined &&
1737
+ m2 !== undefined &&
1738
+ mouseCol >= m1 &&
1739
+ mouseCol < m2
1740
+ )
1741
+ })
1742
+ }
1743
+ return []
1744
+ },
1745
+
1638
1746
  /**
1639
1747
  * #method
1640
1748
  */
@@ -1703,22 +1811,8 @@ function stateModelFactory() {
1703
1811
  * #action
1704
1812
  */
1705
1813
  fit() {
1706
- if (self.numRows > 0) {
1707
- self.rowHeight = clamp(
1708
- self.msaAreaHeight / self.numRows,
1709
- minRowHeight,
1710
- maxCellSize,
1711
- )
1712
- }
1713
- if (self.numColumns > 0) {
1714
- self.colWidth = clamp(
1715
- self.msaAreaWidth / self.numColumns,
1716
- minColWidth,
1717
- maxCellSize,
1718
- )
1719
- }
1720
- self.scrollX = 0
1721
- self.scrollY = 0
1814
+ this.fitVertically()
1815
+ this.fitHorizontally()
1722
1816
  },
1723
1817
  /**
1724
1818
  * #action
@@ -1748,6 +1842,13 @@ function stateModelFactory() {
1748
1842
  },
1749
1843
 
1750
1844
  afterCreate() {
1845
+ // seed the highlighted-columns overlay from the declarative property so
1846
+ // a shared snapshot/URL opens with those columns highlighted (the
1847
+ // volatile highlightedColumns can later be driven by genome-hover sync)
1848
+ if (self.highlightColumns?.length) {
1849
+ self.setHighlightedColumns(self.highlightColumns)
1850
+ }
1851
+
1751
1852
  addDisposer(
1752
1853
  self,
1753
1854
  autorun(() => {
@@ -194,16 +194,10 @@ function neighborJoining(distances: number[][], names: string[]): NJNode {
194
194
  const ri = r.get(minI)!
195
195
  const rj = r.get(minJ)!
196
196
 
197
- let limbI: number
198
- let limbJ: number
199
-
200
- if (remaining > 2) {
201
- limbI = dij / 2 + (ri - rj) / (2 * (remaining - 2))
202
- limbJ = dij - limbI
203
- } else {
204
- limbI = dij / 2
205
- limbJ = dij / 2
206
- }
197
+ // remaining is always > 2 inside this loop; the final two nodes are
198
+ // connected after it
199
+ let limbI = dij / 2 + (ri - rj) / (2 * (remaining - 2))
200
+ let limbJ = dij - limbI
207
201
 
208
202
  // Ensure non-negative branch lengths
209
203
  limbI = Math.max(0, limbI)
@@ -217,19 +211,18 @@ function neighborJoining(distances: number[][], names: string[]): NJNode {
217
211
  rightLength: limbJ,
218
212
  }
219
213
 
220
- // Update distance matrix
221
- const newIdx = minI
214
+ // Update distance matrix, reusing minI's slot for the merged node
222
215
  for (const k of active) {
223
216
  if (k !== minI && k !== minJ) {
224
217
  const newDist = (D[minI]![k]! + D[minJ]![k]! - dij) / 2
225
- D[newIdx]![k] = Math.max(0, newDist)
226
- D[k]![newIdx] = Math.max(0, newDist)
218
+ D[minI]![k] = Math.max(0, newDist)
219
+ D[k]![minI] = Math.max(0, newDist)
227
220
  }
228
221
  }
229
222
 
230
223
  // Mark minJ as removed
231
224
  nodes[minJ] = undefined
232
- nodes[newIdx] = newNode
225
+ nodes[minI] = newNode
233
226
 
234
227
  remaining--
235
228
  }
@@ -257,25 +250,45 @@ function neighborJoining(distances: number[][], names: string[]): NJNode {
257
250
  return nodes[finalActive[0]!]!
258
251
  }
259
252
 
260
- function nodeToNewick(node: NJNode, branchLength?: number): string {
261
- let result: string
262
-
263
- if (node.name !== undefined && !node.left && !node.right) {
264
- const quotedName = node.name.replaceAll("'", "''")
265
- result = `'${quotedName}'`
266
- } else {
267
- const leftNewick = node.left ? nodeToNewick(node.left, node.leftLength) : ''
268
- const rightNewick = node.right
269
- ? nodeToNewick(node.right, node.rightLength)
270
- : ''
271
- result = `(${leftNewick},${rightNewick})`
272
- }
253
+ function withBranchLength(newick: string, branchLength?: number) {
254
+ return branchLength === undefined
255
+ ? newick
256
+ : `${newick}:${branchLength.toFixed(6)}`
257
+ }
273
258
 
274
- if (branchLength !== undefined) {
275
- result += `:${branchLength.toFixed(6)}`
259
+ // Emit Newick iteratively rather than recursively: an NJ tree of N leaves can
260
+ // be a caterpillar of depth ~N, so recursion risks a stack overflow on large
261
+ // alignments (matching the iterative-traversal convention in hierarchy.ts).
262
+ function nodeToNewick(root: NJNode): string {
263
+ const postOrder: NJNode[] = []
264
+ const stack: NJNode[] = [root]
265
+ while (stack.length > 0) {
266
+ const node = stack.pop()!
267
+ postOrder.push(node)
268
+ if (node.left) {
269
+ stack.push(node.left)
270
+ }
271
+ if (node.right) {
272
+ stack.push(node.right)
273
+ }
276
274
  }
277
275
 
278
- return result
276
+ const newickByNode = new Map<NJNode, string>()
277
+ for (let i = postOrder.length - 1; i >= 0; i--) {
278
+ const node = postOrder[i]!
279
+ if (node.name !== undefined && !node.left && !node.right) {
280
+ newickByNode.set(node, `'${node.name.replaceAll("'", "''")}'`)
281
+ } else {
282
+ const left = node.left
283
+ ? withBranchLength(newickByNode.get(node.left)!, node.leftLength)
284
+ : ''
285
+ const right = node.right
286
+ ? withBranchLength(newickByNode.get(node.right)!, node.rightLength)
287
+ : ''
288
+ newickByNode.set(node, `(${left},${right})`)
289
+ }
290
+ }
291
+ return newickByNode.get(root)!
279
292
  }
280
293
 
281
294
  export function calculateNeighborJoiningTree(
@@ -0,0 +1,134 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Verifies the domain color-key legend is drawn as a reserved column on the
4
+ // right of the SVG export when domains are shown. Polyfills mirror
5
+ // impgRender.test.tsx (svgcanvas needs DOMMatrix/DOMPoint + a measureText).
6
+ import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
7
+ import { enableStaticRendering } from 'mobx-react'
8
+ import { beforeAll, expect, test } from 'vitest'
9
+
10
+ import MSAModelF from './model.ts'
11
+ import { renderToSvg } from './renderToSvg.tsx'
12
+
13
+ class Mat {
14
+ constructor(
15
+ public a = 1,
16
+ public b = 0,
17
+ public c = 0,
18
+ public d = 1,
19
+ public e = 0,
20
+ public f = 0,
21
+ ) {}
22
+ multiply(o: Mat) {
23
+ return new Mat(
24
+ this.a * o.a + this.c * o.b,
25
+ this.b * o.a + this.d * o.b,
26
+ this.a * o.c + this.c * o.d,
27
+ this.b * o.c + this.d * o.d,
28
+ this.a * o.e + this.c * o.f + this.e,
29
+ this.b * o.e + this.d * o.f + this.f,
30
+ )
31
+ }
32
+ translate(x: number, y = 0) {
33
+ return this.multiply(new Mat(1, 0, 0, 1, x, y))
34
+ }
35
+ scale(x: number, y = x) {
36
+ return this.multiply(new Mat(x, 0, 0, y, 0, 0))
37
+ }
38
+ }
39
+ class Pt {
40
+ constructor(
41
+ public x = 0,
42
+ public y = 0,
43
+ ) {}
44
+ matrixTransform(m: Mat) {
45
+ return new Pt(
46
+ m.a * this.x + m.c * this.y + m.e,
47
+ m.b * this.x + m.d * this.y + m.f,
48
+ )
49
+ }
50
+ }
51
+
52
+ beforeAll(() => {
53
+ enableStaticRendering(true)
54
+ const g = globalThis as Record<string, unknown>
55
+ g.DOMMatrix = Mat
56
+ g.DOMPoint = Pt
57
+ HTMLCanvasElement.prototype.getContext = function () {
58
+ let font = '10px sans-serif'
59
+ return {
60
+ get font() {
61
+ return font
62
+ },
63
+ set font(v: string) {
64
+ font = v
65
+ },
66
+ measureText(t: string) {
67
+ const size = Number.parseFloat(font) || 10
68
+ return { width: t.length * size * 0.6 } as TextMetrics
69
+ },
70
+ } as unknown as CanvasRenderingContext2D
71
+ } as unknown as typeof HTMLCanvasElement.prototype.getContext
72
+ })
73
+
74
+ const msa = `>seq1
75
+ ACDEFGHIKL
76
+ >seq2
77
+ ACDE-GHIKL`
78
+
79
+ function makeModel() {
80
+ const model = MSAModelF().create({
81
+ id: 'domain-legend-test',
82
+ type: 'MsaView',
83
+ height: 400,
84
+ msaFormat: 'fasta',
85
+ data: { msa },
86
+ })
87
+ model.setWidth(800)
88
+ model.setDomains({
89
+ seq1: {
90
+ xref: [{ id: 'seq1' }],
91
+ matches: [
92
+ {
93
+ signature: {
94
+ entry: {
95
+ name: 'Kinase',
96
+ accession: 'PF00069',
97
+ description: 'Protein kinase domain',
98
+ },
99
+ },
100
+ locations: [{ start: 1, end: 5 }],
101
+ },
102
+ ],
103
+ },
104
+ })
105
+ return model
106
+ }
107
+
108
+ test('domain legend is drawn in the svg export when domains are shown', async () => {
109
+ const model = makeModel()
110
+ expect(model.actuallyShowDomains).toBe(true)
111
+ expect(model.visibleDomainTypes.map(d => d.accession)).toEqual(['PF00069'])
112
+
113
+ const svg = await renderToSvg(model, {
114
+ theme: createJBrowseTheme(),
115
+ exportType: 'entire',
116
+ includeMinimap: false,
117
+ includeTracks: false,
118
+ })
119
+ expect(svg).toContain('Kinase')
120
+ })
121
+
122
+ test('no legend column when domains are hidden', async () => {
123
+ const model = makeModel()
124
+ model.setShowDomains(false)
125
+ expect(model.actuallyShowDomains).toBe(false)
126
+
127
+ const svg = await renderToSvg(model, {
128
+ theme: createJBrowseTheme(),
129
+ exportType: 'entire',
130
+ includeMinimap: false,
131
+ includeTracks: false,
132
+ })
133
+ expect(svg).not.toContain('Kinase')
134
+ })