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.
- 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/header/settingsMenuItems.js +1 -1
- package/dist/components/header/settingsMenuItems.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/components/tree/renderTreeCanvas.js +22 -19
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/{renderTestEnv.d.ts → headlessRenderEnv.d.ts} +15 -13
- package/dist/{renderTestEnv.js → headlessRenderEnv.js} +22 -18
- package/dist/headlessRenderEnv.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/model/treeModel.d.ts +1 -1
- package/dist/model/treeModel.js +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/header/settingsMenuItems.ts +1 -1
- 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/components/tree/renderTreeCanvas.test.ts +55 -0
- package/src/components/tree/renderTreeCanvas.ts +23 -18
- package/src/{renderTestEnv.ts → headlessRenderEnv.ts} +23 -19
- package/src/impgRender.test.tsx +2 -2
- package/src/index.ts +6 -0
- package/src/model/treeModel.ts +1 -1
- 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
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
|
104
|
-
{Math.round(entireHeight)} pixels
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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 {
|
|
15
|
-
|
|
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
|
|
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 {
|
|
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
|
|
@@ -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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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) {
|