react-msaview 5.4.1 → 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 (77) 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/MSAViewer.d.ts +7 -1
  7. package/dist/components/MSAViewer.js +10 -7
  8. package/dist/components/MSAViewer.js.map +1 -1
  9. package/dist/components/Track.js +8 -5
  10. package/dist/components/Track.js.map +1 -1
  11. package/dist/components/dialogs/FeatureDialog.js +27 -23
  12. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  13. package/dist/components/header/MultiAlignmentSelector.js +0 -0
  14. package/dist/components/header/MultiAlignmentSelector.js.map +1 -1
  15. package/dist/components/msa/DomainLegend.d.ts +6 -0
  16. package/dist/components/msa/DomainLegend.js +41 -0
  17. package/dist/components/msa/DomainLegend.js.map +1 -0
  18. package/dist/components/msa/MSACanvasBlock.js +17 -5
  19. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  20. package/dist/components/msa/MSAPanel.js +3 -1
  21. package/dist/components/msa/MSAPanel.js.map +1 -1
  22. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +58 -14
  23. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  24. package/dist/components/msa/renderMSABlock.js +3 -3
  25. package/dist/components/msa/renderMSABlock.js.map +1 -1
  26. package/dist/components/msa/renderMSAMouseover.js +32 -4
  27. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  28. package/dist/components/renderCtx.d.ts +1 -1
  29. package/dist/components/tracks/renderTracksSvg.js +3 -3
  30. package/dist/components/tracks/renderTracksSvg.js.map +1 -1
  31. package/dist/constants.d.ts +3 -0
  32. package/dist/constants.js +16 -0
  33. package/dist/constants.js.map +1 -1
  34. package/dist/createPaletteMap.d.ts +5 -2
  35. package/dist/createPaletteMap.js +15 -5
  36. package/dist/createPaletteMap.js.map +1 -1
  37. package/dist/launchInterProScan.d.ts +2 -0
  38. package/dist/launchInterProScan.js.map +1 -1
  39. package/dist/model.d.ts +96 -1
  40. package/dist/model.js +91 -3
  41. package/dist/model.js.map +1 -1
  42. package/dist/renderToSvg.js +104 -74
  43. package/dist/renderToSvg.js.map +1 -1
  44. package/dist/{webpack.js → umd.js} +1 -1
  45. package/dist/umd.js.map +1 -0
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +1 -1
  48. package/package.json +7 -6
  49. package/src/colorSchemes.ts +12 -7
  50. package/src/components/MSAViewer.tsx +30 -18
  51. package/src/components/Track.tsx +8 -5
  52. package/src/components/dialogs/FeatureDialog.tsx +62 -48
  53. package/src/components/header/MultiAlignmentSelector.tsx +0 -0
  54. package/src/components/msa/DomainLegend.tsx +74 -0
  55. package/src/components/msa/MSACanvasBlock.tsx +22 -4
  56. package/src/components/msa/MSAPanel.tsx +2 -0
  57. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +73 -17
  58. package/src/components/msa/renderMSABlock.ts +3 -3
  59. package/src/components/msa/renderMSAMouseover.ts +32 -4
  60. package/src/components/renderCtx.ts +1 -0
  61. package/src/components/tracks/renderTracksSvg.ts +3 -3
  62. package/src/constants.ts +18 -0
  63. package/src/createPaletteMap.ts +18 -5
  64. package/src/launchInterProScan.ts +3 -1
  65. package/src/model.ts +105 -2
  66. package/src/renderDomainLegendSvg.test.tsx +134 -0
  67. package/src/renderToSvg.tsx +210 -147
  68. package/src/segmentOverlay.test.ts +70 -0
  69. package/src/version.ts +1 -1
  70. package/bundle/index.js.LICENSE.txt +0 -100
  71. package/dist/components/util.d.ts +0 -1
  72. package/dist/components/util.js +0 -7
  73. package/dist/components/util.js.map +0 -1
  74. package/dist/webpack.js.map +0 -1
  75. package/src/components/util.ts +0 -9
  76. /package/dist/{webpack.d.ts → umd.d.ts} +0 -0
  77. /package/src/{webpack.ts → umd.ts} +0 -0
@@ -1,11 +1,24 @@
1
+ import { colord } from 'colord'
2
+
1
3
  import palettes from './ggplotPalettes.ts'
2
4
 
3
5
  /**
4
- * Creates a map from keys to colors using ggplot-style palettes.
5
- * The palette is selected based on the number of keys.
6
+ * Creates a map from keys to colors. Up to the largest ggplot palette (8 keys)
7
+ * we use the hand-picked ggplot colors; beyond that e.g. a gene-structure
8
+ * overlay with one feature per exon — we generate evenly spaced HSL hues so
9
+ * every key still gets a distinct, stable color instead of running off the end
10
+ * of the palette.
6
11
  */
