react-msaview 6.2.3 → 6.3.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 (80) hide show
  1. package/bundle/index.js +104 -104
  2. package/bundle/index.js.map +4 -4
  3. package/dist/components/MSAViewer.d.ts +10 -1
  4. package/dist/components/MSAViewer.js +3 -1
  5. package/dist/components/MSAViewer.js.map +1 -1
  6. package/dist/components/minimap/Minimap.js +1 -1
  7. package/dist/components/minimap/Minimap.js.map +1 -1
  8. package/dist/components/minimap/MinimapSVG.js +2 -2
  9. package/dist/components/minimap/MinimapSVG.js.map +1 -1
  10. package/dist/components/msa/MSAMouseoverCanvas.js +4 -2
  11. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  12. package/dist/components/msa/msaRaster.d.ts +5 -4
  13. package/dist/components/msa/msaRaster.js +10 -6
  14. package/dist/components/msa/msaRaster.js.map +1 -1
  15. package/dist/components/msa/renderHighlights.d.ts +31 -0
  16. package/dist/components/msa/renderHighlights.js +49 -0
  17. package/dist/components/msa/renderHighlights.js.map +1 -0
  18. package/dist/components/msa/renderMSAMouseover.d.ts +3 -1
  19. package/dist/components/msa/renderMSAMouseover.js +11 -1
  20. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  21. package/dist/components/tracks/drawTracks.js +4 -0
  22. package/dist/components/tracks/drawTracks.js.map +1 -1
  23. package/dist/components/tree/renderTreeCanvas.js +31 -0
  24. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  25. package/dist/exportFileName.d.ts +6 -0
  26. package/dist/exportFileName.js +15 -0
  27. package/dist/exportFileName.js.map +1 -0
  28. package/dist/headlessRenderEnv.d.ts +4 -1
  29. package/dist/headlessRenderEnv.js +6 -3
  30. package/dist/headlessRenderEnv.js.map +1 -1
  31. package/dist/index.d.ts +2 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/model.d.ts +172 -9
  34. package/dist/model.js +134 -3
  35. package/dist/model.js.map +1 -1
  36. package/dist/renderToSvg.js +30 -6
  37. package/dist/renderToSvg.js.map +1 -1
  38. package/dist/svgTestUtil.d.ts +1662 -0
  39. package/dist/svgTestUtil.js +28 -0
  40. package/dist/svgTestUtil.js.map +1 -0
  41. package/dist/types.d.ts +33 -0
  42. package/dist/umd.d.ts +1 -0
  43. package/dist/umd.js +3 -0
  44. package/dist/umd.js.map +1 -1
  45. package/dist/util.d.ts +1 -0
  46. package/dist/util.js +7 -0
  47. package/dist/util.js.map +1 -1
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/package.json +3 -3
  51. package/src/columnTracks.test.ts +97 -0
  52. package/src/components/MSAViewer.tsx +13 -0
  53. package/src/components/minimap/Minimap.tsx +1 -1
  54. package/src/components/minimap/MinimapSVG.tsx +2 -2
  55. package/src/components/msa/MSAMouseoverCanvas.tsx +4 -2
  56. package/src/components/msa/msaRaster.ts +10 -6
  57. package/src/components/msa/renderHighlights.ts +86 -0
  58. package/src/components/msa/renderMSAMouseover.ts +14 -0
  59. package/src/components/tracks/drawTracks.ts +4 -0
  60. package/src/components/tree/renderTreeCanvas.ts +56 -0
  61. package/src/exportFileName.test.ts +40 -0
  62. package/src/exportFileName.ts +20 -0
  63. package/src/headlessRenderEnv.ts +20 -11
  64. package/src/highlights.test.ts +70 -0
  65. package/src/impgRender.test.tsx +2 -4
  66. package/src/index.ts +4 -0
  67. package/src/model.ts +162 -10
  68. package/src/renderColumnTracksSvg.test.tsx +108 -0
  69. package/src/renderDomainsSvg.test.tsx +25 -10
  70. package/src/renderHighlightsSvg.test.tsx +85 -0
  71. package/src/renderMinimapSvg.test.tsx +6 -13
  72. package/src/renderRasterSvg.test.tsx +21 -93
  73. package/src/renderSequenceLogoSvg.test.tsx +4 -17
  74. package/src/renderToSvg.test.tsx +7 -21
  75. package/src/renderToSvg.tsx +32 -6
  76. package/src/svgTestUtil.ts +40 -0
  77. package/src/types.ts +39 -0
  78. package/src/umd.ts +3 -0
  79. package/src/util.ts +8 -0
  80. package/src/version.ts +1 -1
