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.
Files changed (52) 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/model.d.ts +25 -0
  26. package/dist/model.js +62 -13
  27. package/dist/model.js.map +1 -1
  28. package/dist/renderToSvg.js +115 -36
  29. package/dist/renderToSvg.js.map +1 -1
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/package.json +3 -3
  33. package/src/components/dialogs/ExportSVGDialog.tsx +55 -20
  34. package/src/components/getVisibleLeaves.ts +25 -9
  35. package/src/components/minimap/Minimap.tsx +1 -4
  36. package/src/components/minimap/MinimapSVG.tsx +50 -35
  37. package/src/components/minimap/minimapLayout.test.ts +16 -5
  38. package/src/components/minimap/minimapLayout.ts +10 -11
  39. package/src/components/msa/msaRaster.ts +124 -17
  40. package/src/{renderTestEnv.ts → headlessRenderEnv.ts} +23 -19
  41. package/src/impgRender.test.tsx +2 -2
  42. package/src/index.ts +6 -0
  43. package/src/model.ts +77 -13
  44. package/src/modelReset.test.ts +120 -0
  45. package/src/renderDomainsSvg.test.tsx +2 -2
  46. package/src/renderMinimapSvg.test.tsx +29 -15
  47. package/src/renderRasterSvg.test.tsx +216 -0
  48. package/src/renderSequenceLogoSvg.test.tsx +6 -4
  49. package/src/renderToSvg.test.tsx +79 -0
  50. package/src/renderToSvg.tsx +201 -65
  51. package/src/version.ts +1 -1
  52. 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 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'
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 { addDisposer, cast, types } from '@jbrowse/mobx-state-tree'
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
- self.setData({
2064
- tree: '',
2065
- msa: '',
2066
- })
2067
- self.resetZoom()
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
- 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
+ })