react-msaview 6.1.0 → 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 (57) 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/header/settingsMenuItems.js +1 -1
  9. package/dist/components/header/settingsMenuItems.js.map +1 -1
  10. package/dist/components/minimap/Minimap.js +1 -1
  11. package/dist/components/minimap/Minimap.js.map +1 -1
  12. package/dist/components/minimap/MinimapSVG.d.ts +4 -4
  13. package/dist/components/minimap/MinimapSVG.js +16 -10
  14. package/dist/components/minimap/MinimapSVG.js.map +1 -1
  15. package/dist/components/minimap/minimapLayout.d.ts +5 -8
  16. package/dist/components/minimap/minimapLayout.js +10 -11
  17. package/dist/components/minimap/minimapLayout.js.map +1 -1
  18. package/dist/components/msa/msaRaster.d.ts +32 -2
  19. package/dist/components/msa/msaRaster.js +93 -15
  20. package/dist/components/msa/msaRaster.js.map +1 -1
  21. package/dist/components/tree/renderTreeCanvas.js +22 -19
  22. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  23. package/dist/{renderTestEnv.d.ts → headlessRenderEnv.d.ts} +15 -13
  24. package/dist/{renderTestEnv.js → headlessRenderEnv.js} +22 -18
  25. package/dist/headlessRenderEnv.js.map +1 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +3 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/model/treeModel.d.ts +1 -1
  30. package/dist/model/treeModel.js +1 -1
  31. package/dist/renderToSvg.js +115 -36
  32. package/dist/renderToSvg.js.map +1 -1
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/package.json +3 -3
  36. package/src/components/dialogs/ExportSVGDialog.tsx +55 -20
  37. package/src/components/getVisibleLeaves.ts +25 -9
  38. package/src/components/header/settingsMenuItems.ts +1 -1
  39. package/src/components/minimap/Minimap.tsx +1 -4
  40. package/src/components/minimap/MinimapSVG.tsx +50 -35
  41. package/src/components/minimap/minimapLayout.test.ts +16 -5
  42. package/src/components/minimap/minimapLayout.ts +10 -11
  43. package/src/components/msa/msaRaster.ts +124 -17
  44. package/src/components/tree/renderTreeCanvas.test.ts +55 -0
  45. package/src/components/tree/renderTreeCanvas.ts +23 -18
  46. package/src/{renderTestEnv.ts → headlessRenderEnv.ts} +23 -19
  47. package/src/impgRender.test.tsx +2 -2
  48. package/src/index.ts +6 -0
  49. package/src/model/treeModel.ts +1 -1
  50. package/src/renderDomainsSvg.test.tsx +2 -2
  51. package/src/renderMinimapSvg.test.tsx +29 -15
  52. package/src/renderRasterSvg.test.tsx +216 -0
  53. package/src/renderSequenceLogoSvg.test.tsx +6 -4
  54. package/src/renderToSvg.test.tsx +79 -0
  55. package/src/renderToSvg.tsx +201 -65
  56. package/src/version.ts +1 -1
  57. package/dist/renderTestEnv.js.map +0 -1
