react-msaview 6.1.1 → 6.2.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 (47) hide show
  1. package/bundle/index.js +92 -92
  2. package/bundle/index.js.map +4 -4
  3. package/dist/components/dialogs/ExportSVGDialog.js +48 -20
  4. package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
  5. package/dist/components/getVisibleLeaves.d.ts +8 -0
  6. package/dist/components/getVisibleLeaves.js +13 -6
  7. package/dist/components/getVisibleLeaves.js.map +1 -1
  8. package/dist/components/minimap/Minimap.js +1 -1
  9. package/dist/components/minimap/Minimap.js.map +1 -1
  10. package/dist/components/minimap/MinimapSVG.d.ts +4 -4
  11. package/dist/components/minimap/MinimapSVG.js +16 -10
  12. package/dist/components/minimap/MinimapSVG.js.map +1 -1
  13. package/dist/components/minimap/minimapLayout.d.ts +5 -8
  14. package/dist/components/minimap/minimapLayout.js +10 -11
  15. package/dist/components/minimap/minimapLayout.js.map +1 -1
  16. package/dist/components/msa/msaRaster.d.ts +32 -2
  17. package/dist/components/msa/msaRaster.js +93 -15
  18. package/dist/components/msa/msaRaster.js.map +1 -1
  19. package/dist/{renderTestEnv.d.ts → headlessRenderEnv.d.ts} +15 -13
  20. package/dist/{renderTestEnv.js → headlessRenderEnv.js} +22 -18
  21. package/dist/headlessRenderEnv.js.map +1 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +3 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/renderToSvg.js +115 -36
  26. package/dist/renderToSvg.js.map +1 -1
  27. package/dist/version.d.ts +1 -1
  28. package/dist/version.js +1 -1
  29. package/package.json +3 -3
  30. package/src/components/dialogs/ExportSVGDialog.tsx +55 -20
  31. package/src/components/getVisibleLeaves.ts +25 -9
  32. package/src/components/minimap/Minimap.tsx +1 -4
  33. package/src/components/minimap/MinimapSVG.tsx +50 -35
  34. package/src/components/minimap/minimapLayout.test.ts +16 -5
  35. package/src/components/minimap/minimapLayout.ts +10 -11
  36. package/src/components/msa/msaRaster.ts +124 -17
  37. package/src/{renderTestEnv.ts → headlessRenderEnv.ts} +23 -19
  38. package/src/impgRender.test.tsx +2 -2
  39. package/src/index.ts +6 -0
  40. package/src/renderDomainsSvg.test.tsx +2 -2
  41. package/src/renderMinimapSvg.test.tsx +29 -15
  42. package/src/renderRasterSvg.test.tsx +216 -0
  43. package/src/renderSequenceLogoSvg.test.tsx +6 -4
  44. package/src/renderToSvg.test.tsx +79 -0
  45. package/src/renderToSvg.tsx +201 -65
  46. package/src/version.ts +1 -1
  47. package/dist/renderTestEnv.js.map +0 -1
