react-msaview 6.1.1 → 6.2.1
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 +92 -92
- package/bundle/index.js.map +4 -4
- package/dist/components/dialogs/ExportSVGDialog.js +48 -20
- package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
- package/dist/components/getVisibleLeaves.d.ts +8 -0
- package/dist/components/getVisibleLeaves.js +13 -6
- package/dist/components/getVisibleLeaves.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.d.ts +4 -4
- package/dist/components/minimap/MinimapSVG.js +16 -10
- package/dist/components/minimap/MinimapSVG.js.map +1 -1
- package/dist/components/minimap/minimapLayout.d.ts +5 -8
- package/dist/components/minimap/minimapLayout.js +10 -11
- package/dist/components/minimap/minimapLayout.js.map +1 -1
- package/dist/components/msa/msaRaster.d.ts +32 -2
- package/dist/components/msa/msaRaster.js +93 -15
- package/dist/components/msa/msaRaster.js.map +1 -1
- package/dist/{renderTestEnv.d.ts → headlessRenderEnv.d.ts} +15 -13
- package/dist/{renderTestEnv.js → headlessRenderEnv.js} +22 -18
- package/dist/headlessRenderEnv.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/model.d.ts +25 -0
- package/dist/model.js +62 -13
- package/dist/model.js.map +1 -1
- package/dist/renderToSvg.js +115 -36
- package/dist/renderToSvg.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/components/dialogs/ExportSVGDialog.tsx +55 -20
- package/src/components/getVisibleLeaves.ts +25 -9
- package/src/components/minimap/Minimap.tsx +1 -4
- package/src/components/minimap/MinimapSVG.tsx +50 -35
- package/src/components/minimap/minimapLayout.test.ts +16 -5
- package/src/components/minimap/minimapLayout.ts +10 -11
- package/src/components/msa/msaRaster.ts +124 -17
- package/src/{renderTestEnv.ts → headlessRenderEnv.ts} +23 -19
- package/src/impgRender.test.tsx +2 -2
- package/src/index.ts +6 -0
- package/src/model.ts +77 -13
- package/src/modelReset.test.ts +120 -0
- package/src/renderDomainsSvg.test.tsx +2 -2
- package/src/renderMinimapSvg.test.tsx +29 -15
- package/src/renderRasterSvg.test.tsx +216 -0
- package/src/renderSequenceLogoSvg.test.tsx +6 -4
- package/src/renderToSvg.test.tsx +79 -0
- package/src/renderToSvg.tsx +201 -65
- package/src/version.ts +1 -1
- package/dist/renderTestEnv.js.map +0 -1
|
@@ -3,20 +3,19 @@ import type { MsaViewModel } from '../../model.ts'
|
|
|
3
3
|
export const MINIMAP_BAR_HEIGHT = 12
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Viewport-rectangle and trapezoid geometry for
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* differ: on screen the minimap sits over the alignment canvas, while the SVG
|
|
11
|
-
* export lays the whole figure out on its own grid. What the rectangle *marks*
|
|
12
|
-
* is always the on-screen viewport, so that part comes from the model.
|
|
6
|
+
* Viewport-rectangle and trapezoid geometry for the minimap, shared by the
|
|
7
|
+
* interactive Minimap and the static MinimapSVG. Both span the alignment canvas
|
|
8
|
+
* exactly -- a minimap drawn a different width than the columns it maps puts
|
|
9
|
+
* its viewport rectangle over the wrong ones.
|
|
13
10
|
*/
|
|
14
|
-
export function getMinimapLayout(model: MsaViewModel
|
|
11
|
+
export function getMinimapLayout(model: MsaViewModel) {
|
|
15
12
|
const { scrollX, minimapHeight, totalWidth, msaCanvasWidth } = model
|
|
16
|
-
const unit = totalWidth > 0 ?
|
|
13
|
+
const unit = totalWidth > 0 ? msaCanvasWidth / totalWidth : 0
|
|
17
14
|
const s = -scrollX * unit
|
|
18
|
-
|
|
15
|
+
// the rectangle marks the visible slice of the alignment, so it can never be
|
|
16
|
+
// wider than the bar itself
|
|
17
|
+
const w = Math.min(msaCanvasWidth, Math.max(msaCanvasWidth * unit, 20))
|
|
19
18
|
const polygonHeight = minimapHeight - MINIMAP_BAR_HEIGHT
|
|
20
|
-
const polygonPoints = `${s + w},0 ${s},0 0,${polygonHeight} ${
|
|
19
|
+
const polygonPoints = `${s + w},0 ${s},0 0,${polygonHeight} ${msaCanvasWidth},${polygonHeight}`
|
|
21
20
|
return { unit, s, w, polygonHeight, polygonPoints }
|
|
22
21
|
}
|
|
@@ -11,7 +11,7 @@ const maxCachedTiles = 64
|
|
|
11
11
|
const maxThumbnailColumns = 2000
|
|
12
12
|
const maxColorCacheEntries = 4096
|
|
13
13
|
|
|
14
|
-
type RasterCanvas = HTMLCanvasElement | OffscreenCanvas
|
|
14
|
+
export type RasterCanvas = HTMLCanvasElement | OffscreenCanvas
|
|
15
15
|
type RasterCtx = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
|
|
16
16
|
|
|
17
17
|
export interface RasterSpec {
|
|
@@ -125,23 +125,32 @@ export function rasterPixels({
|
|
|
125
125
|
return out
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// Getting a context object back is not the same as getting one that can do
|
|
129
|
+
// this: the headless render shim hands back a context carrying only measureText,
|
|
130
|
+
// which a truthiness test accepts and putImageData then does not survive. Name
|
|
131
|
+
// the methods the raster actually calls, so every caller degrades together.
|
|
132
|
+
function usableRasterCtx(ctx: unknown): ctx is RasterCtx {
|
|
133
|
+
const c = ctx as Record<string, unknown> | null
|
|
134
|
+
return (
|
|
135
|
+
!!c &&
|
|
136
|
+
typeof c.createImageData === 'function' &&
|
|
137
|
+
typeof c.putImageData === 'function' &&
|
|
138
|
+
typeof c.getImageData === 'function' &&
|
|
139
|
+
typeof c.clearRect === 'function' &&
|
|
140
|
+
typeof c.fillRect === 'function'
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
128
144
|
function makeRasterCanvas(width: number, height: number) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
const canvas = document.createElement('canvas')
|
|
140
|
-
canvas.width = width
|
|
141
|
-
canvas.height = height
|
|
142
|
-
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
143
|
-
return ctx
|
|
144
|
-
? { canvas: canvas as RasterCanvas, ctx: ctx as RasterCtx }
|
|
145
|
+
const canvas =
|
|
146
|
+
typeof OffscreenCanvas !== 'undefined'
|
|
147
|
+
? new OffscreenCanvas(width, height)
|
|
148
|
+
: typeof document !== 'undefined'
|
|
149
|
+
? Object.assign(document.createElement('canvas'), { width, height })
|
|
150
|
+
: undefined
|
|
151
|
+
const ctx = canvas?.getContext('2d', { willReadFrequently: true })
|
|
152
|
+
return usableRasterCtx(ctx)
|
|
153
|
+
? { canvas: canvas as RasterCanvas, ctx }
|
|
145
154
|
: undefined
|
|
146
155
|
}
|
|
147
156
|
|
|
@@ -308,6 +317,104 @@ export function drawMsaRaster({
|
|
|
308
317
|
ctx.imageSmoothingEnabled = true
|
|
309
318
|
}
|
|
310
319
|
|
|
320
|
+
/**
|
|
321
|
+
* A raster canvas as a PNG data URI, for embedding in the SVG export.
|
|
322
|
+
*
|
|
323
|
+
* toDataURL is the synchronous read-back and only the DOM canvas has it, so an
|
|
324
|
+
* OffscreenCanvas -- which is what the tile and thumbnail caches hold wherever
|
|
325
|
+
* it exists -- gets copied onto one first. Returns undefined wherever the
|
|
326
|
+
* read-back is unavailable (jsdom) rather than throwing, which is the signal to
|
|
327
|
+
* fall back to drawing the thing in vector.
|
|
328
|
+
*/
|
|
329
|
+
export function canvasHref(canvas: RasterCanvas | undefined) {
|
|
330
|
+
if (!canvas || typeof document === 'undefined') {
|
|
331
|
+
return undefined
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
if (canvas instanceof HTMLCanvasElement) {
|
|
335
|
+
return canvas.toDataURL('image/png')
|
|
336
|
+
}
|
|
337
|
+
const out = document.createElement('canvas')
|
|
338
|
+
out.width = canvas.width
|
|
339
|
+
out.height = canvas.height
|
|
340
|
+
const ctx = out.getContext('2d')
|
|
341
|
+
if (typeof ctx?.drawImage !== 'function') {
|
|
342
|
+
return undefined
|
|
343
|
+
}
|
|
344
|
+
ctx.drawImage(canvas as CanvasImageSource, 0, 0)
|
|
345
|
+
return out.toDataURL('image/png')
|
|
346
|
+
} catch {
|
|
347
|
+
return undefined
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// A canvas much past this many pixels fails to allocate, and the browser's own
|
|
352
|
+
// per-side limit is lower still, so a raster bigger than either samples down.
|
|
353
|
+
// The result is still drawn across the same rectangle -- it loses cell-exact
|
|
354
|
+
// detail at a size where no figure could show it anyway.
|
|
355
|
+
const maxImagePixels = 64e6
|
|
356
|
+
const maxImageSide = 16384
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The alignment rectangle [col0, col0+numCols) x [row0, row0+numRows) as a PNG
|
|
360
|
+
* data URI, for the SVG export.
|
|
361
|
+
*
|
|
362
|
+
* SVG has no blit, so the export cannot reuse the tile cache the live canvas
|
|
363
|
+
* draws from: one <image> is the whole background instead. That is the
|
|
364
|
+
* difference between an export that scales and one that does not -- the vector
|
|
365
|
+
* path emits a <rect> per cell, and a 200x500 alignment exhausts the heap
|
|
366
|
+
* building them.
|
|
367
|
+
*
|
|
368
|
+
* Returns undefined wherever a canvas cannot be read back (jsdom, notably),
|
|
369
|
+
* which is the signal to keep the per-cell path.
|
|
370
|
+
*/
|
|
371
|
+
export function rasterImageHref({
|
|
372
|
+
model,
|
|
373
|
+
theme,
|
|
374
|
+
col0,
|
|
375
|
+
row0,
|
|
376
|
+
numCols,
|
|
377
|
+
numRows,
|
|
378
|
+
}: {
|
|
379
|
+
model: MsaViewModel
|
|
380
|
+
theme: Theme
|
|
381
|
+
col0: number
|
|
382
|
+
row0: number
|
|
383
|
+
numCols: number
|
|
384
|
+
numRows: number
|
|
385
|
+
}) {
|
|
386
|
+
if (numCols <= 0 || numRows <= 0 || typeof document === 'undefined') {
|
|
387
|
+
return undefined
|
|
388
|
+
}
|
|
389
|
+
const cache = getCache(model, theme)
|
|
390
|
+
let step = 1
|
|
391
|
+
while (
|
|
392
|
+
Math.ceil(numCols / step) * Math.ceil(numRows / step) > maxImagePixels ||
|
|
393
|
+
Math.ceil(numCols / step) > maxImageSide ||
|
|
394
|
+
Math.ceil(numRows / step) > maxImageSide
|
|
395
|
+
) {
|
|
396
|
+
step *= 2
|
|
397
|
+
}
|
|
398
|
+
const width = Math.ceil(numCols / step)
|
|
399
|
+
const height = Math.ceil(numRows / step)
|
|
400
|
+
|
|
401
|
+
return canvasHref(
|
|
402
|
+
paint(
|
|
403
|
+
rasterPixels({
|
|
404
|
+
spec: cache.spec,
|
|
405
|
+
col0,
|
|
406
|
+
row0,
|
|
407
|
+
width,
|
|
408
|
+
height,
|
|
409
|
+
colStep: step,
|
|
410
|
+
rowStep: step,
|
|
411
|
+
}),
|
|
412
|
+
width,
|
|
413
|
+
height,
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
311
418
|
/**
|
|
312
419
|
* The whole alignment sampled down to a strip small enough to sit behind the
|
|
313
420
|
* minimap thumb, cached alongside the block tiles because it goes stale under
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* DOM shims for driving `renderToSvg` outside a browser -- the SVG-export
|
|
3
|
+
* tests, the README figure generator, and react-msaview-cli all need them.
|
|
4
4
|
*
|
|
5
5
|
* jsdom implements neither DOMMatrix nor a canvas 2D context, and
|
|
6
6
|
* `@jbrowse/svgcanvas` needs both: it tracks the current transform as a real
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* leaves the rest undefined -- every emitted transform then carries `NaN` in its
|
|
13
13
|
* x components, which no assertion on a drawn x-position can survive.
|
|
14
14
|
*/
|
|
15
|
-
export class
|
|
15
|
+
export class HeadlessDOMMatrix {
|
|
16
16
|
a: number
|
|
17
17
|
b: number
|
|
18
18
|
c: number
|
|
@@ -30,8 +30,8 @@ export class TestDOMMatrix {
|
|
|
30
30
|
this.f = f
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
multiply(o:
|
|
34
|
-
return new
|
|
33
|
+
multiply(o: HeadlessDOMMatrix) {
|
|
34
|
+
return new HeadlessDOMMatrix([
|
|
35
35
|
this.a * o.a + this.c * o.b,
|
|
36
36
|
this.b * o.a + this.d * o.b,
|
|
37
37
|
this.a * o.c + this.c * o.d,
|
|
@@ -42,22 +42,22 @@ export class TestDOMMatrix {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
translate(x: number, y = 0) {
|
|
45
|
-
return this.multiply(new
|
|
45
|
+
return this.multiply(new HeadlessDOMMatrix([1, 0, 0, 1, x, y]))
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
scale(x: number, y = x) {
|
|
49
|
-
return this.multiply(new
|
|
49
|
+
return this.multiply(new HeadlessDOMMatrix([x, 0, 0, y, 0, 0]))
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export class
|
|
53
|
+
export class HeadlessDOMPoint {
|
|
54
54
|
constructor(
|
|
55
55
|
public x = 0,
|
|
56
56
|
public y = 0,
|
|
57
57
|
) {}
|
|
58
58
|
|
|
59
|
-
matrixTransform(m:
|
|
60
|
-
return new
|
|
59
|
+
matrixTransform(m: HeadlessDOMMatrix) {
|
|
60
|
+
return new HeadlessDOMPoint(
|
|
61
61
|
m.a * this.x + m.c * this.y + m.e,
|
|
62
62
|
m.b * this.x + m.d * this.y + m.f,
|
|
63
63
|
)
|
|
@@ -65,18 +65,22 @@ export class TestDOMPoint {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* Install the shims
|
|
68
|
+
* Install the shims, into `globalThis` by default. A Node caller that builds
|
|
69
|
+
* its own jsdom passes that window instead, because Node has no global
|
|
70
|
+
* HTMLCanvasElement to patch.
|
|
69
71
|
*
|
|
70
72
|
* `measureText` reports 0.6em per character, close enough to a real sans-serif
|
|
71
|
-
* average for layout
|
|
72
|
-
*
|
|
73
|
+
* average for layout and, more usefully, exactly predictable: a caller can
|
|
74
|
+
* compute the width a renderer will see rather than hardcoding a number
|
|
73
75
|
* measured from one machine's fonts.
|
|
74
76
|
*/
|
|
75
|
-
export function
|
|
77
|
+
export function installHeadlessRenderEnv(win: unknown = globalThis) {
|
|
76
78
|
const g = globalThis as Record<string, unknown>
|
|
77
|
-
g.DOMMatrix =
|
|
78
|
-
g.DOMPoint =
|
|
79
|
-
|
|
79
|
+
g.DOMMatrix = HeadlessDOMMatrix
|
|
80
|
+
g.DOMPoint = HeadlessDOMPoint
|
|
81
|
+
const canvas = (win as { HTMLCanvasElement: typeof HTMLCanvasElement })
|
|
82
|
+
.HTMLCanvasElement
|
|
83
|
+
canvas.prototype.getContext = function () {
|
|
80
84
|
let font = '10px sans-serif'
|
|
81
85
|
return {
|
|
82
86
|
get font() {
|
|
@@ -86,11 +90,11 @@ export function installRenderTestEnv() {
|
|
|
86
90
|
font = v
|
|
87
91
|
},
|
|
88
92
|
measureText: (t: string) => ({
|
|
89
|
-
width: t.length * (Number.parseFloat(font) || 10) *
|
|
93
|
+
width: t.length * (Number.parseFloat(font) || 10) * CHAR_WIDTH_RATIO,
|
|
90
94
|
}),
|
|
91
95
|
} as unknown as CanvasRenderingContext2D
|
|
92
96
|
} as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
/** the per-character width factor `measureText` above reports */
|
|
96
|
-
export const
|
|
100
|
+
export const CHAR_WIDTH_RATIO = 0.6
|
package/src/impgRender.test.tsx
CHANGED
|
@@ -11,13 +11,13 @@ import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
|
|
|
11
11
|
import { enableStaticRendering } from 'mobx-react'
|
|
12
12
|
import { beforeAll, expect, test } from 'vitest'
|
|
13
13
|
|
|
14
|
+
import { installHeadlessRenderEnv } from './headlessRenderEnv.ts'
|
|
14
15
|
import MSAModelF from './model.ts'
|
|
15
|
-
import { installRenderTestEnv } from './renderTestEnv.ts'
|
|
16
16
|
import { renderToSvg } from './renderToSvg.tsx'
|
|
17
17
|
|
|
18
18
|
beforeAll(() => {
|
|
19
19
|
enableStaticRendering(true)
|
|
20
|
-
|
|
20
|
+
installHeadlessRenderEnv()
|
|
21
21
|
})
|
|
22
22
|
|
|
23
23
|
// representative `impg query -o fasta-aln` block: PanSN names with region
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
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
|
+
// renderToSvg outside a browser needs DOM bits jsdom omits; react-msaview-cli
|
|
10
|
+
// and the README figure generator both drive it that way
|
|
11
|
+
export {
|
|
12
|
+
CHAR_WIDTH_RATIO,
|
|
13
|
+
installHeadlessRenderEnv,
|
|
14
|
+
} from './headlessRenderEnv.ts'
|
|
9
15
|
export { default as MSAView } from './components/Loading.tsx'
|
|
10
16
|
export { default as MSAViewer } from './components/MSAViewer.tsx'
|
|
11
17
|
export { type MsaViewModel, default as MSAModelF } from './model.ts'
|
package/src/model.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { clamp, groupBy, notEmpty, sum } from '@jbrowse/core/util'
|
|
2
2
|
import { openLocation } from '@jbrowse/core/util/io'
|
|
3
3
|
import { ElementId, FileLocation } from '@jbrowse/core/util/types/mst'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
addDisposer,
|
|
6
|
+
applySnapshot,
|
|
7
|
+
cast,
|
|
8
|
+
getSnapshot,
|
|
9
|
+
types,
|
|
10
|
+
} from '@jbrowse/mobx-state-tree'
|
|
5
11
|
import { colord } from 'colord'
|
|
6
12
|
import { autorun, transaction } from 'mobx'
|
|
7
13
|
import {
|
|
@@ -107,6 +113,46 @@ function parseTreeText(text: string) {
|
|
|
107
113
|
// asked for.
|
|
108
114
|
const defaultOffTracks = new Set(['sequence-logo'])
|
|
109
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The snapshot properties reset() carries across a return to the import form:
|
|
118
|
+
* display preferences and layout, nothing derived from the loaded file.
|
|
119
|
+
*
|
|
120
|
+
* reset() applies a default snapshot filtered to this list, so the list is the
|
|
121
|
+
* whole decision: a property left off it resets to its default, a visible and
|
|
122
|
+
* benign failure. The previous shape — a hand-maintained list of things to
|
|
123
|
+
* CLEAR — failed in the dangerous direction: a forgotten property silently
|
|
124
|
+
* carried the previous file's state into the next one, and because node ids
|
|
125
|
+
* are path-derived (node-0-0-1), a stale `collapsed` or `showOnly` id matched
|
|
126
|
+
* a real node in the new tree and folded it. Downstream composed properties
|
|
127
|
+
* (e.g. the jbrowse plugin's) are not on the list, so they reset too.
|
|
128
|
+
*
|
|
129
|
+
* Exported for modelReset.test.ts, which checks that everything off this list
|
|
130
|
+
* matches a freshly created model after reset().
|
|
131
|
+
*/
|
|
132
|
+
export const preservedOnReset = new Set([
|
|
133
|
+
'id',
|
|
134
|
+
'type',
|
|
135
|
+
'height',
|
|
136
|
+
'drawMsaLetters',
|
|
137
|
+
'scrollZoom',
|
|
138
|
+
'bgColor',
|
|
139
|
+
'colorSchemeName',
|
|
140
|
+
'showColumnStats',
|
|
141
|
+
'drawLabels',
|
|
142
|
+
'labelsAlignRight',
|
|
143
|
+
'treeAreaWidth',
|
|
144
|
+
'treeWidth',
|
|
145
|
+
'showBranchLen',
|
|
146
|
+
'drawTree',
|
|
147
|
+
'drawNodeBubbles',
|
|
148
|
+
'autoTreeAreaWidth',
|
|
149
|
+
'turnedOffTracks',
|
|
150
|
+
'hideGaps',
|
|
151
|
+
'allowedGappyness',
|
|
152
|
+
'subFeatureRows',
|
|
153
|
+
'showDomainLegend',
|
|
154
|
+
])
|
|
155
|
+
|
|
110
156
|
// `turnedOffTracks` records the user's explicit choices only: an id is absent
|
|
111
157
|
// until they touch that track, and then its value is whether the track is OFF.
|
|
112
158
|
// Reading the default through this is what lets a track ship hidden without
|
|
@@ -622,6 +668,13 @@ function stateModelFactory() {
|
|
|
622
668
|
self.gffFilehandle = gffFilehandle
|
|
623
669
|
},
|
|
624
670
|
|
|
671
|
+
/**
|
|
672
|
+
* #action
|
|
673
|
+
*/
|
|
674
|
+
setTreeMetadataFilehandle(treeMetadataFilehandle?: FileLocationType) {
|
|
675
|
+
self.treeMetadataFilehandle = treeMetadataFilehandle
|
|
676
|
+
},
|
|
677
|
+
|
|
625
678
|
/**
|
|
626
679
|
* #action
|
|
627
680
|
*/
|
|
@@ -2058,21 +2111,26 @@ function stateModelFactory() {
|
|
|
2058
2111
|
},
|
|
2059
2112
|
/**
|
|
2060
2113
|
* #action
|
|
2114
|
+
* Return to the import form: every property off `preservedOnReset`
|
|
2115
|
+
* (data, filehandles, collapsed/showOnly, zoom, scroll, ...) goes back
|
|
2116
|
+
* to its default, then the file-derived volatiles applySnapshot cannot
|
|
2117
|
+
* reach are cleared by hand.
|
|
2061
2118
|
*/
|
|
2062
2119
|
reset() {
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2120
|
+
applySnapshot(
|
|
2121
|
+
self,
|
|
2122
|
+
Object.fromEntries(
|
|
2123
|
+
Object.entries(getSnapshot(self) as Record<string, unknown>).filter(
|
|
2124
|
+
([key]) => preservedOnReset.has(key),
|
|
2125
|
+
),
|
|
2126
|
+
),
|
|
2127
|
+
)
|
|
2068
2128
|
self.setError(undefined)
|
|
2069
|
-
self.setScrollY(0)
|
|
2070
|
-
self.setScrollX(0)
|
|
2071
|
-
self.setCurrentAlignment(0)
|
|
2072
|
-
self.setTreeFilehandle(undefined)
|
|
2073
|
-
self.setMSAFilehandle(undefined)
|
|
2074
|
-
self.setGFFFilehandle(undefined)
|
|
2075
2129
|
self.setAnnotations([])
|
|
2130
|
+
self.setHighlightedColumns(undefined)
|
|
2131
|
+
self.setMousePos(undefined, undefined)
|
|
2132
|
+
self.setMouseClickPos(undefined, undefined)
|
|
2133
|
+
self.setHoveredTreeNode(undefined)
|
|
2076
2134
|
},
|
|
2077
2135
|
/**
|
|
2078
2136
|
* #action
|
|
@@ -2205,10 +2263,16 @@ function stateModelFactory() {
|
|
|
2205
2263
|
self,
|
|
2206
2264
|
autorun(async () => {
|
|
2207
2265
|
const filehandle = getFilehandle()
|
|
2266
|
+
// a cleared filehandle bumps the generation too, so a reset()
|
|
2267
|
+
// mid-download invalidates the in-flight fetch instead of letting
|
|
2268
|
+
// its onLoad land on the emptied model. That also orphans the
|
|
2269
|
+
// invalidated run's `finally`, so the loading flag is cleared
|
|
2270
|
+
// here on its behalf
|
|
2271
|
+
const current = ++generation
|
|
2208
2272
|
if (!filehandle) {
|
|
2273
|
+
setLoading?.(false)
|
|
2209
2274
|
return
|
|
2210
2275
|
}
|
|
2211
|
-
const current = ++generation
|
|
2212
2276
|
const isCurrent = () => current === generation
|
|
2213
2277
|
try {
|
|
2214
2278
|
setLoading?.(true)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// reset() returns the view to the import form, and the next file loads into
|
|
2
|
+
// the same model instance. It applies a default snapshot filtered to
|
|
3
|
+
// preservedOnReset, so anything file-derived that survived would be a hole in
|
|
4
|
+
// that list — the last test enumerates the snapshot keys to keep the list and
|
|
5
|
+
// the model's properties from drifting apart as properties are added. The
|
|
6
|
+
// stakes: node ids are path-derived (node-0-0-1), so a stale collapsed or
|
|
7
|
+
// showOnly id names a real node in the next tree and folds it.
|
|
8
|
+
import { getSnapshot } from '@jbrowse/mobx-state-tree'
|
|
9
|
+
import { describe, expect, test } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import MSAModelF, { preservedOnReset } from './model.ts'
|
|
12
|
+
|
|
13
|
+
const firstFile = {
|
|
14
|
+
msa: '>a\nACGT\n>b\nACGT\n>c\nACGT\n>d\nACGT',
|
|
15
|
+
tree: '((a,b),(c,d));',
|
|
16
|
+
}
|
|
17
|
+
const secondFile = {
|
|
18
|
+
msa: '>e\nTTTT\n>f\nTTTT\n>g\nTTTT\n>h\nTTTT',
|
|
19
|
+
tree: '((e,f),(g,h));',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeModel() {
|
|
23
|
+
const model = MSAModelF().create({ type: 'MsaView', data: firstFile })
|
|
24
|
+
model.setWidth(800)
|
|
25
|
+
return model
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function internalNodeId(model: ReturnType<typeof makeModel>) {
|
|
29
|
+
return model.leaves[0]!.parent!.data.id
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('reset', () => {
|
|
33
|
+
test('a collapsed clade does not fold the next loaded tree', () => {
|
|
34
|
+
const model = makeModel()
|
|
35
|
+
model.toggleCollapsed(internalNodeId(model))
|
|
36
|
+
expect(model.leaves.length).toBe(3)
|
|
37
|
+
|
|
38
|
+
model.reset()
|
|
39
|
+
model.setData(secondFile)
|
|
40
|
+
expect(model.collapsed.length).toBe(0)
|
|
41
|
+
expect(model.leaves.map(l => l.data.name).toSorted()).toEqual([
|
|
42
|
+
'e',
|
|
43
|
+
'f',
|
|
44
|
+
'g',
|
|
45
|
+
'h',
|
|
46
|
+
])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('showOnly does not restrict the next loaded tree', () => {
|
|
50
|
+
const model = makeModel()
|
|
51
|
+
model.setShowOnly(internalNodeId(model))
|
|
52
|
+
expect(model.leaves.length).toBe(2)
|
|
53
|
+
|
|
54
|
+
model.reset()
|
|
55
|
+
model.setData(secondFile)
|
|
56
|
+
expect(model.showOnly).toBe(undefined)
|
|
57
|
+
expect(model.leaves.length).toBe(4)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('clears file-derived volatiles', () => {
|
|
61
|
+
const model = makeModel()
|
|
62
|
+
model.setMousePos(2, 1)
|
|
63
|
+
model.setMouseClickPos(3, 0)
|
|
64
|
+
model.setHighlightedColumns([1, 2])
|
|
65
|
+
model.setHoveredTreeNode(internalNodeId(model))
|
|
66
|
+
|
|
67
|
+
model.reset()
|
|
68
|
+
expect(model.mouseCol).toBe(undefined)
|
|
69
|
+
expect(model.mouseClickCol).toBe(undefined)
|
|
70
|
+
expect(model.highlightedColumns).toBe(undefined)
|
|
71
|
+
expect(model.hoveredTreeNode).toBe(undefined)
|
|
72
|
+
expect(model.error).toBe(undefined)
|
|
73
|
+
expect(model.annotations).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('display preferences survive', () => {
|
|
77
|
+
const model = makeModel()
|
|
78
|
+
model.setColorSchemeName('clustalx')
|
|
79
|
+
model.setDrawNodeBubbles(false)
|
|
80
|
+
model.toggleTrack('conservation')
|
|
81
|
+
|
|
82
|
+
model.reset()
|
|
83
|
+
expect(model.colorSchemeName).toBe('clustalx')
|
|
84
|
+
expect(model.drawNodeBubbles).toBe(false)
|
|
85
|
+
expect(model.turnedOffTracks.get('conservation')).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('every property off preservedOnReset matches a fresh model', () => {
|
|
89
|
+
const model = makeModel()
|
|
90
|
+
model.toggleCollapsed(internalNodeId(model))
|
|
91
|
+
model.setShowOnly(internalNodeId(model))
|
|
92
|
+
model.drawRelativeTo('a')
|
|
93
|
+
model.setFilter('PF00001', true)
|
|
94
|
+
model.setScrollX(-100)
|
|
95
|
+
model.setScrollY(-50)
|
|
96
|
+
model.zoomIn()
|
|
97
|
+
model.setCurrentAlignment(1)
|
|
98
|
+
model.setMSAFilehandle({
|
|
99
|
+
uri: 'http://x/msa.fa',
|
|
100
|
+
locationType: 'UriLocation',
|
|
101
|
+
})
|
|
102
|
+
model.setTreeFilehandle({
|
|
103
|
+
uri: 'http://x/t.nh',
|
|
104
|
+
locationType: 'UriLocation',
|
|
105
|
+
})
|
|
106
|
+
model.setTreeMetadataFilehandle({
|
|
107
|
+
uri: 'http://x/m.json',
|
|
108
|
+
locationType: 'UriLocation',
|
|
109
|
+
})
|
|
110
|
+
model.setMSAFormat('fasta')
|
|
111
|
+
|
|
112
|
+
model.reset()
|
|
113
|
+
const fresh = MSAModelF().create({ type: 'MsaView' })
|
|
114
|
+
const strip = (snap: object) =>
|
|
115
|
+
Object.fromEntries(
|
|
116
|
+
Object.entries(snap).filter(([key]) => !preservedOnReset.has(key)),
|
|
117
|
+
)
|
|
118
|
+
expect(strip(getSnapshot(model))).toEqual(strip(getSnapshot(fresh)))
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -8,13 +8,13 @@ import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
|
|
|
8
8
|
import { enableStaticRendering } from 'mobx-react'
|
|
9
9
|
import { beforeAll, expect, test } from 'vitest'
|
|
10
10
|
|
|
11
|
+
import { installHeadlessRenderEnv } from './headlessRenderEnv.ts'
|
|
11
12
|
import MSAModelF from './model.ts'
|
|
12
|
-
import { installRenderTestEnv } from './renderTestEnv.ts'
|
|
13
13
|
import { renderToSvg } from './renderToSvg.tsx'
|
|
14
14
|
|
|
15
15
|
beforeAll(() => {
|
|
16
16
|
enableStaticRendering(true)
|
|
17
|
-
|
|
17
|
+
installHeadlessRenderEnv()
|
|
18
18
|
})
|
|
19
19
|
|
|
20
20
|
const msa = `>seq1
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
//
|
|
3
|
-
// The viewport export draws a minimap by default, and nothing covered it.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The viewport export draws a minimap by default, and nothing covered it. It
|
|
4
|
+
// spans the same alignment canvas the live minimap does, so its trapezoid has
|
|
5
|
+
// to reach the right edge of the exported alignment column -- and it is drawn
|
|
6
|
+
// only when the live view shows one at all.
|
|
7
7
|
import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
|
|
8
8
|
import { enableStaticRendering } from 'mobx-react'
|
|
9
9
|
import { beforeAll, expect, test } from 'vitest'
|
|
10
10
|
|
|
11
|
+
import { installHeadlessRenderEnv } from './headlessRenderEnv.ts'
|
|
11
12
|
import MSAModelF from './model.ts'
|
|
12
|
-
import { installRenderTestEnv } from './renderTestEnv.ts'
|
|
13
13
|
import { renderToSvg } from './renderToSvg.tsx'
|
|
14
14
|
|
|
15
15
|
beforeAll(() => {
|
|
16
16
|
enableStaticRendering(true)
|
|
17
|
-
|
|
17
|
+
installHeadlessRenderEnv()
|
|
18
18
|
})
|
|
19
19
|
|
|
20
20
|
const width = 1000
|
|
21
21
|
|
|
22
|
-
function makeModel() {
|
|
23
|
-
const row = 'ACDEFGHIKL'.repeat(
|
|
22
|
+
function makeModel(cols = 400) {
|
|
23
|
+
const row = 'ACDEFGHIKL'.repeat(cols / 10)
|
|
24
24
|
const model = MSAModelF().create({
|
|
25
25
|
type: 'MsaView',
|
|
26
26
|
msaFormat: 'fasta',
|
|
@@ -31,19 +31,24 @@ function makeModel() {
|
|
|
31
31
|
return model
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
expect(model.showHorizontalScrollbar).toBe(true)
|
|
37
|
-
|
|
38
|
-
const svg = await renderToSvg(model, {
|
|
34
|
+
function exportViewport(model: ReturnType<typeof makeModel>) {
|
|
35
|
+
return renderToSvg(model, {
|
|
39
36
|
theme: createJBrowseTheme(),
|
|
40
37
|
exportType: 'viewport',
|
|
41
38
|
includeMinimap: true,
|
|
42
39
|
includeTracks: false,
|
|
43
40
|
})
|
|
41
|
+
}
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
const
|
|
43
|
+
test('the exported minimap spans the exported alignment column', async () => {
|
|
44
|
+
const model = makeModel()
|
|
45
|
+
expect(model.showHorizontalScrollbar).toBe(true)
|
|
46
|
+
|
|
47
|
+
const svg = await exportViewport(model)
|
|
48
|
+
|
|
49
|
+
// the minimap column is the alignment canvas, which is what the export lays
|
|
50
|
+
// out beside the tree
|
|
51
|
+
const minimapWidth = model.msaCanvasWidth
|
|
47
52
|
const polygon = /<polygon[^>]*points="([^"]*)"/.exec(svg)?.[1]
|
|
48
53
|
expect(polygon).toBeDefined()
|
|
49
54
|
// bottom edge of the trapezoid reaches the full width it was given
|
|
@@ -53,3 +58,12 @@ test('the exported minimap spans the exported alignment column', async () => {
|
|
|
53
58
|
// and the bar outline is drawn to the same width
|
|
54
59
|
expect(svg).toContain(`width="${minimapWidth}"`)
|
|
55
60
|
})
|
|
61
|
+
|
|
62
|
+
test('no minimap when the alignment already fits across', async () => {
|
|
63
|
+
const model = makeModel(10)
|
|
64
|
+
expect(model.showHorizontalScrollbar).toBe(false)
|
|
65
|
+
|
|
66
|
+
// a minimap here would mark a viewport wider than the bar it sits in, and the
|
|
67
|
+
// live view draws none either
|
|
68
|
+
expect(await exportViewport(model)).not.toContain('<polygon')
|
|
69
|
+
})
|