react-msaview 6.0.0 → 6.1.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.
- package/bundle/index.js +98 -98
- package/bundle/index.js.map +4 -4
- package/dist/columnCounts.d.ts +7 -0
- package/dist/columnCounts.js +18 -0
- package/dist/columnCounts.js.map +1 -1
- package/dist/components/DragHandle.d.ts +25 -0
- package/dist/components/DragHandle.js +49 -0
- package/dist/components/DragHandle.js.map +1 -0
- package/dist/components/ResizeHandles.d.ts +4 -2
- package/dist/components/ResizeHandles.js +29 -32
- package/dist/components/ResizeHandles.js.map +1 -1
- package/dist/components/VerticalScrollbar.js +2 -2
- package/dist/components/VerticalScrollbar.js.map +1 -1
- package/dist/components/dialogs/InterProScanDialog.js +52 -129
- package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
- package/dist/components/header/settingsMenuItems.js +16 -1
- package/dist/components/header/settingsMenuItems.js.map +1 -1
- package/dist/components/minimap/Minimap.js +32 -4
- package/dist/components/minimap/Minimap.js.map +1 -1
- package/dist/components/msa/MSACanvas.js +8 -2
- package/dist/components/msa/MSACanvas.js.map +1 -1
- package/dist/components/msa/MSACanvasBlock.js +12 -3
- package/dist/components/msa/MSACanvasBlock.js.map +1 -1
- package/dist/components/msa/msaRaster.d.ts +63 -0
- package/dist/components/msa/msaRaster.js +252 -0
- package/dist/components/msa/msaRaster.js.map +1 -0
- package/dist/components/msa/renderMSABlock.d.ts +2 -1
- package/dist/components/msa/renderMSABlock.js +13 -21
- package/dist/components/msa/renderMSABlock.js.map +1 -1
- package/dist/components/msa/tileColor.d.ts +6 -0
- package/dist/components/msa/tileColor.js +35 -0
- package/dist/components/msa/tileColor.js.map +1 -0
- package/dist/components/renderCtx.d.ts +1 -1
- package/dist/components/tracks/TrackBlocks.d.ts +14 -0
- package/dist/components/tracks/TrackBlocks.js +65 -0
- package/dist/components/tracks/TrackBlocks.js.map +1 -0
- package/dist/components/tracks/{renderTracksSvg.d.ts → drawTracks.d.ts} +22 -10
- package/dist/components/tracks/drawTracks.js +193 -0
- package/dist/components/tracks/drawTracks.js.map +1 -0
- package/dist/components/tree/TreeCanvas.js +8 -2
- package/dist/components/tree/TreeCanvas.js.map +1 -1
- package/dist/components/tree/TreeCanvasBlock.js +7 -4
- package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
- package/dist/components/tree/renderTreeCanvas.js +27 -21
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +8 -0
- package/dist/constants.js.map +1 -1
- package/dist/model/DataModel.d.ts +1 -4
- package/dist/model/DataModel.js +8 -10
- package/dist/model/DataModel.js.map +1 -1
- package/dist/model/treeModel.d.ts +1 -1
- package/dist/model/treeModel.js +1 -1
- package/dist/model.d.ts +36 -7
- package/dist/model.js +113 -58
- package/dist/model.js.map +1 -1
- package/dist/neighborJoining.js +34 -18
- package/dist/neighborJoining.js.map +1 -1
- package/dist/renderTestEnv.d.ts +43 -0
- package/dist/renderTestEnv.js +88 -0
- package/dist/renderTestEnv.js.map +1 -0
- package/dist/renderToSvg.js +1 -1
- package/dist/renderToSvg.js.map +1 -1
- package/dist/sequenceLogo.d.ts +28 -0
- package/dist/sequenceLogo.js +47 -0
- package/dist/sequenceLogo.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/columnCounts.test.ts +15 -0
- package/src/columnCounts.ts +19 -0
- package/src/components/DragHandle.tsx +81 -0
- package/src/components/ResizeHandles.tsx +46 -54
- package/src/components/VerticalScrollbar.tsx +3 -2
- package/src/components/dialogs/InterProScanDialog.tsx +54 -131
- package/src/components/header/settingsMenuItems.ts +19 -1
- package/src/components/minimap/Minimap.tsx +47 -4
- package/src/components/msa/MSACanvas.tsx +30 -10
- package/src/components/msa/MSACanvasBlock.tsx +13 -3
- package/src/components/msa/msaRaster.test.ts +145 -0
- package/src/components/msa/msaRaster.ts +350 -0
- package/src/components/msa/renderMSABlock.ts +15 -20
- package/src/components/msa/tileColor.ts +37 -0
- package/src/components/renderCtx.ts +2 -0
- package/src/components/tracks/TrackBlocks.tsx +111 -0
- package/src/components/tracks/drawTracks.ts +323 -0
- package/src/components/tree/TreeCanvas.tsx +16 -4
- package/src/components/tree/TreeCanvasBlock.tsx +18 -11
- package/src/components/tree/renderTreeCanvas.test.ts +55 -0
- package/src/components/tree/renderTreeCanvas.ts +28 -19
- package/src/constants.ts +10 -0
- package/src/impgRender.test.tsx +2 -57
- package/src/largeTree.test.ts +16 -2
- package/src/model/DataModel.ts +11 -11
- package/src/model/treeModel.ts +1 -1
- package/src/model.ts +128 -60
- package/src/neighborJoining.ts +36 -18
- package/src/renderDomainsSvg.test.tsx +2 -57
- package/src/renderMinimapSvg.test.tsx +2 -56
- package/src/renderSequenceLogoSvg.test.tsx +176 -0
- package/src/renderTestEnv.ts +96 -0
- package/src/renderToSvg.tsx +1 -1
- package/src/sequenceLogo.test.ts +88 -0
- package/src/sequenceLogo.ts +63 -0
- package/src/types.ts +7 -1
- package/src/version.ts +1 -1
- package/dist/components/ConservationTrack.d.ts +0 -8
- package/dist/components/ConservationTrack.js +0 -49
- package/dist/components/ConservationTrack.js.map +0 -1
- package/dist/components/ScrollbarThumb.d.ts +0 -14
- package/dist/components/ScrollbarThumb.js +0 -28
- package/dist/components/ScrollbarThumb.js.map +0 -1
- package/dist/components/TextTrack.d.ts +0 -8
- package/dist/components/TextTrack.js +0 -58
- package/dist/components/TextTrack.js.map +0 -1
- package/dist/components/tracks/renderTracksSvg.js +0 -126
- package/dist/components/tracks/renderTracksSvg.js.map +0 -1
- package/src/components/ConservationTrack.tsx +0 -99
- package/src/components/ScrollbarThumb.tsx +0 -48
- package/src/components/TextTrack.tsx +0 -103
- package/src/components/tracks/renderTracksSvg.ts +0 -247
|
@@ -1,19 +1,56 @@
|
|
|
1
1
|
import React, { useCallback } from 'react'
|
|
2
2
|
|
|
3
|
+
import { useTheme } from '@mui/material'
|
|
3
4
|
import { observer } from 'mobx-react'
|
|
4
5
|
|
|
5
|
-
import
|
|
6
|
+
import { useCanvasAutorun } from '../../useCanvasAutorun.ts'
|
|
7
|
+
import DragHandle, { scrollbarThumbFill } from '../DragHandle.tsx'
|
|
8
|
+
import { msaThumbnail } from '../msa/msaRaster.ts'
|
|
6
9
|
import { MINIMAP_BAR_HEIGHT, getMinimapLayout } from './minimapLayout.ts'
|
|
7
10
|
|
|
8
11
|
import type { MsaViewModel } from '../../model.ts'
|
|
9
12
|
|
|
10
13
|
const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
|
|
11
|
-
const { minimapHeight, msaCanvasWidth } = model
|
|
14
|
+
const { minimapHeight, msaCanvasWidth, highResScaleFactor } = model
|
|
15
|
+
const theme = useTheme()
|
|
12
16
|
const { unit, s, w, polygonHeight, polygonPoints } = getMinimapLayout(
|
|
13
17
|
model,
|
|
14
18
|
msaCanvasWidth,
|
|
15
19
|
)
|
|
16
20
|
|
|
21
|
+
// the whole alignment, sampled down to the bar: it says where the conserved
|
|
22
|
+
// blocks and the gappy stretches are, which is what a scroll aims at
|
|
23
|
+
const barWidth = Math.round(msaCanvasWidth * highResScaleFactor)
|
|
24
|
+
const barHeight = Math.round(MINIMAP_BAR_HEIGHT * highResScaleFactor)
|
|
25
|
+
const ref = useCanvasAutorun({
|
|
26
|
+
draw: ctx => {
|
|
27
|
+
ctx.resetTransform()
|
|
28
|
+
ctx.clearRect(0, 0, barWidth, barHeight)
|
|
29
|
+
const thumbnail = msaThumbnail({
|
|
30
|
+
model,
|
|
31
|
+
theme,
|
|
32
|
+
height: MINIMAP_BAR_HEIGHT,
|
|
33
|
+
})
|
|
34
|
+
if (thumbnail) {
|
|
35
|
+
ctx.imageSmoothingEnabled = true
|
|
36
|
+
ctx.drawImage(
|
|
37
|
+
thumbnail,
|
|
38
|
+
0,
|
|
39
|
+
0,
|
|
40
|
+
thumbnail.width,
|
|
41
|
+
thumbnail.height,
|
|
42
|
+
0,
|
|
43
|
+
0,
|
|
44
|
+
barWidth,
|
|
45
|
+
barHeight,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
width: barWidth,
|
|
50
|
+
height: barHeight,
|
|
51
|
+
deps: [model, theme],
|
|
52
|
+
})
|
|
53
|
+
|
|
17
54
|
const onDrag = useCallback(
|
|
18
55
|
(delta: number, startScroll: number) => {
|
|
19
56
|
model.setScrollX(startScroll - delta / unit)
|
|
@@ -33,15 +70,21 @@ const Minimap = observer(function ({ model }: { model: MsaViewModel }) {
|
|
|
33
70
|
flexShrink: 0,
|
|
34
71
|
}}
|
|
35
72
|
>
|
|
36
|
-
<
|
|
73
|
+
<canvas
|
|
74
|
+
ref={ref}
|
|
75
|
+
width={barWidth}
|
|
76
|
+
height={barHeight}
|
|
37
77
|
style={{
|
|
78
|
+
display: 'block',
|
|
79
|
+
width: msaCanvasWidth,
|
|
38
80
|
height: MINIMAP_BAR_HEIGHT,
|
|
39
81
|
boxSizing: 'border-box',
|
|
40
82
|
border: '1px solid #555',
|
|
41
83
|
}}
|
|
42
84
|
/>
|
|
43
|
-
<
|
|
85
|
+
<DragHandle
|
|
44
86
|
axis="x"
|
|
87
|
+
variant="thumb"
|
|
45
88
|
getStart={() => model.scrollX}
|
|
46
89
|
onDrag={onDrag}
|
|
47
90
|
style={{
|
|
@@ -9,8 +9,16 @@ import MSACanvasBlock from './MSACanvasBlock.tsx'
|
|
|
9
9
|
import type { MsaViewModel } from '../../model.ts'
|
|
10
10
|
|
|
11
11
|
const MSACanvas = observer(function ({ model }: { model: MsaViewModel }) {
|
|
12
|
-
const {
|
|
13
|
-
|
|
12
|
+
const {
|
|
13
|
+
MSA,
|
|
14
|
+
msaFilehandle,
|
|
15
|
+
height,
|
|
16
|
+
msaCanvasWidth,
|
|
17
|
+
blocks2d,
|
|
18
|
+
scrollZoom,
|
|
19
|
+
scrollX,
|
|
20
|
+
scrollY,
|
|
21
|
+
} = model
|
|
14
22
|
const ref = useRef<HTMLDivElement>(null)
|
|
15
23
|
const onScrollX = useCallback(
|
|
16
24
|
(d: number) => {
|
|
@@ -59,14 +67,26 @@ const MSACanvas = observer(function ({ model }: { model: MsaViewModel }) {
|
|
|
59
67
|
}}
|
|
60
68
|
>
|
|
61
69
|
{!MSA && !msaFilehandle ? null : MSA ? (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
// one transform for the whole block set, so a scroll frame is a
|
|
71
|
+
// compositor move rather than a style write per block
|
|
72
|
+
<div
|
|
73
|
+
style={{
|
|
74
|
+
position: 'absolute',
|
|
75
|
+
top: 0,
|
|
76
|
+
left: 0,
|
|
77
|
+
transform: `translate(${scrollX}px, ${scrollY}px)`,
|
|
78
|
+
willChange: 'transform',
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{blocks2d.map(([bx, by]) => (
|
|
82
|
+
<MSACanvasBlock
|
|
83
|
+
key={`${bx}_${by}`}
|
|
84
|
+
model={model}
|
|
85
|
+
offsetX={bx}
|
|
86
|
+
offsetY={by}
|
|
87
|
+
/>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
70
90
|
) : (
|
|
71
91
|
<Loading />
|
|
72
92
|
)}
|
|
@@ -6,6 +6,7 @@ import { observer } from 'mobx-react'
|
|
|
6
6
|
import { useCanvasAutorun } from '../../useCanvasAutorun.ts'
|
|
7
7
|
import { useColorContrast } from '../../useColorContrast.ts'
|
|
8
8
|
import ColumnStats from './ColumnStats.tsx'
|
|
9
|
+
import { drawMsaRaster, rasterSupported } from './msaRaster.ts'
|
|
9
10
|
import { renderBoxFeatureCanvasBlock } from './renderBoxFeatureCanvasBlock.ts'
|
|
10
11
|
import { renderMSABlock } from './renderMSABlock.ts'
|
|
11
12
|
import { useMsaBlockMouse } from './useMsaBlockMouse.ts'
|
|
@@ -21,7 +22,7 @@ const MSACanvasBlock = observer(function ({
|
|
|
21
22
|
offsetX: number
|
|
22
23
|
offsetY: number
|
|
23
24
|
}) {
|
|
24
|
-
const {
|
|
25
|
+
const { colorScheme, blockSize, highResScaleFactor } = model
|
|
25
26
|
const { theme, contrastScheme } = useColorContrast(colorScheme)
|
|
26
27
|
const { tooltipPoint, onMouseMove, onClick, onMouseLeave } = useMsaBlockMouse(
|
|
27
28
|
{
|
|
@@ -42,6 +43,14 @@ const MSACanvasBlock = observer(function ({
|
|
|
42
43
|
offsetY,
|
|
43
44
|
model,
|
|
44
45
|
})
|
|
46
|
+
// the alignment background comes from zoom-independent raster tiles; the
|
|
47
|
+
// domain overlay paints its own boxes instead, and letter-color mode has
|
|
48
|
+
// no background to paint
|
|
49
|
+
const rasterTiles =
|
|
50
|
+
!model.actuallyShowDomains && model.bgColor && rasterSupported()
|
|
51
|
+
if (rasterTiles) {
|
|
52
|
+
drawMsaRaster({ ctx, model, theme, offsetX, offsetY })
|
|
53
|
+
}
|
|
45
54
|
renderMSABlock({
|
|
46
55
|
ctx,
|
|
47
56
|
theme,
|
|
@@ -49,6 +58,7 @@ const MSACanvasBlock = observer(function ({
|
|
|
49
58
|
offsetY,
|
|
50
59
|
contrastScheme,
|
|
51
60
|
model,
|
|
61
|
+
rasterTiles,
|
|
52
62
|
})
|
|
53
63
|
},
|
|
54
64
|
width: canvasSize,
|
|
@@ -79,8 +89,8 @@ const MSACanvasBlock = observer(function ({
|
|
|
79
89
|
height={canvasSize}
|
|
80
90
|
style={{
|
|
81
91
|
position: 'absolute',
|
|
82
|
-
top:
|
|
83
|
-
left:
|
|
92
|
+
top: offsetY,
|
|
93
|
+
left: offsetX,
|
|
84
94
|
width: blockSize,
|
|
85
95
|
height: blockSize,
|
|
86
96
|
}}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { beforeAll, expect, test } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import MSAModelF from '../../model.ts'
|
|
5
|
+
import { drawMsaRaster, rasterPixels } from './msaRaster.ts'
|
|
6
|
+
|
|
7
|
+
import type { RasterSpec } from './msaRaster.ts'
|
|
8
|
+
|
|
9
|
+
// the stub context resolves a color by parsing '#rrggbb', so every color the
|
|
10
|
+
// raster asks about in this file is written that way
|
|
11
|
+
function hexBytes(css: string) {
|
|
12
|
+
const hex = css.replace('#', '')
|
|
13
|
+
return [
|
|
14
|
+
Number.parseInt(hex.slice(0, 2), 16),
|
|
15
|
+
Number.parseInt(hex.slice(2, 4), 16),
|
|
16
|
+
Number.parseInt(hex.slice(4, 6), 16),
|
|
17
|
+
255,
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const drawn: unknown[] = []
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
HTMLCanvasElement.prototype.getContext = function () {
|
|
25
|
+
let fillStyle = '#000000'
|
|
26
|
+
return {
|
|
27
|
+
get fillStyle() {
|
|
28
|
+
return fillStyle
|
|
29
|
+
},
|
|
30
|
+
set fillStyle(v: string) {
|
|
31
|
+
fillStyle = v
|
|
32
|
+
},
|
|
33
|
+
imageSmoothingEnabled: true,
|
|
34
|
+
font: '10px sans-serif',
|
|
35
|
+
measureText: (t: string) => ({ width: t.length * 6 }),
|
|
36
|
+
clearRect: () => {},
|
|
37
|
+
fillRect: () => {},
|
|
38
|
+
resetTransform: () => {},
|
|
39
|
+
scale: () => {},
|
|
40
|
+
translate: () => {},
|
|
41
|
+
getImageData: () => ({
|
|
42
|
+
data: Uint8ClampedArray.from(hexBytes(fillStyle)),
|
|
43
|
+
}),
|
|
44
|
+
createImageData: (width: number, height: number) => ({
|
|
45
|
+
data: new Uint8ClampedArray(width * height * 4),
|
|
46
|
+
width,
|
|
47
|
+
height,
|
|
48
|
+
}),
|
|
49
|
+
putImageData: () => {},
|
|
50
|
+
drawImage: (image: unknown) => {
|
|
51
|
+
drawn.push(image)
|
|
52
|
+
},
|
|
53
|
+
} as unknown as CanvasRenderingContext2D
|
|
54
|
+
} as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const spec: RasterSpec = {
|
|
58
|
+
rowNames: ['a', 'b', 'c'],
|
|
59
|
+
columns: new Map([
|
|
60
|
+
['a', 'MKL'],
|
|
61
|
+
['b', 'MKL'],
|
|
62
|
+
['c', 'MK'],
|
|
63
|
+
]),
|
|
64
|
+
relativeTo: 'a',
|
|
65
|
+
colorAt: (_col, letter) => (letter === 'M' ? '#ff0000' : undefined),
|
|
66
|
+
bg: '#000000',
|
|
67
|
+
hover: '#00ff00',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ids: Record<string, number> = { '#ff0000': 1, '#000000': 2, '#00ff00': 3 }
|
|
71
|
+
const toPixel = (css: string) => ids[css]!
|
|
72
|
+
|
|
73
|
+
test('one pixel per cell, reference matches and short rows', () => {
|
|
74
|
+
const px = rasterPixels({
|
|
75
|
+
spec,
|
|
76
|
+
col0: 0,
|
|
77
|
+
row0: 0,
|
|
78
|
+
width: 3,
|
|
79
|
+
height: 4,
|
|
80
|
+
toPixel,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// the reference row itself keeps its scheme colors
|
|
84
|
+
expect([...px.slice(0, 3)]).toEqual([1, 2, 2])
|
|
85
|
+
// every cell of an identical row reads as a match
|
|
86
|
+
expect([...px.slice(3, 6)]).toEqual([3, 3, 3])
|
|
87
|
+
// past the end of a short row, and past the last row, stays transparent
|
|
88
|
+
expect([...px.slice(6, 9)]).toEqual([3, 3, 0])
|
|
89
|
+
expect([...px.slice(9, 12)]).toEqual([0, 0, 0])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('sampling steps over columns and rows', () => {
|
|
93
|
+
const px = rasterPixels({
|
|
94
|
+
spec,
|
|
95
|
+
col0: 0,
|
|
96
|
+
row0: 0,
|
|
97
|
+
width: 2,
|
|
98
|
+
height: 2,
|
|
99
|
+
colStep: 2,
|
|
100
|
+
rowStep: 2,
|
|
101
|
+
toPixel,
|
|
102
|
+
})
|
|
103
|
+
// the second sampled column falls past the end of the short row 'c'
|
|
104
|
+
expect([...px]).toEqual([1, 2, 3, 0])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
function make() {
|
|
108
|
+
const model = MSAModelF().create({
|
|
109
|
+
id: 'raster',
|
|
110
|
+
type: 'MsaView',
|
|
111
|
+
msaFormat: 'fasta',
|
|
112
|
+
data: { msa: '>a\nMKLVIL\n>b\nMRLVIL\n>c\nMKLAIL' },
|
|
113
|
+
})
|
|
114
|
+
model.setWidth(1000)
|
|
115
|
+
return model
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function tilesFor(model: ReturnType<typeof make>) {
|
|
119
|
+
drawn.length = 0
|
|
120
|
+
const ctx = document.createElement('canvas').getContext('2d')!
|
|
121
|
+
drawMsaRaster({ ctx, model, theme: theme(), offsetX: 0, offsetY: 0 })
|
|
122
|
+
return [...drawn]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// only the two palette entries the raster reads
|
|
126
|
+
const theme = () =>
|
|
127
|
+
({
|
|
128
|
+
palette: {
|
|
129
|
+
action: { hover: '#00ff00' },
|
|
130
|
+
background: { default: '#ffffff' },
|
|
131
|
+
},
|
|
132
|
+
}) as never
|
|
133
|
+
|
|
134
|
+
test('a zoom reuses the tiles, a recolor rebuilds them', () => {
|
|
135
|
+
const model = make()
|
|
136
|
+
const tile = tilesFor(model)[0]
|
|
137
|
+
expect(tile).toBeDefined()
|
|
138
|
+
|
|
139
|
+
model.setColWidth(3)
|
|
140
|
+
model.setRowHeight(2)
|
|
141
|
+
expect(tilesFor(model)[0]).toBe(tile)
|
|
142
|
+
|
|
143
|
+
model.setColorSchemeName('clustalx_protein_dynamic')
|
|
144
|
+
expect(tilesFor(model)[0]).not.toBe(tile)
|
|
145
|
+
})
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { tileColorFn, tileColorTable } from './tileColor.ts'
|
|
2
|
+
|
|
3
|
+
import type { MsaViewModel } from '../../model.ts'
|
|
4
|
+
import type { Theme } from '@mui/material'
|
|
5
|
+
|
|
6
|
+
// One raster tile holds this many cells on a side. A tile costs ~10ms to build
|
|
7
|
+
// and ~1MB to hold, and is then drawn at any zoom for ~1ms, so the cache only
|
|
8
|
+
// has to be large enough to cover a screen without thrashing.
|
|
9
|
+
export const rasterTileSize = 512
|
|
10
|
+
const maxCachedTiles = 64
|
|
11
|
+
const maxThumbnailColumns = 2000
|
|
12
|
+
const maxColorCacheEntries = 4096
|
|
13
|
+
|
|
14
|
+
type RasterCanvas = HTMLCanvasElement | OffscreenCanvas
|
|
15
|
+
type RasterCtx = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
|
|
16
|
+
|
|
17
|
+
export interface RasterSpec {
|
|
18
|
+
rowNames: string[]
|
|
19
|
+
columns: Map<string, string>
|
|
20
|
+
relativeTo: string | undefined
|
|
21
|
+
colorAt: (col: number, letter: string) => string | undefined
|
|
22
|
+
bg: string
|
|
23
|
+
hover: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const littleEndian = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1
|
|
27
|
+
|
|
28
|
+
function packRgba(r: number, g: number, b: number, a: number) {
|
|
29
|
+
return littleEndian
|
|
30
|
+
? ((a << 24) | (b << 16) | (g << 8) | r) >>> 0
|
|
31
|
+
: ((r << 24) | (g << 16) | (b << 8) | a) >>> 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let scratch: RasterCtx | null | undefined
|
|
35
|
+
const colorCache = new Map<string, number>()
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* CSS color string -> the packed pixel an ImageData holds for it. Painting a 1x1
|
|
39
|
+
* canvas and reading it back handles every form a color scheme can produce --
|
|
40
|
+
* hex, rgb(), hsl(), named -- without a parser of our own, and the cache means
|
|
41
|
+
* each distinct color is resolved once per session rather than once per cell.
|
|
42
|
+
*/
|
|
43
|
+
export function cssColorToPixel(css: string) {
|
|
44
|
+
const hit = colorCache.get(css)
|
|
45
|
+
if (hit !== undefined) {
|
|
46
|
+
return hit
|
|
47
|
+
}
|
|
48
|
+
if (scratch === undefined) {
|
|
49
|
+
scratch = makeRasterCanvas(1, 1)?.ctx ?? null
|
|
50
|
+
}
|
|
51
|
+
let packed = 0
|
|
52
|
+
if (scratch) {
|
|
53
|
+
scratch.clearRect(0, 0, 1, 1)
|
|
54
|
+
scratch.fillStyle = css
|
|
55
|
+
scratch.fillRect(0, 0, 1, 1)
|
|
56
|
+
const [r = 0, g = 0, b = 0, a = 0] = scratch.getImageData(0, 0, 1, 1).data
|
|
57
|
+
packed = packRgba(r, g, b, a)
|
|
58
|
+
}
|
|
59
|
+
if (colorCache.size >= maxColorCacheEntries) {
|
|
60
|
+
colorCache.clear()
|
|
61
|
+
}
|
|
62
|
+
colorCache.set(css, packed)
|
|
63
|
+
return packed
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Packs a rectangle of the alignment into one pixel per cell, in `rowNames`
|
|
68
|
+
* order, ready to hand to putImageData. Cells past the end of a row, and rows
|
|
69
|
+
* with no sequence at all, stay transparent -- what the per-cell tile painter
|
|
70
|
+
* also leaves behind.
|
|
71
|
+
*
|
|
72
|
+
* `colStep`/`rowStep` sample instead of covering, which is how the minimap fits
|
|
73
|
+
* a whole alignment into a 12px bar.
|
|
74
|
+
*/
|
|
75
|
+
export function rasterPixels({
|
|
76
|
+
spec,
|
|
77
|
+
col0,
|
|
78
|
+
row0,
|
|
79
|
+
width,
|
|
80
|
+
height,
|
|
81
|
+
colStep = 1,
|
|
82
|
+
rowStep = 1,
|
|
83
|
+
toPixel = cssColorToPixel,
|
|
84
|
+
}: {
|
|
85
|
+
spec: RasterSpec
|
|
86
|
+
col0: number
|
|
87
|
+
row0: number
|
|
88
|
+
width: number
|
|
89
|
+
height: number
|
|
90
|
+
colStep?: number
|
|
91
|
+
rowStep?: number
|
|
92
|
+
toPixel?: (css: string) => number
|
|
93
|
+
}) {
|
|
94
|
+
const { rowNames, columns, relativeTo, colorAt, bg, hover } = spec
|
|
95
|
+
const out = new Uint32Array(width * height)
|
|
96
|
+
const bgPixel = toPixel(bg)
|
|
97
|
+
const hoverPixel = toPixel(hover)
|
|
98
|
+
const reference = relativeTo ? columns.get(relativeTo) : undefined
|
|
99
|
+
|
|
100
|
+
for (let r = 0; r < height; r++) {
|
|
101
|
+
const name = rowNames[row0 + r * rowStep]
|
|
102
|
+
if (name === undefined) {
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
const str = columns.get(name)
|
|
106
|
+
if (!str) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const isReference = name === relativeTo
|
|
110
|
+
const base = r * width
|
|
111
|
+
for (let c = 0; c < width; c++) {
|
|
112
|
+
const col = col0 + c * colStep
|
|
113
|
+
const letter = str[col]
|
|
114
|
+
if (letter === undefined) {
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
if (reference && !isReference && letter === reference[col]) {
|
|
118
|
+
out[base + c] = hoverPixel
|
|
119
|
+
} else {
|
|
120
|
+
const color = colorAt(col, letter)
|
|
121
|
+
out[base + c] = color === undefined ? bgPixel : toPixel(color)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
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
|
+
: undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function paint(pixels: Uint32Array, width: number, height: number) {
|
|
149
|
+
const made = makeRasterCanvas(width, height)
|
|
150
|
+
if (!made) {
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
const image = made.ctx.createImageData(width, height)
|
|
154
|
+
new Uint32Array(image.data.buffer).set(pixels)
|
|
155
|
+
made.ctx.putImageData(image, 0, 0)
|
|
156
|
+
return made.canvas
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let supported: boolean | undefined
|
|
160
|
+
|
|
161
|
+
// jsdom, and any browser without a 2d context, has no raster to draw; the
|
|
162
|
+
// per-cell painter in renderMSABlock stays the fallback
|
|
163
|
+
export function rasterSupported() {
|
|
164
|
+
supported ??= !!makeRasterCanvas(1, 1)
|
|
165
|
+
return supported
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface RasterCache {
|
|
169
|
+
keys: unknown[]
|
|
170
|
+
spec: RasterSpec
|
|
171
|
+
numColumns: number
|
|
172
|
+
numRows: number
|
|
173
|
+
tiles: Map<string, RasterCanvas>
|
|
174
|
+
thumbnail?: RasterCanvas
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const caches = new WeakMap<object, RasterCache>()
|
|
178
|
+
|
|
179
|
+
// Everything a cell's color depends on. A change to any of them makes every
|
|
180
|
+
// cached tile stale, which a fresh cache expresses without tracking which.
|
|
181
|
+
function rasterKeys(model: MsaViewModel, theme: Theme) {
|
|
182
|
+
return [
|
|
183
|
+
model.columns,
|
|
184
|
+
model.leaves,
|
|
185
|
+
model.colorSchemeName,
|
|
186
|
+
tileColorTable(model),
|
|
187
|
+
model.relativeTo,
|
|
188
|
+
theme.palette.action.hover,
|
|
189
|
+
theme.palette.background.default,
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function getCache(model: MsaViewModel, theme: Theme) {
|
|
194
|
+
const keys = rasterKeys(model, theme)
|
|
195
|
+
const prev = caches.get(model)
|
|
196
|
+
if (prev && keys.every((key, i) => key === prev.keys[i])) {
|
|
197
|
+
return prev
|
|
198
|
+
}
|
|
199
|
+
const next: RasterCache = {
|
|
200
|
+
keys,
|
|
201
|
+
spec: {
|
|
202
|
+
rowNames: model.leaves.map(node => node.data.name),
|
|
203
|
+
columns: model.columns,
|
|
204
|
+
relativeTo: model.relativeTo,
|
|
205
|
+
colorAt: tileColorFn(model),
|
|
206
|
+
bg: theme.palette.background.default,
|
|
207
|
+
hover: theme.palette.action.hover,
|
|
208
|
+
},
|
|
209
|
+
numColumns: model.numColumns,
|
|
210
|
+
numRows: model.numRows,
|
|
211
|
+
tiles: new Map(),
|
|
212
|
+
}
|
|
213
|
+
caches.set(model, next)
|
|
214
|
+
return next
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function getTile(cache: RasterCache, tileRow: number, tileCol: number) {
|
|
218
|
+
const key = `${tileRow}_${tileCol}`
|
|
219
|
+
const hit = cache.tiles.get(key)
|
|
220
|
+
if (hit) {
|
|
221
|
+
// re-inserting makes this the newest entry: a Map iterates in insertion
|
|
222
|
+
// order, so its first key is the least recently used one
|
|
223
|
+
cache.tiles.delete(key)
|
|
224
|
+
cache.tiles.set(key, hit)
|
|
225
|
+
return hit
|
|
226
|
+
}
|
|
227
|
+
const col0 = tileCol * rasterTileSize
|
|
228
|
+
const row0 = tileRow * rasterTileSize
|
|
229
|
+
const width = Math.min(rasterTileSize, cache.numColumns - col0)
|
|
230
|
+
const height = Math.min(rasterTileSize, cache.numRows - row0)
|
|
231
|
+
if (width <= 0 || height <= 0) {
|
|
232
|
+
return undefined
|
|
233
|
+
}
|
|
234
|
+
const canvas = paint(
|
|
235
|
+
rasterPixels({ spec: cache.spec, col0, row0, width, height }),
|
|
236
|
+
width,
|
|
237
|
+
height,
|
|
238
|
+
)
|
|
239
|
+
if (!canvas) {
|
|
240
|
+
return undefined
|
|
241
|
+
}
|
|
242
|
+
cache.tiles.set(key, canvas)
|
|
243
|
+
for (const stale of cache.tiles.keys()) {
|
|
244
|
+
if (cache.tiles.size <= maxCachedTiles) {
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
cache.tiles.delete(stale)
|
|
248
|
+
}
|
|
249
|
+
return canvas
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Paints the alignment background of one MSA block from cached
|
|
254
|
+
* one-pixel-per-cell tiles. The tiles do not depend on the zoom level, so a zoom
|
|
255
|
+
* frame costs a handful of drawImage calls instead of a fillRect per visible
|
|
256
|
+
* cell -- which ran to 885ms per block at the minimum column width.
|
|
257
|
+
*/
|
|
258
|
+
export function drawMsaRaster({
|
|
259
|
+
ctx,
|
|
260
|
+
model,
|
|
261
|
+
theme,
|
|
262
|
+
offsetX,
|
|
263
|
+
offsetY,
|
|
264
|
+
}: {
|
|
265
|
+
ctx: CanvasRenderingContext2D
|
|
266
|
+
model: MsaViewModel
|
|
267
|
+
theme: Theme
|
|
268
|
+
offsetX: number
|
|
269
|
+
offsetY: number
|
|
270
|
+
}) {
|
|
271
|
+
const { colWidth, rowHeight, blockSize, highResScaleFactor } = model
|
|
272
|
+
const cache = getCache(model, theme)
|
|
273
|
+
|
|
274
|
+
ctx.resetTransform()
|
|
275
|
+
ctx.scale(highResScaleFactor, highResScaleFactor)
|
|
276
|
+
ctx.translate(-offsetX, -offsetY)
|
|
277
|
+
// below a pixel per cell a tile carries more cells than the block has room
|
|
278
|
+
// for, so let the browser downsample it rather than letting whichever cell
|
|
279
|
+
// landed last win
|
|
280
|
+
ctx.imageSmoothingEnabled = colWidth < 1 || rowHeight < 1
|
|
281
|
+
|
|
282
|
+
const tileWidth = rasterTileSize * colWidth
|
|
283
|
+
const tileHeight = rasterTileSize * rowHeight
|
|
284
|
+
const firstCol = Math.max(0, Math.floor(offsetX / tileWidth))
|
|
285
|
+
const lastCol = Math.floor((offsetX + blockSize) / tileWidth)
|
|
286
|
+
const firstRow = Math.max(0, Math.floor(offsetY / tileHeight))
|
|
287
|
+
const lastRow = Math.floor((offsetY + blockSize) / tileHeight)
|
|
288
|
+
|
|
289
|
+
for (let tileRow = firstRow; tileRow <= lastRow; tileRow++) {
|
|
290
|
+
for (let tileCol = firstCol; tileCol <= lastCol; tileCol++) {
|
|
291
|
+
const tile = getTile(cache, tileRow, tileCol)
|
|
292
|
+
if (tile) {
|
|
293
|
+
ctx.drawImage(
|
|
294
|
+
tile,
|
|
295
|
+
0,
|
|
296
|
+
0,
|
|
297
|
+
tile.width,
|
|
298
|
+
tile.height,
|
|
299
|
+
tileCol * tileWidth,
|
|
300
|
+
tileRow * tileHeight,
|
|
301
|
+
tile.width * colWidth,
|
|
302
|
+
tile.height * rowHeight,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
ctx.resetTransform()
|
|
308
|
+
ctx.imageSmoothingEnabled = true
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The whole alignment sampled down to a strip small enough to sit behind the
|
|
313
|
+
* minimap thumb, cached alongside the block tiles because it goes stale under
|
|
314
|
+
* exactly the same conditions.
|
|
315
|
+
*/
|
|
316
|
+
export function msaThumbnail({
|
|
317
|
+
model,
|
|
318
|
+
theme,
|
|
319
|
+
height,
|
|
320
|
+
}: {
|
|
321
|
+
model: MsaViewModel
|
|
322
|
+
theme: Theme
|
|
323
|
+
height: number
|
|
324
|
+
}) {
|
|
325
|
+
const cache = getCache(model, theme)
|
|
326
|
+
const { numColumns, numRows } = cache
|
|
327
|
+
if (numColumns <= 0 || numRows <= 0) {
|
|
328
|
+
return undefined
|
|
329
|
+
}
|
|
330
|
+
if (!cache.thumbnail) {
|
|
331
|
+
const colStep = Math.max(1, Math.ceil(numColumns / maxThumbnailColumns))
|
|
332
|
+
const rowStep = Math.max(1, Math.ceil(numRows / height))
|
|
333
|
+
const w = Math.ceil(numColumns / colStep)
|
|
334
|
+
const h = Math.ceil(numRows / rowStep)
|
|
335
|
+
cache.thumbnail = paint(
|
|
336
|
+
rasterPixels({
|
|
337
|
+
spec: cache.spec,
|
|
338
|
+
col0: 0,
|
|
339
|
+
row0: 0,
|
|
340
|
+
width: w,
|
|
341
|
+
height: h,
|
|
342
|
+
colStep,
|
|
343
|
+
rowStep,
|
|
344
|
+
}),
|
|
345
|
+
w,
|
|
346
|
+
h,
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
return cache.thumbnail
|
|
350
|
+
}
|