react-msaview 6.2.2 → 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.
- package/bundle/index.js +104 -104
- package/bundle/index.js.map +4 -4
- package/dist/components/MSAViewer.d.ts +10 -1
- package/dist/components/MSAViewer.js +3 -1
- package/dist/components/MSAViewer.js.map +1 -1
- package/dist/components/minimap/Minimap.js +1 -1
- package/dist/components/minimap/Minimap.js.map +1 -1
- package/dist/components/minimap/MinimapSVG.js +2 -2
- package/dist/components/minimap/MinimapSVG.js.map +1 -1
- package/dist/components/msa/MSAMouseoverCanvas.js +4 -2
- package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
- package/dist/components/msa/msaRaster.d.ts +5 -4
- package/dist/components/msa/msaRaster.js +10 -6
- package/dist/components/msa/msaRaster.js.map +1 -1
- package/dist/components/msa/renderHighlights.d.ts +31 -0
- package/dist/components/msa/renderHighlights.js +49 -0
- package/dist/components/msa/renderHighlights.js.map +1 -0
- package/dist/components/msa/renderMSAMouseover.d.ts +3 -1
- package/dist/components/msa/renderMSAMouseover.js +11 -1
- package/dist/components/msa/renderMSAMouseover.js.map +1 -1
- package/dist/components/tracks/drawTracks.js +4 -0
- package/dist/components/tracks/drawTracks.js.map +1 -1
- package/dist/components/tree/renderTreeCanvas.js +31 -0
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/exportFileName.d.ts +6 -0
- package/dist/exportFileName.js +15 -0
- package/dist/exportFileName.js.map +1 -0
- package/dist/headlessRenderEnv.d.ts +4 -1
- package/dist/headlessRenderEnv.js +6 -3
- package/dist/headlessRenderEnv.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js.map +1 -1
- package/dist/model.d.ts +172 -9
- package/dist/model.js +134 -3
- package/dist/model.js.map +1 -1
- package/dist/renderToSvg.js +30 -6
- package/dist/renderToSvg.js.map +1 -1
- package/dist/svgTestUtil.d.ts +1662 -0
- package/dist/svgTestUtil.js +28 -0
- package/dist/svgTestUtil.js.map +1 -0
- package/dist/types.d.ts +33 -0
- package/dist/umd.d.ts +1 -0
- package/dist/umd.js +3 -0
- package/dist/umd.js.map +1 -1
- package/dist/util.d.ts +1 -0
- package/dist/util.js +7 -0
- package/dist/util.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/columnTracks.test.ts +97 -0
- package/src/components/MSAViewer.tsx +13 -0
- package/src/components/minimap/Minimap.tsx +1 -1
- package/src/components/minimap/MinimapSVG.tsx +2 -2
- package/src/components/msa/MSAMouseoverCanvas.tsx +4 -2
- package/src/components/msa/msaRaster.ts +10 -6
- package/src/components/msa/renderHighlights.ts +86 -0
- package/src/components/msa/renderMSAMouseover.ts +14 -0
- package/src/components/tracks/drawTracks.ts +4 -0
- package/src/components/tree/renderTreeCanvas.ts +56 -0
- package/src/exportFileName.test.ts +40 -0
- package/src/exportFileName.ts +20 -0
- package/src/headlessRenderEnv.ts +20 -11
- package/src/highlights.test.ts +70 -0
- package/src/impgRender.test.tsx +2 -4
- package/src/index.ts +4 -0
- package/src/model.ts +162 -10
- package/src/renderColumnTracksSvg.test.tsx +108 -0
- package/src/renderDomainsSvg.test.tsx +25 -10
- package/src/renderHighlightsSvg.test.tsx +85 -0
- package/src/renderMinimapSvg.test.tsx +6 -13
- package/src/renderRasterSvg.test.tsx +21 -93
- package/src/renderSequenceLogoSvg.test.tsx +4 -17
- package/src/renderToSvg.test.tsx +7 -21
- package/src/renderToSvg.tsx +32 -6
- package/src/svgTestUtil.ts +40 -0
- package/src/types.ts +39 -0
- package/src/umd.ts +3 -0
- package/src/util.ts +8 -0
- 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
|
+
}
|
package/src/headlessRenderEnv.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
font
|
|
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
|
-
|
|
93
|
-
|
|
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
|
+
})
|
package/src/impgRender.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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, '
|
|
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
|
-
|
|
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 =
|
|
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
|