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.
- 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/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/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
|
@@ -1,46 +1,61 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import { scrollbarThumbFill } from '../DragHandle.tsx'
|
|
4
|
+
import { canvasHref, msaThumbnail } from '../msa/msaRaster.ts'
|
|
5
5
|
import { MINIMAP_BAR_HEIGHT, getMinimapLayout } from './minimapLayout.ts'
|
|
6
6
|
|
|
7
7
|
import type { MsaViewModel } from '../../model.ts'
|
|
8
|
+
import type { Theme } from '@mui/material'
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
// The static counterpart to Minimap, for the SVG export. renderToSvg drives it
|
|
11
|
+
// through renderToStaticMarkup, so it reads the model once and never re-renders
|
|
12
|
+
// -- no observer wrapper.
|
|
13
|
+
export default function MinimapSVG({
|
|
14
|
+
model,
|
|
15
|
+
theme,
|
|
16
|
+
}: {
|
|
17
|
+
model: MsaViewModel
|
|
18
|
+
theme: Theme
|
|
19
|
+
}) {
|
|
20
|
+
const { msaCanvasWidth } = model
|
|
21
|
+
const { s, w, polygonPoints } = getMinimapLayout(model)
|
|
22
|
+
// the same downsampled alignment the live minimap draws behind its thumb;
|
|
23
|
+
// undefined wherever a canvas cannot be read back, leaving a bare outline
|
|
24
|
+
const href = canvasHref(
|
|
25
|
+
msaThumbnail({ model, theme, height: MINIMAP_BAR_HEIGHT }),
|
|
26
|
+
)
|
|
14
27
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
{href ? (
|
|
31
|
+
<image
|
|
32
|
+
href={href}
|
|
18
33
|
x={0}
|
|
19
34
|
y={0}
|
|
20
|
-
width={
|
|
35
|
+
width={msaCanvasWidth}
|
|
21
36
|
height={MINIMAP_BAR_HEIGHT}
|
|
22
|
-
|
|
23
|
-
fill="none"
|
|
37
|
+
preserveAspectRatio="none"
|
|
24
38
|
/>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
) : null}
|
|
40
|
+
<rect
|
|
41
|
+
x={0}
|
|
42
|
+
y={0}
|
|
43
|
+
width={msaCanvasWidth}
|
|
44
|
+
height={MINIMAP_BAR_HEIGHT}
|
|
45
|
+
stroke="#555"
|
|
46
|
+
fill="none"
|
|
47
|
+
/>
|
|
48
|
+
<rect
|
|
49
|
+
x={Math.max(0, s)}
|
|
50
|
+
y={0}
|
|
51
|
+
width={w}
|
|
52
|
+
height={MINIMAP_BAR_HEIGHT}
|
|
53
|
+
fill={scrollbarThumbFill}
|
|
54
|
+
stroke="#555"
|
|
55
|
+
/>
|
|
56
|
+
<g transform={`translate(0 ${MINIMAP_BAR_HEIGHT})`}>
|
|
57
|
+
<polygon fill={scrollbarThumbFill} points={polygonPoints} />
|
|
58
|
+
</g>
|
|
59
|
+
</>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -23,7 +23,7 @@ test('the viewport rectangle scales by the canvas width, not the msa area', () =
|
|
|
23
23
|
expect(model.showVerticalScrollbar).toBe(true)
|
|
24
24
|
expect(model.msaCanvasWidth).toBe(model.msaAreaWidth - 20)
|
|
25
25
|
|
|
26
|
-
const { unit, s, w } = getMinimapLayout(model
|
|
26
|
+
const { unit, s, w } = getMinimapLayout(model)
|
|
27
27
|
expect(unit).toBeCloseTo(model.msaCanvasWidth / model.totalWidth)
|
|
28
28
|
// unscrolled, the rectangle starts at the left edge and covers the fraction
|
|
29
29
|
// of the alignment on screen. Sizing it by msaAreaWidth instead overstated
|
|
@@ -34,12 +34,12 @@ test('the viewport rectangle scales by the canvas width, not the msa area', () =
|
|
|
34
34
|
)
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
-
test('the rectangle tracks scrollX and the trapezoid spans the
|
|
37
|
+
test('the rectangle tracks scrollX and the trapezoid spans the bar', () => {
|
|
38
38
|
const model = makeModel()
|
|
39
39
|
model.setScrollX(-300)
|
|
40
40
|
|
|
41
|
-
const width =
|
|
42
|
-
const { s, polygonHeight, polygonPoints } = getMinimapLayout(model
|
|
41
|
+
const { msaCanvasWidth: width } = model
|
|
42
|
+
const { s, polygonHeight, polygonPoints } = getMinimapLayout(model)
|
|
43
43
|
expect(s).toBeCloseTo((300 / model.totalWidth) * width)
|
|
44
44
|
expect(polygonHeight).toBe(model.minimapHeight - MINIMAP_BAR_HEIGHT)
|
|
45
45
|
// bottom edge spans the full minimap, top edge is the viewport rectangle
|
|
@@ -48,11 +48,22 @@ test('the rectangle tracks scrollX and the trapezoid spans the given width', ()
|
|
|
48
48
|
).toBe(true)
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
+
test('an alignment that already fits never overflows the bar', () => {
|
|
52
|
+
const model = MSAModelF().create({
|
|
53
|
+
type: 'MsaView',
|
|
54
|
+
msaFormat: 'fasta',
|
|
55
|
+
data: { msa: '>a\nACGT\n>b\nACGT' },
|
|
56
|
+
})
|
|
57
|
+
model.setWidth(1000)
|
|
58
|
+
expect(model.totalWidth).toBeLessThan(model.msaCanvasWidth)
|
|
59
|
+
expect(getMinimapLayout(model).w).toBe(model.msaCanvasWidth)
|
|
60
|
+
})
|
|
61
|
+
|
|
51
62
|
test('an empty alignment collapses to a zero-width unit instead of dividing by zero', () => {
|
|
52
63
|
const model = MSAModelF().create({ type: 'MsaView' })
|
|
53
64
|
model.setWidth(1000)
|
|
54
65
|
expect(model.totalWidth).toBe(0)
|
|
55
|
-
const { unit, s, w } = getMinimapLayout(model
|
|
66
|
+
const { unit, s, w } = getMinimapLayout(model)
|
|
56
67
|
expect(unit).toBe(0)
|
|
57
68
|
expect(s).toBeCloseTo(0)
|
|
58
69
|
// still clamped to the minimum grabbable width
|
|
@@ -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'
|
|
@@ -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
|
+
})
|