7
12
  export function createPaletteMap(keys: string[]) {
8
- const k = Math.min(keys.length - 1, palettes.length - 1)
9
- const palette = palettes[k]!
10
- return Object.fromEntries(keys.map((key, i) => [key, palette[i]!]))
13
+ const n = keys.length
14
+ if (n <= palettes.length) {
15
+ const palette = palettes[n - 1]!
16
+ return Object.fromEntries(keys.map((key, i) => [key, palette[i]!]))
17
+ }
18
+ return Object.fromEntries(
19
+ keys.map((key, i) => [
20
+ key,
21
+ colord({ h: (i * 360) / n, s: 60, l: 60 }).toHex(),
22
+ ]),
23
+ )
11
24
  }
@@ -13,9 +13,11 @@ export interface InterProScanResults {
13
13
  name: string
14
14
  description: string
15
15
  accession: string
16
+ // original GFF feature type (exon, CDS, ...) when sourced from GFF
17
+ featureType?: string
16
18
  }
17
19
  }
18
- locations: { start: number; end: number }[]
20
+ locations: { start: number; end: number; strand?: number }[]
19
21
  }[]
20
22
  xref: { id: string }[]
21
23
  }
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'
@@ -373,12 +383,15 @@ function stateModelFactory() {
373
383
  .actions(self => ({
374
384
  /**
375
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
376
388
  */
377
389
  drawRelativeTo(id: string | undefined) {
378
390
  self.relativeTo = id
379
391
  },
380
392
  /**
381
393
  * #action
394
+ * hide columns that are entirely (or mostly, see allowedGappyness) gaps
382
395
  */
383
396
  setHideGaps(arg: boolean) {
384
397
  self.hideGaps = arg
@@ -457,6 +470,7 @@ function stateModelFactory() {
457
470
  },
458
471
  /**
459
472
  * #action
473
+ * toggle the InterProScan protein-domain overlay on the alignment
460
474
  */
461
475
  setShowDomains(arg: boolean) {
462
476
  self.showDomains = arg
@@ -510,6 +524,7 @@ function stateModelFactory() {
510
524
 
511
525
  /**
512
526
  * #action
527
+ * collapse or un-collapse the subtree rooted at the given tree node id
513
528
  */
514
529
  toggleCollapsed(node: string) {
515
530
  if (self.collapsed.includes(node)) {
@@ -521,6 +536,8 @@ function stateModelFactory() {
521
536
 
522
537
  /**
523
538
  * #action
539
+ * show only the subtree rooted at the given node id (pass undefined to
540
+ * show the whole tree again)
524
541
  */
525
542
  setShowOnly(node?: string) {
526
543
  self.showOnly = node
@@ -528,6 +545,8 @@ function stateModelFactory() {
528
545
 
529
546
  /**
530
547
  * #action
548
+ * set the alignment/tree/metadata/domain data directly from strings,
549
+ * bypassing the filehandle loaders
531
550
  */
532
551
  setData(data: {
533
552
  msa?: string
@@ -1232,6 +1251,7 @@ function stateModelFactory() {
1232
1251
 
1233
1252
  /**
1234
1253
  * #action
1254
+ * restore the default column width and row height
1235
1255
  */
1236
1256
  resetZoom() {
1237
1257
  self.setColWidth(defaultColWidth)
@@ -1592,13 +1612,15 @@ function stateModelFactory() {
1592
1612
  val.matches.flatMap(({ signature, locations }) => {
1593
1613
  const { entry } = signature
1594
1614
  return entry
1595
- ? locations.map(({ start, end }) => ({
1615
+ ? locations.map(({ start, end, strand }) => ({
1596
1616
  id,
1597
1617
  name: entry.name,
1598
1618
  accession: entry.accession,
1599
1619
  description: entry.description,
1620
+ featureType: entry.featureType,
1600
1621
  start,
1601
1622
  end,
1623
+ strand,
1602
1624
  }))
1603
1625
  : []
1604
1626
  }),
@@ -1630,8 +1652,38 @@ function stateModelFactory() {
1630
1652
  get verticalScrollbarWidth() {
1631
1653
  return self.showVerticalScrollbar ? 20 : 0
1632
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
+ },
1633
1676
  get fillPalette() {
1634
- 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 }
1635
1687
  },
1636
1688
  get strokePalette() {
1637
1689
  return transform(this.fillPalette, ([key, val]) => [
@@ -1640,6 +1692,57 @@ function stateModelFactory() {
1640
1692
  ])
1641
1693
  },
1642
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
+
1643
1746
  /**
1644
1747
  * #method
1645
1748
  */
@@ -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
+ })