@@ -0,0 +1,216 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // The SVG export's raster background. jsdom has no canvas, so the export falls
4
+ // back to a <rect> per cell there and every other export test exercises that
5
+ // path; this file supplies the canvas the raster needs and checks the export
6
+ // takes it instead.
7
+ import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
8
+ import { enableStaticRendering } from 'mobx-react'
9
+ import { beforeAll, expect, test } from 'vitest'
10
+
11
+ import MSAModelF from './model.ts'
12
+ import { renderToSvg } from './renderToSvg.tsx'
13
+
14
+ const dataUrl = 'data:image/png;base64,STUB'
15
+
16
+ // enough of a 2d context for the raster: a color parser, an ImageData that is
17
+ // really backed by bytes, and a canvas that reads back
18
+ beforeAll(() => {
19
+ enableStaticRendering(true)
20
+ globalThis.DOMMatrix = class {
21
+ a = 1
22
+ b = 0
23
+ c = 0
24
+ d = 1
25
+ e = 0
26
+ f = 0
27
+ constructor(init?: number[]) {
28
+ const [a = 1, b = 0, c = 0, d = 1, e = 0, f = 0] = init ?? []
29
+ Object.assign(this, { a, b, c, d, e, f })
30
+ }
31
+ multiply(o: any) {
32
+ return new (globalThis.DOMMatrix as any)([
33
+ this.a * o.a + this.c * o.b,
34
+ this.b * o.a + this.d * o.b,
35
+ this.a * o.c + this.c * o.d,
36
+ this.b * o.c + this.d * o.d,
37
+ this.a * o.e + this.c * o.f + this.e,
38
+ this.b * o.e + this.d * o.f + this.f,
39
+ ])
40
+ }
41
+ translate(x: number, y = 0) {
42
+ return this.multiply(
43
+ new (globalThis.DOMMatrix as any)([1, 0, 0, 1, x, y]),
44
+ )
45
+ }
46
+ scale(x: number, y = x) {
47
+ return this.multiply(
48
+ new (globalThis.DOMMatrix as any)([x, 0, 0, y, 0, 0]),
49
+ )
50
+ }
51
+ } as any
52
+ globalThis.DOMPoint = class {
53
+ constructor(
54
+ public x = 0,
55
+ public y = 0,
56
+ ) {}
57
+ matrixTransform(m: any) {
58
+ return new (globalThis.DOMPoint as any)(
59
+ m.a * this.x + m.c * this.y + m.e,
60
+ m.b * this.x + m.d * this.y + m.f,
61
+ )
62
+ }
63
+ } as any
64
+
65
+ HTMLCanvasElement.prototype.getContext = function () {
66
+ let font = '10px sans-serif'
67
+ let fillStyle = '#000000'
68
+ return {
69
+ get font() {
70
+ return font
71
+ },
72
+ set font(v: string) {
73
+ font = v
74
+ },
75
+ get fillStyle() {
76
+ return fillStyle
77
+ },
78
+ set fillStyle(v: string) {
79
+ fillStyle = v
80
+ },
81
+ measureText: (t: string) => ({
82
+ width: t.length * (Number.parseFloat(font) || 10) * 0.6,
83
+ }),
84
+ clearRect: () => {},
85
+ fillRect: () => {},
86
+ createImageData: (width: number, height: number) => ({
87
+ data: new Uint8ClampedArray(width * height * 4),
88
+ width,
89
+ height,
90
+ }),
91
+ putImageData: () => {},
92
+ getImageData: () => ({ data: Uint8ClampedArray.from([1, 2, 3, 255]) }),
93
+ } as unknown as CanvasRenderingContext2D
94
+ } as unknown as typeof HTMLCanvasElement.prototype.getContext
95
+ HTMLCanvasElement.prototype.toDataURL = () => dataUrl
96
+ })
97
+
98
+ function makeModel(rows: number, cols: number) {
99
+ const letters = 'ACDEFGHIKLMNPQRSTVWY'
100
+ const msa = Array.from({ length: rows }, (_, r) => {
101
+ let s = ''
102
+ for (let c = 0; c < cols; c++) {
103
+ s += letters[(r * 7 + c * 3) % letters.length]
104
+ }
105
+ return `>seq${r}\n${s}`
106
+ }).join('\n')
107
+ const model = MSAModelF().create({
108
+ id: 'raster',
109
+ type: 'MsaView',
110
+ height: 400,
111
+ msaFormat: 'fasta',
112
+ data: { msa },
113
+ })
114
+ model.setWidth(800)
115
+ return model
116
+ }
117
+
118
+ function exportEntire(model: ReturnType<typeof makeModel>) {
119
+ return renderToSvg(model, {
120
+ theme: createJBrowseTheme(),
121
+ exportType: 'entire',
122
+ })
123
+ }
124
+
125
+ test('the background is one image, not a rect per cell', async () => {
126
+ const model = makeModel(40, 300)
127
+ expect(model.bgColor).toBe(true)
128
+ expect(model.actuallyShowDomains).toBe(false)
129
+
130
+ const svg = await exportEntire(model)
131
+ const images = [...svg.matchAll(/<image[^>]*>/g)].map(m => m[0])
132
+
133
+ expect(images).toHaveLength(1)
134
+ expect(images[0]).toContain(dataUrl)
135
+ // the alignment's own rectangle, one pixel per cell scaled up by whole cells
136
+ expect(images[0]).toContain(`width="${300 * model.colWidth}"`)
137
+ expect(images[0]).toContain(`height="${40 * model.rowHeight}"`)
138
+ expect(images[0]).toContain('image-rendering="pixelated"')
139
+
140
+ // 12000 cells would have been 12000 rects
141
+ expect((svg.match(/<rect/g) ?? []).length).toBeLessThan(10)
142
+ })
143
+
144
+ test('letters still draw on top of the raster', async () => {
145
+ const model = makeModel(4, 10)
146
+ expect(model.showMsaLetters).toBe(true)
147
+
148
+ const svg = await exportEntire(model)
149
+
150
+ expect(svg).toContain('<image')
151
+ expect((svg.match(/<text/g) ?? []).length).toBeGreaterThanOrEqual(40)
152
+ })
153
+
154
+ test('the domain overlay keeps the vector path, which paints its own boxes', async () => {
155
+ const model = makeModel(4, 10)
156
+ model.setDomains({
157
+ seq0: {
158
+ xref: [{ id: 'seq0' }],
159
+ matches: [
160
+ {
161
+ signature: {
162
+ entry: { name: 'Kinase', accession: 'PF00069', description: '' },
163
+ },
164
+ locations: [{ start: 1, end: 5 }],
165
+ },
166
+ ],
167
+ },
168
+ })
169
+ expect(model.actuallyShowDomains).toBe(true)
170
+
171
+ expect(await exportEntire(model)).not.toContain('<image')
172
+ })
173
+
174
+ test('turning background color off leaves nothing for a raster to draw', async () => {
175
+ const model = makeModel(4, 10)
176
+ model.setBgColor(false)
177
+
178
+ expect(await exportEntire(model)).not.toContain('<image')
179
+ })
180
+
181
+ test('the exported minimap carries the alignment thumbnail', async () => {
182
+ const model = makeModel(20, 2000)
183
+ expect(model.showHorizontalScrollbar).toBe(true)
184
+
185
+ const svg = await renderToSvg(model, {
186
+ theme: createJBrowseTheme(),
187
+ exportType: 'viewport',
188
+ includeMinimap: true,
189
+ })
190
+
191
+ // the bar is the same downsampled alignment the live minimap draws, rather
192
+ // than the empty outline the export used to leave
193
+ const bar = [...svg.matchAll(/<image[^>]*>/g)]
194
+ .map(m => m[0])
195
+ .find(i => i.includes('height="12"'))
196
+ expect(bar).toBeDefined()
197
+ expect(bar).toContain(dataUrl)
198
+ expect(bar).toContain(`width="${model.msaCanvasWidth}"`)
199
+ })
200
+
201
+ test('each track is named in the tree column beside it', async () => {
202
+ const model = makeModel(4, 40)
203
+ model.toggleTrack('sequence-logo')
204
+ const names = model.turnedOnTracks.map(t => t.model.name)
205
+ expect(names.length).toBeGreaterThan(1)
206
+
207
+ const svg = await renderToSvg(model, {
208
+ theme: createJBrowseTheme(),
209
+ exportType: 'entire',
210
+ includeTracks: true,
211
+ })
212
+
213
+ for (const name of names) {
214
+ expect(svg).toContain(`>${name}</text>`)
215
+ }
216
+ })
@@ -10,13 +10,16 @@ import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
10
10
  import { enableStaticRendering } from 'mobx-react'