@@ -4,6 +4,7 @@ import { Dialog, ErrorMessage } from '@jbrowse/core/ui'
4
4
  import {
5
5
  Alert,
6
6
  Button,
7
+ CircularProgress,
7
8
  DialogActions,
8
9
  DialogContent,
9
10
  FormControl,
@@ -19,6 +20,16 @@ import Checkbox2 from '../Checkbox2.tsx'
19
20
 
20
21
  import type { MsaViewModel } from '../../model.ts'
21
22
 
23
+ // Run after the browser has painted. A requestAnimationFrame callback still
24
+ // runs *before* the frame it was queued for, so one alone hands the main thread
25
+ // to the render with the spinner not yet on screen; the second fires once that
26
+ // frame is out.
27
+ function afterPaint(fn: () => void) {
28
+ requestAnimationFrame(() => {
29
+ requestAnimationFrame(fn)
30
+ })
31
+ }
32
+
22
33
  export default function ExportSVGDialog({
23
34
  model,
24
35
  onClose,
@@ -32,6 +43,7 @@ export default function ExportSVGDialog({
32
43
  'viewport',
33
44
  )
34
45
  const [error, setError] = useState<unknown>()
46
+ const [exporting, setExporting] = useState(false)
35
47
  const theme = useTheme()
36
48
  const {
37
49
  totalWidth,
@@ -39,12 +51,20 @@ export default function ExportSVGDialog({
39
51
  treeAreaWidth,
40
52
  turnedOnTracks,
41
53
  totalTrackAreaHeight,
54
+ numColumns,
55
+ leaves,
56
+ showMsaLetters,
42
57
  } = model
43
58
  const hasTracks = turnedOnTracks.length > 0
44
59
  const entireWidth = totalWidth + treeAreaWidth
45
60
  const entireHeight = totalHeight + (includeTracks ? totalTrackAreaHeight : 0)
46
- const isLargeExport =
47
- exportType === 'entire' && (entireWidth > 10000 || entireHeight > 10000)
61
+ // the background is one raster image whatever its size, so what the figure
62
+ // actually costs is its residue letters: each is an svg element of its own.
63
+ // Past roughly this many the export runs to tens of seconds and can exhaust
64
+ // the tab's memory outright
65
+ const glyphs =
66
+ showMsaLetters && exportType === 'entire' ? numColumns * leaves.length : 0
67
+ const isLargeExport = glyphs > 100_000
48
68
  return (
49
69
  <Dialog
50
70
  onClose={() => {
@@ -100,8 +120,11 @@ export default function ExportSVGDialog({
100
120
  </div>
101
121
  {isLargeExport ? (
102
122
  <Alert severity="warning" style={{ marginTop: 8 }}>
103
- The entire MSA is very large ({Math.round(entireWidth)}x
104
- {Math.round(entireHeight)} pixels). Export may be slow or fail.
123
+ The entire MSA is {Math.round(entireWidth)}x
124
+ {Math.round(entireHeight)} pixels and draws{' '}
125
+ {Math.round(glyphs / 1000)}k residue letters, each its own SVG
126
+ element. Export may be slow or fail — zooming out far enough to hide
127
+ the letters exports the same alignment as an image instead.
105
128
  </Alert>
106
129
  ) : null}
107
130
  </DialogContent>
@@ -109,25 +132,37 @@ export default function ExportSVGDialog({
109
132
  <Button
110
133
  variant="contained"
111
134
  color="primary"
135
+ // rendering runs on the main thread and a large figure holds it for
136
+ // seconds; without this the button stays live through the freeze and
137
+ // a second click starts a second export
138
+ disabled={exporting}
139
+ startIcon={
140
+ exporting ? <CircularProgress size={16} color="inherit" /> : null
141
+ }
112
142
  onClick={() => {
113
- void model
114
- .exportSVG({
115
- theme,
116
- includeMinimap:
117
- exportType === 'entire' ? false : includeMinimap,
118
- includeTracks: hasTracks && includeTracks,
119
- exportType,
120
- })
121
- .then(() => {
122
- onClose()
123
- })
124
- .catch((e: unknown) => {
125
- console.error(e)
126
- setError(e)
127
- })
143
+ setExporting(true)
144
+ afterPaint(() => {
145
+ void model
146
+ .exportSVG({
147
+ theme,
148
+ includeMinimap,
149
+ includeTracks: hasTracks && includeTracks,
150
+ exportType,
151
+ })
152
+ .then(() => {
153
+ onClose()
154
+ })
155
+ .catch((e: unknown) => {
156
+ console.error(e)
157
+ setError(e)
158
+ })
159
+ .finally(() => {
160
+ setExporting(false)
161
+ })
162
+ })
128
163
  }}
129
164
  >
130
- Submit
165
+ {exporting ? 'Exporting…' : 'Submit'}
131
166
  </Button>
132
167
  <Button
133
168
  variant="contained"
@@ -1,7 +1,28 @@
1
1
  import type { MsaViewModel } from '../model.ts'
2
2
 
3
- // The slice of leaves whose rows intersect a block of height blockSizeY at
4
- // vertical offset offsetY, padded by one row on each side.
3
+ // The half-open range of rows [yStart, yEnd) whose leaves intersect a block of
4
+ // height blockSizeY at vertical offset offsetY, padded by one row on each side.
5
+ // Y-axis counterpart to visibleColRange.
6
+ export function visibleRowRange({
7
+ model,
8
+ offsetY,
9
+ blockSizeY,
10
+ }: {
11
+ model: MsaViewModel
12
+ offsetY: number
13
+ blockSizeY: number
14
+ }) {
15
+ const { rowHeight } = model
16
+ return {
17
+ yStart: Math.max(0, Math.floor((offsetY - rowHeight) / rowHeight)),
18
+ yEnd: Math.max(
19
+ 0,
20
+ Math.ceil((offsetY + blockSizeY + rowHeight) / rowHeight),
21
+ ),
22
+ }
23
+ }
24
+
25
+ // The leaves in that range.
5
26
  export function getVisibleLeaves({
6
27
  model,
7
28
  offsetY,
@@ -11,11 +32,6 @@ export function getVisibleLeaves({
11
32
  offsetY: number
12
33
  blockSizeY: number
13
34
  }) {
14
- const { leaves, rowHeight } = model
15
- const yStart = Math.max(0, Math.floor((offsetY - rowHeight) / rowHeight))
16
- const yEnd = Math.max(
17
- 0,
18
- Math.ceil((offsetY + blockSizeY + rowHeight) / rowHeight),
19
- )
20
- return leaves.slice(yStart, yEnd)
35
+ const { yStart, yEnd } = visibleRowRange({ model, offsetY, blockSizeY })
36
+ return model.leaves.slice(yStart, yEnd)
21
37
  }
@@ -65,7 +65,7 @@ export function treeSettingsMenuItems(model: MsaViewModel): MenuItem[] {
65
65
  toggle('Show tree', drawTree, arg => {
66
66
  model.setDrawTree(arg)
67
67
  }),
68
- toggle('Draw clickable bubbles on tree branches', drawNodeBubbles, arg => {
68
+ toggle('Draw bubbles on tree branches', drawNodeBubbles, arg => {
69
69
  model.setDrawNodeBubbles(arg)
70
70
  }),
71
71
  toggle('Tree labels align right', labelsAlignRight, arg => {
@@ -13,10 +13,7 @@ import type { MsaViewModel } from '../../model.ts'
13
13
  const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
14
14
  const { minimapHeight, msaCanvasWidth, highResScaleFactor } = model
15
15
  const theme = useTheme()
16
- const { unit, s, w, polygonHeight, polygonPoints } = getMinimapLayout(
17
- model,
18
- msaCanvasWidth,
19
- )
16
+ const { unit, s, w, polygonHeight, polygonPoints } = getMinimapLayout(model)
20
17
 
21
18
  // the whole alignment, sampled down to the bar: it says where the conserved
22
19
  // blocks and the gappy stretches are, which is what a scroll aims at
@@ -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
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import { calcDepthToLeaf, findMaxBranchLen } from '../../hierarchy.ts'
4
4
  import stateModelFactory from '../../model.ts'
5
+ import { ClickMapIndex } from './clickMap.ts'
5
6
  import { getNodeX, renderTreeCanvas } from './renderTreeCanvas.ts'
6
7
 
7
8
  import type { HierarchyNode } from '../../hierarchy.ts'
@@ -174,3 +175,57 @@ describe('renderTreeCanvas horizontal extent', () => {
174
175
  expect(model.treeWidth).toBe(model.treeAreaWidth - 10 - model.marginLeft)
175
176
  })
176
177
  })
178
+
179
+ describe('node bubble click targets', () => {
180
+ const bounds = { minX: 0, minY: 0, maxX: 10000, maxY: 10000 }
181
+
182
+ // "draw clickable bubbles" only controls the painting; the branches have to
183
+ // stay clickable with it off
184
+ function renderWithBubbles(drawNodeBubbles: boolean) {
185
+ const model = stateModelFactory().create({
186
+ type: 'MsaView',
187
+ data: { msa: '>a\nA\n>b\nA\n>c\nA\n>d\nA', tree: '((a,b),(c,d));' },
188
+ })
189
+ model.setWidth(1000)
190
+ model.setDrawLabels(false)
191
+ model.setDrawNodeBubbles(drawNodeBubbles)
192
+
193
+ let arcs = 0
194
+ const ctx = {
195
+ font: '12px sans-serif',
196
+ beginPath() {},
197
+ stroke() {},
198
+ fill() {},
199
+ resetTransform() {},
200
+ scale() {},
201
+ translate() {},
202
+ moveTo() {},
203
+ lineTo() {},
204
+ arc() {
205
+ arcs++
206
+ },
207
+ } as unknown as RenderCtx
208
+
209
+ const clickMap = new ClickMapIndex()
210
+ renderTreeCanvas({
211
+ model,
212
+ ctx,
213
+ clickMap,
214
+ offsetY: 0,
215
+ theme: { palette: { text: { primary: '#000' } } } as Theme,
216
+ })
217
+ return { arcs, hits: clickMap.search(bounds) }
218
+ }
219
+
220
+ it('indexes the internal nodes when the bubbles are drawn', () => {
221
+ const { arcs, hits } = renderWithBubbles(true)
222
+ expect(arcs).toBe(3)
223
+ expect(hits.filter(h => h.branch).length).toBe(3)
224
+ })
225
+
226
+ it('indexes them just the same when the bubbles are off', () => {
227
+ const { arcs, hits } = renderWithBubbles(false)
228
+ expect(arcs).toBe(0)
229
+ expect(hits.filter(h => h.branch).length).toBe(3)
230
+ })
231
+ })
@@ -171,6 +171,8 @@ function renderCollapsedTriangles({
171
171
  })
172
172
  }
173
173
 
174
+ // the bubbles are click targets whether or not they are painted, so this pass
175
+ // always runs and `draw` only decides whether it also strokes the circles
174
176
  function renderNodeBubbles({
175
177
  ctx,
176
178
  clickMap,
@@ -180,6 +182,7 @@ function renderNodeBubbles({
180
182
  maxDepthToLeaf,
181
183
  blockSizeYOverride,
182
184
  collapsedSet,
185
+ draw,
183
186
  }: {
184
187
  ctx: RenderCtx
185
188
  clickMap?: ClickMapIndex
@@ -189,6 +192,7 @@ function renderNodeBubbles({
189
192
  maxDepthToLeaf: number
190
193
  blockSizeYOverride?: number
191
194
  collapsedSet: Set<string>
195
+ draw: boolean
192
196
  }) {
193
197
  const {
194
198
  hierarchy,
@@ -207,12 +211,14 @@ function renderNodeBubbles({
207
211
  const { id, name } = data
208
212
  if (node.height >= 1 && inYBlock(y, offsetY, by)) {
209
213
  const isCollapsed = collapsedSet.has(id)
210
- ctx.strokeStyle = 'black'
211
- ctx.fillStyle = isCollapsed ? 'black' : 'white'
212
- ctx.beginPath()
213
- ctx.arc(x, y, radius, 0, 2 * Math.PI)
214
- ctx.fill()
215
- ctx.stroke()
214
+ if (draw) {
215
+ ctx.strokeStyle = 'black'
216
+ ctx.fillStyle = isCollapsed ? 'black' : 'white'
217
+ ctx.beginPath()
218
+ ctx.arc(x, y, radius, 0, 2 * Math.PI)
219
+ ctx.fill()
220
+ ctx.stroke()
221
+ }
216
222
 
217
223
  // collapsed nodes get their click/hover target from the triangle pass,
218
224
  // which covers the apex bubble and carries a descriptive label
@@ -417,18 +423,17 @@ export function renderTreeCanvas({
417
423
  collapsedSet,
418
424
  })
419
425
 
420
- if (drawNodeBubbles) {
421
- renderNodeBubbles({
422
- ctx,
423
- offsetY,
424
- clickMap,
425
- model,
426
- tipX,
427
- maxDepthToLeaf,
428
- blockSizeYOverride,
429
- collapsedSet,
430
- })
431
- }
426
+ renderNodeBubbles({
427
+ ctx,
428
+ offsetY,
429
+ clickMap,
430
+ model,
431
+ tipX,
432
+ maxDepthToLeaf,
433
+ blockSizeYOverride,
434
+ collapsedSet,
435
+ draw: drawNodeBubbles,
436
+ })
432
437
  }
433
438
 
434
439
  if (showTreeText) {