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
@@ -1,46 +1,61 @@
1
1
  import React from 'react'
2
2
 
3
- import { observer } from 'mobx-react'
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
- const MinimapSVG = observer(
10
- ({ model, width }: { model: MsaViewModel; width: number }) => {
11
- const { s, w, polygonPoints } = getMinimapLayout(model, width)
12
- const fillColor = 'rgb(66, 119, 127)'
13
- const fillOpacity = 0.3
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
- return (
16
- <>
17
- <rect
28
+ return (
29
+ <>
30
+ {href ? (
31
+ <image
32
+ href={href}
18
33
  x={0}
19
34
  y={0}
20
- width={width}
35
+ width={msaCanvasWidth}
21
36
  height={MINIMAP_BAR_HEIGHT}
22
- stroke="#555"
23
- fill="none"
37
+ preserveAspectRatio="none"
24
38
  />
25
- <rect
26
- x={Math.max(0, s)}
27
- y={0}
28
- width={w}
29
- height={MINIMAP_BAR_HEIGHT}
30
- fill={fillColor}
31
- fillOpacity={fillOpacity}
32
- stroke="#555"
33
- />
34
- <g transform={`translate(0 ${MINIMAP_BAR_HEIGHT})`}>
35
- <polygon
36
- fill={fillColor}
37
- fillOpacity={fillOpacity}
38
- points={polygonPoints}
39
- />
40
- </g>
41
- </>
42
- )
43
- },
44
- )
45
-
46
- export default MinimapSVG
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, model.msaCanvasWidth)
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 given width', () => {
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 = 400
42
- const { s, polygonHeight, polygonPoints } = getMinimapLayout(model, width)
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, 400)
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 a minimap drawn `minimapWidth`
7
- * pixels wide, shared by the interactive Minimap and the static MinimapSVG.
8
- *
9
- * The width is a parameter rather than read off the model because the two
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, minimapWidth: number) {
11
+ export function getMinimapLayout(model: MsaViewModel) {
15
12
  const { scrollX, minimapHeight, totalWidth, msaCanvasWidth } = model
16
- const unit = totalWidth > 0 ? minimapWidth / totalWidth : 0
13
+ const unit = totalWidth > 0 ? msaCanvasWidth / totalWidth : 0
17
14
  const s = -scrollX * unit
18
- const w = Math.max(msaCanvasWidth * unit, 20)
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} ${minimapWidth},${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
- if (typeof OffscreenCanvas !== 'undefined') {
130
- const canvas = new OffscreenCanvas(width, height)
131
- const ctx = canvas.getContext('2d', { willReadFrequently: true })
132
- return ctx
133
- ? { canvas: canvas as RasterCanvas, ctx: ctx as RasterCtx }
134
- : undefined
135
- }
136
- if (typeof document === 'undefined') {
137
- return undefined
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
- * jsdom shims for the SVG-export tests. Test-only -- nothing in the viewer
3
- * imports this.
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 TestDOMMatrix {
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: TestDOMMatrix) {
34
- return new TestDOMMatrix([
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 TestDOMMatrix([1, 0, 0, 1, x, y]))
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 TestDOMMatrix([x, 0, 0, y, 0, 0]))
49
+ return this.multiply(new HeadlessDOMMatrix([x, 0, 0, y, 0, 0]))
50
50
  }
51
51
  }
52
52
 
53
- export class TestDOMPoint {
53
+ export class HeadlessDOMPoint {
54
54
  constructor(
55
55
  public x = 0,
56
56
  public y = 0,
57
57
  ) {}
58
58
 
59
- matrixTransform(m: TestDOMMatrix) {
60
- return new TestDOMPoint(
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. Call from `beforeAll` in any test that exports SVG.
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 assertions and, more usefully, exactly predictable: a test
72
- * can compute the width a renderer will see rather than hardcoding a number
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 installRenderTestEnv() {
77
+ export function installHeadlessRenderEnv(win: unknown = globalThis) {
76
78
  const g = globalThis as Record<string, unknown>
77
- g.DOMMatrix = TestDOMMatrix
78
- g.DOMPoint = TestDOMPoint
79
- HTMLCanvasElement.prototype.getContext = function () {
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) * 0.6,
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 TEST_CHAR_WIDTH_RATIO = 0.6
100
+ export const CHAR_WIDTH_RATIO = 0.6
@@ -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
- installRenderTestEnv()
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
- installRenderTestEnv()
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. The
4
- // export lays the figure out on its own grid rather than the on-screen one, so
5
- // the minimap has to be told how wide to draw -- reading a width off the model
6
- // puts its trapezoid short of the alignment column beside it.
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
- installRenderTestEnv()
17
+ installHeadlessRenderEnv()
18
18
  })
19
19
 
20
20
  const width = 1000
21
21
 
22
- function makeModel() {
23
- const row = 'ACDEFGHIKL'.repeat(40)
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
- test('the exported minimap spans the exported alignment column', async () => {
35
- const model = makeModel()
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
- // the minimap column runs from the tree area to the right edge of the figure
46
- const minimapWidth = width - model.treeAreaWidth
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
+ })