@@ -0,0 +1,20 @@
1
+ import type { FileLocation } from '@jbrowse/core/util/types'
2
+
3
+ /**
4
+ * The name to save an export under: the loaded alignment's file name with the
5
+ * extension swapped, or a plain default when the alignment was pasted in.
6
+ */
7
+ export function exportFileName(
8
+ location: FileLocation | undefined,
9
+ extension: string,
10
+ ) {
11
+ const loc = location as
12
+ { uri?: string; localPath?: string; name?: string } | undefined
13
+ const path = loc?.uri ?? loc?.localPath ?? loc?.name
14
+ const base = path
15
+ ?.split(/[?#]/)[0]
16
+ ?.split(/[/\\]/)
17
+ .pop()
18
+ ?.replace(/\.[^.]*$/, '')
19
+ return `${base || 'image'}.${extension}`
20
+ }
@@ -73,8 +73,14 @@ export class HeadlessDOMPoint {
73
73
  * average for layout and, more usefully, exactly predictable: a caller can
74
74
  * compute the width a renderer will see rather than hardcoding a number
75
75
  * measured from one machine's fonts.
76
+ *
77
+ * `contextExtras` adds to the context a test hands out -- the raster test
78
+ * supplies the ImageData methods that make the export take its image path.
76
79
  */
77
- export function installHeadlessRenderEnv(win: unknown = globalThis) {
80
+ export function installHeadlessRenderEnv(
81
+ win: unknown = globalThis,
82
+ contextExtras: Partial<CanvasRenderingContext2D> = {},
83
+ ) {
78
84
  const g = globalThis as Record<string, unknown>
79
85
  g.DOMMatrix = HeadlessDOMMatrix
80
86
  g.DOMPoint = HeadlessDOMPoint
@@ -82,17 +88,20 @@ export function installHeadlessRenderEnv(win: unknown = globalThis) {
82
88
  .HTMLCanvasElement
83
89
  canvas.prototype.getContext = function () {
84
90
  let font = '10px sans-serif'
85
- return {
86
- get font() {
87
- return font
88
- },
89
- set font(v: string) {
90
- font = v
91
+ return Object.assign(
92
+ {
93
+ get font() {
94
+ return font
95
+ },
96
+ set font(v: string) {
97
+ font = v
98
+ },
99
+ measureText: (t: string) => ({
100
+ width: t.length * (Number.parseFloat(font) || 10) * CHAR_WIDTH_RATIO,
101
+ }),
91
102
  },
92
- measureText: (t: string) => ({
93
- width: t.length * (Number.parseFloat(font) || 10) * CHAR_WIDTH_RATIO,
94
- }),
95
- } as unknown as CanvasRenderingContext2D
103
+ contextExtras,
104
+ ) as unknown as CanvasRenderingContext2D
96
105
  } as unknown as typeof HTMLCanvasElement.prototype.getContext
97
106
  }
98
107
 
@@ -0,0 +1,70 @@
1
+ // @vitest-environment jsdom
2
+ import { expect, test } from 'vitest'
3
+
4
+ import MSAModelF from './model.ts'
5
+
6
+ // seq2 skips columns 3-4 of the alignment, so its residue 3 sits at global
7
+ // column 5 (1-based). The all-gap column 4 is what hideGaps removes.
8
+ const msa = `>seq1
9
+ AC-DEFG
10
+ >seq2
11
+ AC--EFG
12
+ >seq3
13
+ AC-DEFG`
14
+
15
+ function makeModel(highlights: Record<string, unknown>[]) {
16
+ const model = MSAModelF().create({
17
+ id: 'highlights-test',
18
+ type: 'MsaView',
19
+ msaFormat: 'fasta',
20
+ data: { msa },
21
+ highlights,
22
+ })
23
+ model.setWidth(800)
24
+ return model
25
+ }
26
+
27
+ test('a column span is 1-based and lands on 0-based visible columns', () => {
28
+ const model = makeModel([{ start: 2, end: 4, label: 'x' }])
29
+ expect(model.resolvedHighlights).toEqual([
30
+ { startCol: 1, endCol: 3, rowIndices: [], label: 'x', color: undefined },
31
+ ])
32
+ })
33
+
34
+ test('a residue span projects through the named row', () => {
35
+ const model = makeModel([{ row: 'seq2', start: 3, end: 4 }])
36
+ expect(model.resolvedHighlights[0]).toMatchObject({ startCol: 4, endCol: 5 })
37
+ })
38
+
39
+ test('hidden columns shift a span and drop one they swallow whole', () => {
40
+ const model = makeModel([
41
+ { start: 5, end: 6, label: 'after' },
42
+ { start: 3, end: 3, label: 'gone' },
43
+ { row: 'seq2', start: 2, end: 3, label: 'across' },
44
+ ])
45
+ model.setHideGaps(true)
46
+ model.setAllowedGappyness(50)
47
+ expect(model.blanks).toEqual([2])
48
+ expect(
49
+ model.resolvedHighlights.map(h => [h.label, h.startCol, h.endCol]),
50
+ ).toEqual([
51
+ ['after', 3, 4],
52
+ ['across', 1, 3],
53
+ ])
54
+ })
55
+
56
+ test('a row set resolves to row indices and ignores unknown names', () => {
57
+ const model = makeModel([{ rows: ['seq3', 'nope', 'seq1'], color: 'red' }])
58
+ expect(model.resolvedHighlights).toEqual([
59
+ { rowIndices: [2, 0], label: undefined, color: 'red' },
60
+ ])
61
+ expect(makeModel([{ rows: ['nope'] }]).resolvedHighlights).toEqual([])
62
+ })
63
+
64
+ test('setHighlights replaces the list and reset clears it', () => {
65
+ const model = makeModel([])
66
+ model.setHighlights([{ start: 1, end: 1 }])
67
+ expect(model.highlights).toHaveLength(1)
68
+ model.reset()
69
+ expect(model.highlights).toHaveLength(0)
70
+ })
@@ -8,16 +8,14 @@
8
8
  // then renders <MSAView model/>. This exercises that path headlessly via the
9
9
  // public renderToSvg export. Polyfills mirror scripts/generateFigures.tsx.
10
10
  import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
11
- import { enableStaticRendering } from 'mobx-react'
12
11
  import { beforeAll, expect, test } from 'vitest'
13
12
 
14
- import { installHeadlessRenderEnv } from './headlessRenderEnv.ts'
15
13
  import MSAModelF from './model.ts'
16
14
  import { renderToSvg } from './renderToSvg.tsx'
15
+ import { installSvgTestEnv } from './svgTestUtil.ts'
17
16
 
18
17
  beforeAll(() => {
19
- enableStaticRendering(true)
20
- installHeadlessRenderEnv()
18
+ installSvgTestEnv()
21
19
  })
22
20
 
23
21
  // representative `impg query -o fasta-aln` block: PanSN names with region
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // members they rely on (e.g. mouseCol/setMousePos, setHighlightedColumns,
7
7
  // seqPosToVisibleCol/visibleColToSeqPos) are flagged inline in model.ts.
8
8
  export { renderToSvg } from './renderToSvg.tsx'
9
+ export type { ExportSvgOptions } from './renderToSvg.tsx'
9
10
  // renderToSvg outside a browser needs DOM bits jsdom omits; react-msaview-cli
10
11
  // and the README figure generator both drive it that way
11
12
  export {
@@ -30,10 +31,13 @@ export type {
30
31
  Annotation,
31
32
  BasicTrack,
32
33
  BasicTrackModel,
34
+ ColumnTrackSpec,
33
35
  DomainBand,
36
+ Highlight,
34
37
  Node,
35
38
  NodeWithIds,
36
39
  NodeWithIdsAndLength,
40
+ ResolvedHighlight,
37
41
  TextTrackModel,
38
42
  TidyDomainAnnotation,
39
43
  } from './types.ts'
package/src/model.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  segmentShades,
51
51
  } from './constants.ts'
52
52
  import { createPaletteMap } from './createPaletteMap.ts'
53
+ import { exportFileName } from './exportFileName.ts'
53
54
  import { fetchTextWithProgress, isAbortError } from './fetchUtils.ts'
54
55
  import { flatToTree } from './flatToTree.ts'
55
56
  import {
@@ -85,20 +86,29 @@ import {
85
86
  import { buildSeqPosIndex } from './seqPosToGlobalCol.ts'
86
87
  import { maxBitsFor } from './sequenceLogo.ts'
87
88
  import { stripDefault } from './stripDefault.ts'
88
- import { computeRowInsertions, len, skipBlanks, transform } from './util.ts'
89
+ import {
90
+ computeRowInsertions,
91
+ dropBlanks,
92
+ len,
93
+ skipBlanks,
94
+ transform,
95
+ } from './util.ts'
89
96
  import { saveAs } from './vendor/fileSaver.ts'
90
97
 
91
98
  import type { HierarchyNode } from './hierarchy.ts'
99
+ import type { ExportSvgOptions } from './renderToSvg.tsx'
92
100
  import type {
93
101
  Annotation,
94
102
  BasicTrack,
103
+ ColumnTrackSpec,
95
104
  DomainBand,
105
+ Highlight,
96
106
  NodeWithIds,
97
107
  NodeWithIdsAndLength,
108
+ ResolvedHighlight,
98
109
  } from './types.ts'
99
110
  import type { FileLocation as FileLocationType } from '@jbrowse/core/util/types'
100
111
  import type { Instance } from '@jbrowse/mobx-state-tree'
101
- import type { Theme } from '@mui/material'
102
112
  import type { InterProScanResults } from 'msa-parsers'
103
113
 
104
114
  function parseTreeText(text: string) {
@@ -113,6 +123,17 @@ function parseTreeText(text: string) {
113
123
  // asked for.
114
124
  const defaultOffTracks = new Set(['sequence-logo'])
115
125
 
126
+ // a data track over this size stays in the live model but leaves the snapshot,
127
+ // the same rule DataModel applies to an inline document
128
+ const maxColumnTrackSnapshotBytes = 50_000
129
+
130
+ function smallColumnTracks(tracks?: ColumnTrackSpec[]) {
131
+ const kept = tracks?.filter(
132
+ t => JSON.stringify(t).length <= maxColumnTrackSnapshotBytes,
133
+ )
134
+ return kept?.length ? { columnTracks: kept } : {}
135
+ }
136
+
116
137
  /**
117
138
  * The snapshot properties reset() carries across a return to the import form:
118
139
  * display preferences and layout, nothing derived from the loaded file.
@@ -320,6 +341,16 @@ function stateModelFactory() {
320
341
  * hidden-by-default track adds nothing to the shared URL.
321
342
  */
322
343
  turnedOffTracks: stripDefault(types.map(types.boolean), {}),
344
+ /**
345
+ * #property
346
+ * tracks supplied as data rather than computed from the alignment:
347
+ * per-column values drawn as bars, or a per-column string drawn as a
348
+ * text track. See docs/layers.md
349
+ */
350
+ columnTracks: stripDefault(
351
+ types.array(types.frozen<ColumnTrackSpec>()),
352
+ [],
353
+ ),
323
354
 
324
355
  /**
325
356
  * #property
@@ -349,6 +380,14 @@ function stateModelFactory() {
349
380
  * in afterCreate.
350
381
  */
351
382
  highlightColumns: types.frozen<number[] | undefined>(),
383
+ /**
384
+ * #property
385
+ * labeled highlights in 1-based inclusive coordinates: a column span
386
+ * `{start, end}`, a residue span `{row, start, end}` of a named row,
387
+ * or a row set `{rows}`, each with an optional `label` and `color`.
388
+ * Persists in the snapshot, so a computed answer travels in the URL.
389
+ */
390
+ highlights: stripDefault(types.array(types.frozen<Highlight>()), []),
352
391
  }),
353
392
  )
354
393
  .volatile(() => ({
@@ -559,6 +598,12 @@ function stateModelFactory() {
559
598
  setHighlightedColumns(columns?: number[]) {
560
599
  self.highlightedColumns = columns
561
600
  },
601
+ /**
602
+ * #action
603
+ */
604
+ setHighlights(highlights: Highlight[]) {
605
+ self.highlights.replace(highlights)
606
+ },
562
607
  /**
563
608
  * #action
564
609
  * toggle the annotation overlay on the alignment
@@ -1527,6 +1572,12 @@ function stateModelFactory() {
1527
1572
  self.scrollX = clamp(n, self.maxScrollX, 0)
1528
1573
  },
1529
1574
 
1575
+ /**
1576
+ * #action
1577
+ */
1578
+ setColumnTracks(tracks: ColumnTrackSpec[]) {
1579
+ self.columnTracks.replace(tracks)
1580
+ },
1530
1581
  /**
1531
1582
  * #action
1532
1583
  */
@@ -1628,6 +1679,65 @@ function stateModelFactory() {
1628
1679
  }))
1629
1680
  },
1630
1681
 
1682
+ /**
1683
+ * #getter
1684
+ * a data track's values or string, projected from its row's residues
1685
+ * onto alignment columns when it names a row
1686
+ */
1687
+ get columnTrackContent() {
1688
+ const { MSA, blanks, hideGapsEffective } = self
1689
+ const width = MSA?.getWidth() ?? 0
1690
+ const project = <T>(track: ColumnTrackSpec, items: T[], fill: T) => {
1691
+ if (!track.row) {
1692
+ return items
1693
+ }
1694
+ const out = Array.from({ length: width }, () => fill)
1695
+ const index = self.seqPosGlobalColIndex.get(track.row)
1696
+ items.forEach((item, seqPos) => {
1697
+ const col = index?.[seqPos]
1698
+ if (col !== undefined) {
1699
+ out[col] = item
1700
+ }
1701
+ })
1702
+ return out
1703
+ }
1704
+ const skip = <T>(items: T[]) =>
1705
+ hideGapsEffective ? dropBlanks(blanks, items) : items
1706
+ return new Map<string, { values?: number[]; data?: string }>(
1707
+ self.columnTracks.map(track => {
1708
+ if (track.kind === 'bar') {
1709
+ const max = track.max ?? 1
1710
+ const values = skip(project(track, track.values ?? [], 0)).map(
1711
+ v => Math.min(1, Math.max(0, v / max)),
1712
+ )
1713
+ return [track.id, { values }] as const
1714
+ }
1715
+ const data = skip(project(track, (track.data ?? '').split(''), ' '))
1716
+ return [track.id, { data: data.join('') }] as const
1717
+ }),
1718
+ )
1719
+ },
1720
+ /**
1721
+ * #getter
1722
+ */
1723
+ get columnTrackModels(): BasicTrack[] {
1724
+ return self.columnTracks.map(track => ({
1725
+ model: {
1726
+ id: track.id,
1727
+ name: track.name,
1728
+ kind: track.kind,
1729
+ height:
1730
+ track.height ??
1731
+ (track.kind === 'bar'
1732
+ ? self.conservationTrackHeight
1733
+ : self.rowHeight),
1734
+ barColor: track.color,
1735
+ customColorScheme: track.colors,
1736
+ data: this.columnTrackContent.get(track.id)?.data,
1737
+ },
1738
+ ReactComponent: TrackBlocks,
1739
+ }))
1740
+ },
1631
1741
  /**
1632
1742
  * #getter
1633
1743
  */
@@ -1654,6 +1764,7 @@ function stateModelFactory() {
1654
1764
  }
1655
1765
  return [
1656
1766
  ...this.adapterTrackModels,
1767
+ ...this.columnTrackModels,
1657
1768
  ...[
1658
1769
  conservationTrack,
1659
1770
  ...(self.sequenceType === 'amino'
@@ -2047,6 +2158,51 @@ function stateModelFactory() {
2047
2158
  return runs
2048
2159
  },
2049
2160
 
2161
+ /**
2162
+ * #getter
2163
+ * `highlights` projected onto what is on screen: residue spans go
2164
+ * through the named row's gap structure, column spans through the
2165
+ * hidden-column list, and a span that lands entirely on hidden columns
2166
+ * is dropped. Row names that match no row are ignored.
2167
+ */
2168
+ get resolvedHighlights(): ResolvedHighlight[] {
2169
+ const { blanks, rowNamesSet, rowMap } = self
2170
+ const toVisible = (globalCol: number) => {
2171
+ const visible = self.globalColToVisibleCol(globalCol)
2172
+ return visible ?? visibleColsBefore(blanks, globalCol)
2173
+ }
2174
+ return self.highlights.flatMap(
2175
+ ({ row, rows, start, end, label, color }) => {
2176
+ const base = { label, color }
2177
+ if (rows) {
2178
+ const rowIndices = rows
2179
+ .map(name => rowNamesSet.get(name))
2180
+ .filter(notEmpty)
2181
+ return rowIndices.length ? [{ ...base, rowIndices }] : []
2182
+ }
2183
+ if (start === undefined || end === undefined) {
2184
+ return []
2185
+ }
2186
+ let startGlobal = start - 1
2187
+ let endGlobal = end - 1
2188
+ if (row !== undefined) {
2189
+ if (!rowMap.has(row)) {
2190
+ return []
2191
+ }
2192
+ startGlobal = self.seqPosToGlobalCol(row, start - 1)
2193
+ endGlobal = self.seqPosToGlobalCol(row, end - 1)
2194
+ }
2195
+ const startCol = toVisible(startGlobal)
2196
+ const endVisible = self.globalColToVisibleCol(endGlobal)
2197
+ const endCol =
2198
+ endVisible ?? visibleColsBefore(blanks, endGlobal) - 1
2199
+ return startCol <= endCol
2200
+ ? [{ ...base, startCol, endCol, rowIndices: [] }]
2201
+ : []
2202
+ },
2203
+ )
2204
+ },
2205
+
2050
2206
  /**
2051
2207
  * #getter
2052
2208
  * per-column summary statistics for the hovered column: consensus residue
@@ -2140,16 +2296,11 @@ function stateModelFactory() {
2140
2296
  /**
2141
2297
  * #action
2142
2298
  */
2143
- async exportSVG(opts: {
2144
- theme: Theme
2145
- includeMinimap?: boolean
2146
- includeTracks?: boolean
2147
- exportType: 'entire' | 'viewport'
2148
- }) {
2299
+ async exportSVG(opts: ExportSvgOptions) {
2149
2300
  const { renderToSvg } = await import('./renderToSvg.tsx')
2150
2301
  const html = await renderToSvg(self as MsaViewModel, opts)
2151
2302
  const blob = new Blob([html], { type: 'image/svg+xml' })
2152
- saveAs(blob, 'image.svg')
2303
+ saveAs(blob, exportFileName(self.msaFilehandle, 'svg'))
2153
2304
  },
2154
2305
  initFilter(arg: string) {
2155
2306
  if (!self.featureFilters.has(arg)) {
@@ -2443,11 +2594,12 @@ function stateModelFactory() {
2443
2594
  )
2444
2595
  },
2445
2596
  }))
2446
- .postProcessSnapshot(({ data, ...rest }) => ({
2597
+ .postProcessSnapshot(({ data, columnTracks, ...rest }) => ({
2447
2598
  // per-property defaults are stripped by the stripDefault helper; the only thing
2448
2599
  // it can't express is this cross-field rule: drop inline tree/msa/metadata
2449
2600
  // when a sibling filehandle can refetch them, keeping sessions/URLs small
2450
2601
  ...rest,
2602
+ ...smallColumnTracks(columnTracks),
2451
2603
  data: {
2452
2604
  ...(rest.treeFilehandle ? {} : { tree: data.tree }),
2453
2605
  ...(rest.msaFilehandle ? {} : { msa: data.msa }),
@@ -0,0 +1,108 @@
1
+ // @vitest-environment jsdom
2
+ import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
3
+ import { enableStaticRendering } from 'mobx-react'
4
+ import { beforeAll, expect, test } from 'vitest'
5
+
6
+ import { installHeadlessRenderEnv } from './headlessRenderEnv.ts'
7
+ import MSAModelF from './model.ts'
8
+ import { renderToSvg } from './renderToSvg.tsx'
9
+
10
+ import type { ColumnTrackSpec } from './types.ts'
11
+
12
+ beforeAll(() => {
13
+ enableStaticRendering(true)
14
+ installHeadlessRenderEnv()
15
+ })
16
+
17
+ const msa = '>s1\nACGT\n>s2\nACGT\n'
18
+ const colWidth = 20
19
+ const barColor = '#123456'
20
+
21
+ function makeModel(columnTracks: ColumnTrackSpec[]) {
22
+ const model = MSAModelF().create({
23
+ type: 'MsaView',
24
+ msaFormat: 'fasta',
25
+ height: 400,
26
+ colWidth,
27
+ data: { msa },
28
+ columnTracks,
29
+ })
30
+ model.setWidth(1000)
31
+ model.toggleTrack('conservation')
32
+ return model
33
+ }
34
+
35
+ async function exportSvg(model: ReturnType<typeof makeModel>) {
36
+ return renderToSvg(model, {
37
+ theme: createJBrowseTheme(),
38
+ exportType: 'entire',
39
+ includeTracks: true,
40
+ })
41
+ }
42
+
43
+ function rectsFilled(svg: string, fill: string) {
44
+ return [...svg.matchAll(/<rect ([^>]*)\/?>/g)]
45
+ .map(m => m[1]!)
46
+ .filter(attrs => attrs.includes(`fill="${fill}"`))
47
+ .map(attrs => ({
48
+ x: Number(/x="([\d.-]+)"/.exec(attrs)?.[1]),
49
+ width: Number(/width="([\d.-]+)"/.exec(attrs)?.[1]),
50
+ height: Number(/height="([\d.-]+)"/.exec(attrs)?.[1]),
51
+ }))
52
+ }
53
+
54
+ test('a bar track from values exports one bar per column in its color', async () => {
55
+ const model = makeModel([
56
+ {
57
+ id: 'dnds',
58
+ name: 'dN/dS',
59
+ kind: 'bar',
60
+ values: [1, 0.5, 0, 0.25],
61
+ color: barColor,
62
+ },
63
+ ])
64
+ const bars = rectsFilled(await exportSvg(model), barColor).sort(
65
+ (a, b) => a.x - b.x,
66
+ )
67
+ const trackHeight = model.conservationTrackHeight
68
+ expect(bars.map(b => b.x)).toEqual([0, colWidth, colWidth * 2, colWidth * 3])
69
+ expect(bars.map(b => b.height)).toEqual([
70
+ trackHeight,
71
+ trackHeight / 2,
72
+ 0,
73
+ trackHeight / 4,
74
+ ])
75
+ expect(svgNamesTrack(await exportSvg(model))).toContain('dN/dS')
76
+ })
77
+
78
+ test('a text track from data exports its letters and its own colors', async () => {
79
+ const model = makeModel([
80
+ {
81
+ id: 'frame',
82
+ name: 'Codon frame',
83
+ kind: 'text',
84
+ data: '1231',
85
+ colors: { 1: '#aaaaaa', 2: '#bbbbbb', 3: '#cccccc' },
86
+ },
87
+ ])
88
+ const svg = await exportSvg(model)
89
+ expect(
90
+ rectsFilled(svg, '#aaaaaa')
91
+ .map(b => b.x)
92
+ .sort((a, b) => a - b),
93
+ ).toEqual([0, colWidth * 3])
94
+ expect(rectsFilled(svg, '#bbbbbb').map(b => b.x)).toEqual([colWidth])
95
+ expect(svgNamesTrack(svg)).toContain('Codon frame')
96
+ })
97
+
98
+ test('a data track that is toggled off stays out of the export', async () => {
99
+ const model = makeModel([
100
+ { id: 'dnds', name: 'dN/dS', kind: 'bar', values: [1], color: barColor },
101
+ ])
102
+ model.toggleTrack('dnds')
103
+ expect(rectsFilled(await exportSvg(model), barColor)).toEqual([])
104
+ })
105
+
106
+ function svgNamesTrack(svg: string) {
107
+ return [...svg.matchAll(/<text[^>]*>([^<]*)<\/text>/g)].map(m => m[1])
108
+ }
@@ -5,16 +5,13 @@
5
5
  // right. Polyfills mirror impgRender.test.tsx (svgcanvas needs
6
6
  // DOMMatrix/DOMPoint + a measureText).
7
7
  import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
8
- import { enableStaticRendering } from 'mobx-react'
9
8
  import { beforeAll, expect, test } from 'vitest'
10
9
 
11
- import { installHeadlessRenderEnv } from './headlessRenderEnv.ts'
12
- import MSAModelF from './model.ts'
13
10
  import { renderToSvg } from './renderToSvg.tsx'
11
+ import { createTestModel, installSvgTestEnv } from './svgTestUtil.ts'
14
12
 
15
13
  beforeAll(() => {
16
- enableStaticRendering(true)
17
- installHeadlessRenderEnv()
14
+ installSvgTestEnv()
18
15
  })
19
16
 
20
17
  const msa = `>seq1
@@ -27,14 +24,10 @@ function makeModel({
27
24
  start = 1,
28
25
  end = 5,
29
26
  }: { data?: string; start?: number; end?: number } = {}) {
30
- const model = MSAModelF().create({
27
+ const model = createTestModel({
31
28
  id: 'domain-svg-test',
32
- type: 'MsaView',
33
- height: 400,
34
- msaFormat: 'fasta',
35
29
  data: { msa: data },
36
30
  })
37
- model.setWidth(800)
38
31
  model.setDomains({
39
32
  seq1: {
40
33
  xref: [{ id: 'seq1' }],
@@ -80,6 +73,28 @@ test('no legend column when domains are hidden', async () => {
80
73
  expect(await exportEntire(model)).not.toContain('Kinase')
81
74
  })
82
75
 
76
+ test('the page grows to hold a key taller than the alignment', async () => {
77
+ const model = makeModel()
78
+ model.setDomains({
79
+ seq1: {
80
+ xref: [{ id: 'seq1' }],
81
+ matches: Array.from({ length: 12 }, (_, i) => ({
82
+ signature: {
83
+ entry: { name: `Dom${i}`, accession: `PF${i}`, description: '' },
84
+ },
85
+ locations: [{ start: 1, end: 5 }],
86
+ })),
87
+ },
88
+ })
89
+ expect(model.visibleDomainTypes).toHaveLength(12)
90
+ const keyHeight = 16 + 12 * 16
91
+ expect(model.totalHeight).toBeLessThan(keyHeight)
92
+
93
+ const svg = await exportEntire(model)
94
+ expect(svg).toContain(`height="${keyHeight}"`)
95
+ expect(/<svg[^>]*height="(\d+)"/.exec(svg)?.[1]).toBe(String(keyHeight))
96
+ })
97
+
83
98
  test('a domain box past the on-screen block size still draws', async () => {
84
99
  // the export renders the alignment in one pass, so the overlay has to be told
85
100
  // how wide that pass is; falling back to the 500px on-screen block size culled