11
11
  import { beforeAll, expect, test } from 'vitest'
12
12
 
13
+ import {
14
+ CHAR_WIDTH_RATIO,
15
+ installHeadlessRenderEnv,
16
+ } from './headlessRenderEnv.ts'
13
17
  import MSAModelF from './model.ts'
14
- import { TEST_CHAR_WIDTH_RATIO, installRenderTestEnv } from './renderTestEnv.ts'
15
18
  import { renderToSvg } from './renderToSvg.tsx'
16
19
 
17
20
  beforeAll(() => {
18
21
  enableStaticRendering(true)
19
- installRenderTestEnv()
22
+ installHeadlessRenderEnv()
20
23
  })
21
24
 
22
25
  // column 0 is fully conserved A, column 1 is a 3:1 split of C over G, and
@@ -159,8 +162,7 @@ test('letters are stretched to fill their column', async () => {
159
162
  model.toggleTrack('sequence-logo')
160
163
  const glyphs = await glyphsOf(model)
161
164
 
162
- const expected =
163
- colWidth / (model.sequenceLogoTrackHeight * TEST_CHAR_WIDTH_RATIO)
165
+ const expected = colWidth / (model.sequenceLogoTrackHeight * CHAR_WIDTH_RATIO)
164
166
  for (const glyph of glyphs) {
165
167
  expect(glyph.sx).toBeCloseTo(expected, 5)
166
168
  }
@@ -0,0 +1,79 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Layout and palette of the exported figure itself, as opposed to what any one
4
+ // layer draws inside it.
5
+ import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
6
+ import { enableStaticRendering } from 'mobx-react'
7
+ import { beforeAll, expect, test } from 'vitest'
8
+
9
+ import { installHeadlessRenderEnv } from './headlessRenderEnv.ts'
10
+ import MSAModelF from './model.ts'
11
+ import { renderToSvg } from './renderToSvg.tsx'
12
+
13
+ beforeAll(() => {
14
+ enableStaticRendering(true)
15
+ installHeadlessRenderEnv()
16
+ })
17
+
18
+ function makeModel({ rows = 60, cols = 400 } = {}) {
19
+ const letters = 'ACDEFGHIKLMNPQRSTVWY'
20
+ const msa = Array.from({ length: rows }, (_, r) => {
21
+ const seq = Array.from(
22
+ { length: cols },
23
+ (_, c) => letters[(r * 7 + c * 3) % letters.length],
24
+ ).join('')
25
+ return `>seq${r}\n${seq}`
26
+ }).join('\n')
27
+ const model = MSAModelF().create({
28
+ type: 'MsaView',
29
+ msaFormat: 'fasta',
30
+ height: 400,
31
+ data: { msa },
32
+ })
33
+ model.setWidth(800)
34
+ return model
35
+ }
36
+
37
+ const rootRect = (svg: string) => /<rect[^>]*height="100%"[^>]*>/.exec(svg)?.[0]
38
+
39
+ test('the page takes the theme background, not a hardcoded white', async () => {
40
+ const model = makeModel()
41
+ const dark = createJBrowseTheme({ palette: { mode: 'dark' } })
42
+ expect(dark.palette.background.default).not.toBe('white')
43
+
44
+ const svg = await renderToSvg(model, { theme: dark, exportType: 'entire' })
45
+
46
+ // the layers below draw in theme colors: text.primary is near-white in a dark
47
+ // theme, so a white page would export white on white
48
+ expect(rootRect(svg)).toContain(`fill="${dark.palette.background.default}"`)
49
+ })
50
+
51
+ test('a viewport export is the alignment canvas, not the whole widget', async () => {
52
+ const model = makeModel()
53
+ expect(model.showHorizontalScrollbar).toBe(true)
54
+ expect(model.showVerticalScrollbar).toBe(true)
55
+
56
+ const svg = await renderToSvg(model, {
57
+ theme: createJBrowseTheme(),
58
+ exportType: 'viewport',
59
+ includeMinimap: true,
60
+ })
61
+
62
+ // the widget box also covers the resize handle and the scrollbars; exporting
63
+ // it drew the rows and columns those hide
64
+ const { treeAreaWidth, msaCanvasWidth, msaAreaHeight, minimapHeight } = model
65
+ expect(svg).toContain(`width="${treeAreaWidth + msaCanvasWidth}"`)
66
+ expect(svg).toContain(`height="${msaAreaHeight + minimapHeight}"`)
67
+ expect(msaAreaHeight + minimapHeight).toBe(model.height)
68
+ })
69
+
70
+ test('no attribute is serialized as the string "undefined"', async () => {
71
+ const model = makeModel({ rows: 4, cols: 20 })
72
+ const svg = await renderToSvg(model, {
73
+ theme: createJBrowseTheme(),
74
+ exportType: 'entire',
75
+ })
76
+
77
+ expect(svg).toContain('<text')
78
+ expect(svg).not.toContain('="undefined"')
79
+ })