react-msaview 5.10.0 → 6.1.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 (226) hide show
  1. package/bundle/index.js +99 -99
  2. package/bundle/index.js.map +4 -4
  3. package/dist/clustalX.js +22 -22
  4. package/dist/clustalX.js.map +1 -1
  5. package/dist/columnCounts.d.ts +7 -0
  6. package/dist/columnCounts.js +18 -0
  7. package/dist/columnCounts.js.map +1 -1
  8. package/dist/components/CopyButton.d.ts +9 -0
  9. package/dist/components/CopyButton.js +23 -0
  10. package/dist/components/CopyButton.js.map +1 -0
  11. package/dist/components/DragHandle.d.ts +25 -0
  12. package/dist/components/DragHandle.js +49 -0
  13. package/dist/components/DragHandle.js.map +1 -0
  14. package/dist/components/Loading.js +2 -2
  15. package/dist/components/Loading.js.map +1 -1
  16. package/dist/components/MSAView.js +2 -2
  17. package/dist/components/MSAView.js.map +1 -1
  18. package/dist/components/ResizeHandles.d.ts +4 -2
  19. package/dist/components/ResizeHandles.js +35 -54
  20. package/dist/components/ResizeHandles.js.map +1 -1
  21. package/dist/components/SequenceTextArea.js +3 -10
  22. package/dist/components/SequenceTextArea.js.map +1 -1
  23. package/dist/components/VerticalScrollbar.js +11 -25
  24. package/dist/components/VerticalScrollbar.js.map +1 -1
  25. package/dist/components/dialogs/FeatureDialog.js +3 -3
  26. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  27. package/dist/components/dialogs/InterProScanDialog.js +52 -129
  28. package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
  29. package/dist/components/dialogs/{UserProvidedDomainsDialog.d.ts → InterProScanFileDialog.d.ts} +2 -2
  30. package/dist/components/dialogs/{UserProvidedDomainsDialog.js → InterProScanFileDialog.js} +6 -10
  31. package/dist/components/dialogs/InterProScanFileDialog.js.map +1 -0
  32. package/dist/components/dialogs/TrackInfoDialog.d.ts +2 -4
  33. package/dist/components/dialogs/TrackInfoDialog.js +8 -14
  34. package/dist/components/dialogs/TrackInfoDialog.js.map +1 -1
  35. package/dist/components/header/FileMenu.js +3 -3
  36. package/dist/components/header/FileMenu.js.map +1 -1
  37. package/dist/components/header/GappynessSlider.js +1 -1
  38. package/dist/components/header/GappynessSlider.js.map +1 -1
  39. package/dist/components/header/{getDomainsMenu.d.ts → getAnnotationsMenu.d.ts} +1 -1
  40. package/dist/components/header/{getDomainsMenu.js → getAnnotationsMenu.js} +10 -9
  41. package/dist/components/header/getAnnotationsMenu.js.map +1 -0
  42. package/dist/components/header/settingsMenuItems.js +15 -0
  43. package/dist/components/header/settingsMenuItems.js.map +1 -1
  44. package/dist/components/import/ImportForm.js +1 -1
  45. package/dist/components/import/ImportForm.js.map +1 -1
  46. package/dist/components/import/ImportFormExamples.js +66 -57
  47. package/dist/components/import/ImportFormExamples.js.map +1 -1
  48. package/dist/components/minimap/Minimap.js +40 -23
  49. package/dist/components/minimap/Minimap.js.map +1 -1
  50. package/dist/components/minimap/MinimapSVG.d.ts +2 -1
  51. package/dist/components/minimap/MinimapSVG.js +3 -4
  52. package/dist/components/minimap/MinimapSVG.js.map +1 -1
  53. package/dist/components/minimap/minimapLayout.d.ts +10 -1
  54. package/dist/components/minimap/minimapLayout.js +15 -10
  55. package/dist/components/minimap/minimapLayout.js.map +1 -1
  56. package/dist/components/msa/{DomainLegend.d.ts → AnnotationLegend.d.ts} +2 -2
  57. package/dist/components/msa/{DomainLegend.js → AnnotationLegend.js} +4 -4
  58. package/dist/components/msa/AnnotationLegend.js.map +1 -0
  59. package/dist/components/msa/MSACanvas.js +9 -3
  60. package/dist/components/msa/MSACanvas.js.map +1 -1
  61. package/dist/components/msa/MSACanvasBlock.js +12 -3
  62. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  63. package/dist/components/msa/MSAMouseoverCanvas.js +1 -2
  64. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  65. package/dist/components/msa/MSAPanel.js +2 -2
  66. package/dist/components/msa/MSAPanel.js.map +1 -1
  67. package/dist/components/msa/domainBandCursor.d.ts +17 -0
  68. package/dist/components/msa/domainBandCursor.js +39 -0
  69. package/dist/components/msa/domainBandCursor.js.map +1 -0
  70. package/dist/components/msa/msaRaster.d.ts +63 -0
  71. package/dist/components/msa/msaRaster.js +252 -0
  72. package/dist/components/msa/msaRaster.js.map +1 -0
  73. package/dist/components/msa/renderMSABlock.d.ts +2 -1
  74. package/dist/components/msa/renderMSABlock.js +27 -41
  75. package/dist/components/msa/renderMSABlock.js.map +1 -1
  76. package/dist/components/msa/renderMSAMouseover.js +1 -2
  77. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  78. package/dist/components/msa/tileColor.d.ts +6 -0
  79. package/dist/components/msa/tileColor.js +35 -0
  80. package/dist/components/msa/tileColor.js.map +1 -0
  81. package/dist/components/renderCtx.d.ts +1 -1
  82. package/dist/components/tracks/TrackBlocks.d.ts +14 -0
  83. package/dist/components/tracks/TrackBlocks.js +65 -0
  84. package/dist/components/tracks/TrackBlocks.js.map +1 -0
  85. package/dist/components/tracks/{renderTracksSvg.d.ts → drawTracks.d.ts} +22 -10
  86. package/dist/components/tracks/drawTracks.js +193 -0
  87. package/dist/components/tracks/drawTracks.js.map +1 -0
  88. package/dist/components/tree/TreeCanvas.js +8 -2
  89. package/dist/components/tree/TreeCanvas.js.map +1 -1
  90. package/dist/components/tree/TreeCanvasBlock.js +7 -4
  91. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  92. package/dist/components/tree/TreeRuler.js +5 -2
  93. package/dist/components/tree/TreeRuler.js.map +1 -1
  94. package/dist/components/tree/renderTreeCanvas.js +5 -2
  95. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  96. package/dist/constants.d.ts +3 -0
  97. package/dist/constants.js +8 -0
  98. package/dist/constants.js.map +1 -1
  99. package/dist/ggplotPalettes.d.ts +0 -1
  100. package/dist/ggplotPalettes.js +0 -3
  101. package/dist/ggplotPalettes.js.map +1 -1
  102. package/dist/hierarchy.d.ts +2 -6
  103. package/dist/hierarchy.js +4 -6
  104. package/dist/hierarchy.js.map +1 -1
  105. package/dist/index.d.ts +2 -2
  106. package/dist/launchInterProScan.d.ts +0 -23
  107. package/dist/launchInterProScan.js +2 -5
  108. package/dist/launchInterProScan.js.map +1 -1
  109. package/dist/measureTextCanvas.js +21 -6
  110. package/dist/measureTextCanvas.js.map +1 -1
  111. package/dist/model/DataModel.d.ts +1 -4
  112. package/dist/model/DataModel.js +8 -10
  113. package/dist/model/DataModel.js.map +1 -1
  114. package/dist/model.d.ts +86 -107
  115. package/dist/model.js +238 -162
  116. package/dist/model.js.map +1 -1
  117. package/dist/neighborJoining.js +34 -18
  118. package/dist/neighborJoining.js.map +1 -1
  119. package/dist/renderTestEnv.d.ts +43 -0
  120. package/dist/renderTestEnv.js +88 -0
  121. package/dist/renderTestEnv.js.map +1 -0
  122. package/dist/renderToSvg.js +3 -3
  123. package/dist/renderToSvg.js.map +1 -1
  124. package/dist/rowCoordinateCalculations.d.ts +13 -17
  125. package/dist/rowCoordinateCalculations.js +45 -39
  126. package/dist/rowCoordinateCalculations.js.map +1 -1
  127. package/dist/sequenceLogo.d.ts +28 -0
  128. package/dist/sequenceLogo.js +47 -0
  129. package/dist/sequenceLogo.js.map +1 -0
  130. package/dist/types.d.ts +6 -11
  131. package/dist/version.d.ts +1 -1
  132. package/dist/version.js +1 -1
  133. package/dist/version.js.map +1 -1
  134. package/package.json +5 -5
  135. package/src/clustalX.ts +22 -22
  136. package/src/colSpace.test.ts +27 -0
  137. package/src/columnCounts.test.ts +15 -0
  138. package/src/columnCounts.ts +19 -0
  139. package/src/components/CopyButton.tsx +44 -0
  140. package/src/components/DragHandle.tsx +81 -0
  141. package/src/components/Loading.tsx +10 -1
  142. package/src/components/MSAView.tsx +2 -3
  143. package/src/components/ResizeHandles.tsx +70 -87
  144. package/src/components/SequenceTextArea.tsx +3 -16
  145. package/src/components/VerticalScrollbar.tsx +20 -33
  146. package/src/components/dialogs/FeatureDialog.tsx +3 -4
  147. package/src/components/dialogs/InterProScanDialog.tsx +54 -131
  148. package/src/components/dialogs/{UserProvidedDomainsDialog.tsx → InterProScanFileDialog.tsx} +6 -14
  149. package/src/components/dialogs/TrackInfoDialog.tsx +18 -20
  150. package/src/components/header/FileMenu.tsx +3 -3
  151. package/src/components/header/GappynessSlider.tsx +4 -1
  152. package/src/components/header/{getDomainsMenu.ts → getAnnotationsMenu.ts} +10 -9
  153. package/src/components/header/settingsMenuItems.ts +18 -0
  154. package/src/components/import/ImportForm.tsx +1 -1
  155. package/src/components/import/ImportFormExamples.tsx +82 -133
  156. package/src/components/minimap/Minimap.tsx +67 -32
  157. package/src/components/minimap/MinimapSVG.tsx +32 -31
  158. package/src/components/minimap/minimapLayout.test.ts +60 -0
  159. package/src/components/minimap/minimapLayout.ts +15 -10
  160. package/src/components/msa/{DomainLegend.tsx → AnnotationLegend.tsx} +7 -3
  161. package/src/components/msa/MSACanvas.tsx +24 -11
  162. package/src/components/msa/MSACanvasBlock.tsx +13 -3
  163. package/src/components/msa/MSAMouseoverCanvas.tsx +1 -3
  164. package/src/components/msa/MSAPanel.tsx +2 -2
  165. package/src/components/msa/domainBandCursor.ts +41 -0
  166. package/src/components/msa/msaRaster.test.ts +145 -0
  167. package/src/components/msa/msaRaster.ts +350 -0
  168. package/src/components/msa/renderMSABlock.ts +34 -43
  169. package/src/components/msa/renderMSAMouseover.ts +1 -3
  170. package/src/components/msa/tileColor.ts +37 -0
  171. package/src/components/renderCtx.ts +2 -0
  172. package/src/components/tracks/TrackBlocks.tsx +111 -0
  173. package/src/components/tracks/drawTracks.ts +323 -0
  174. package/src/components/tree/TreeCanvas.tsx +16 -4
  175. package/src/components/tree/TreeCanvasBlock.tsx +18 -11
  176. package/src/components/tree/TreeRuler.tsx +7 -2
  177. package/src/components/tree/renderTreeCanvas.test.ts +15 -7
  178. package/src/components/tree/renderTreeCanvas.ts +5 -1
  179. package/src/constants.ts +10 -0
  180. package/src/domainBands.test.ts +24 -0
  181. package/src/domainLetterColor.test.ts +80 -0
  182. package/src/ggplotPalettes.ts +0 -4
  183. package/src/hierarchy.test.ts +7 -7
  184. package/src/hierarchy.ts +6 -9
  185. package/src/impgRender.test.tsx +2 -57
  186. package/src/index.ts +4 -1
  187. package/src/largeTree.test.ts +39 -4
  188. package/src/launchInterProScan.ts +3 -30
  189. package/src/loadErrorDisplay.test.tsx +102 -0
  190. package/src/measureTextCanvas.ts +22 -6
  191. package/src/model/DataModel.ts +11 -11
  192. package/src/model.ts +262 -172
  193. package/src/neighborJoining.ts +36 -18
  194. package/src/renderDomainsSvg.test.tsx +2 -57
  195. package/src/renderMinimapSvg.test.tsx +55 -0
  196. package/src/renderSequenceLogoSvg.test.tsx +176 -0
  197. package/src/renderTestEnv.ts +96 -0
  198. package/src/renderToSvg.tsx +3 -3
  199. package/src/rowCoordinateCalculations.ts +48 -44
  200. package/src/sequenceLogo.test.ts +88 -0
  201. package/src/sequenceLogo.ts +63 -0
  202. package/src/types.ts +17 -16
  203. package/src/version.ts +1 -1
  204. package/src/viewChrome.test.tsx +134 -0
  205. package/dist/components/ConservationTrack.d.ts +0 -8
  206. package/dist/components/ConservationTrack.js +0 -49
  207. package/dist/components/ConservationTrack.js.map +0 -1
  208. package/dist/components/TextTrack.d.ts +0 -8
  209. package/dist/components/TextTrack.js +0 -58
  210. package/dist/components/TextTrack.js.map +0 -1
  211. package/dist/components/dialogs/DomainDialog.d.ts +0 -6
  212. package/dist/components/dialogs/DomainDialog.js +0 -19
  213. package/dist/components/dialogs/DomainDialog.js.map +0 -1
  214. package/dist/components/dialogs/UserProvidedDomainsDialog.js.map +0 -1
  215. package/dist/components/header/SettingsMenu.d.ts +0 -6
  216. package/dist/components/header/SettingsMenu.js +0 -40
  217. package/dist/components/header/SettingsMenu.js.map +0 -1
  218. package/dist/components/header/getDomainsMenu.js.map +0 -1
  219. package/dist/components/msa/DomainLegend.js.map +0 -1
  220. package/dist/components/tracks/renderTracksSvg.js +0 -126
  221. package/dist/components/tracks/renderTracksSvg.js.map +0 -1
  222. package/src/components/ConservationTrack.tsx +0 -99
  223. package/src/components/TextTrack.tsx +0 -103
  224. package/src/components/dialogs/DomainDialog.tsx +0 -45
  225. package/src/components/header/SettingsMenu.tsx +0 -57
  226. package/src/components/tracks/renderTracksSvg.ts +0 -247
@@ -7,7 +7,11 @@ import { observer } from 'mobx-react'
7
7
 
8
8
  import type { MsaViewModel } from '../../model.ts'
9
9
 
10
- const DomainLegend = observer(function ({ model }: { model: MsaViewModel }) {
10
+ const AnnotationLegend = observer(function ({
11
+ model,
12
+ }: {
13
+ model: MsaViewModel
14
+ }) {
11
15
  const {
12
16
  actuallyShowDomains,
13
17
  visibleDomainTypes: visible,
@@ -41,7 +45,7 @@ const DomainLegend = observer(function ({ model }: { model: MsaViewModel }) {
41
45
  >
42
46
  <IconButton
43
47
  size="small"
44
- title={expanded ? 'Collapse domain key' : 'Expand domain key'}
48
+ title={expanded ? 'Collapse key' : 'Expand key'}
45
49
  onClick={() => {
46
50
  model.setShowDomainLegend(!expanded)
47
51
  }}
@@ -76,4 +80,4 @@ const DomainLegend = observer(function ({ model }: { model: MsaViewModel }) {
76
80
  ) : null
77
81
  })
78
82
 
79
- export default DomainLegend
83
+ export default AnnotationLegend
@@ -11,12 +11,13 @@ import type { MsaViewModel } from '../../model.ts'
11
11
  const MSACanvas = observer(function ({ model }: { model: MsaViewModel }) {
12
12
  const {
13
13
  MSA,
14
- verticalScrollbarWidth,
15
14
  msaFilehandle,
16
15
  height,
17
- msaAreaWidth,
16
+ msaCanvasWidth,
18
17
  blocks2d,
19
18
  scrollZoom,
19
+ scrollX,
20
+ scrollY,
20
21
  } = model
21
22
  const ref = useRef<HTMLDivElement>(null)
22
23
  const onScrollX = useCallback(
@@ -61,19 +62,31 @@ const MSACanvas = observer(function ({ model }: { model: MsaViewModel }) {
61
62
  style={{
62
63
  position: 'relative',
63
64
  height,
64
- width: msaAreaWidth - verticalScrollbarWidth,
65
+ width: msaCanvasWidth,
65
66
  overflow: 'hidden',
66
67
  }}
67
68
  >
68
69
  {!MSA && !msaFilehandle ? null : MSA ? (
69
- blocks2d.map(([bx, by]) => (
70
- <MSACanvasBlock
71
- key={`${bx}_${by}`}
72
- model={model}
73
- offsetX={bx}
74
- offsetY={by}
75
- />
76
- ))
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>
77
90
  ) : (
78
91
  <Loading />
79
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 { scrollY, scrollX, colorScheme, blockSize, highResScaleFactor } = model
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: scrollY + offsetY,
83
- left: scrollX + offsetX,
92
+ top: offsetY,
93
+ left: offsetX,
84
94
  width: blockSize,
85
95
  height: blockSize,
86
96
  }}
@@ -13,9 +13,7 @@ const MSAMouseoverCanvas = observer(function ({
13
13
  }: {
14
14
  model: MsaViewModel
15
15
  }) {
16
- const { height, msaAreaWidth, verticalScrollbarWidth, highResScaleFactor } =
17
- model
18
- const width = msaAreaWidth - verticalScrollbarWidth
16
+ const { height, msaCanvasWidth: width, highResScaleFactor } = model
19
17
  const canvasWidth = width * highResScaleFactor
20
18
  const canvasHeight = height * highResScaleFactor
21
19
  const ref = useCanvasAutorun({
@@ -2,7 +2,7 @@ import React from 'react'
2
2
 
3
3
  import { observer } from 'mobx-react'
4
4
 
5
- import DomainLegend from './DomainLegend.tsx'
5
+ import AnnotationLegend from './AnnotationLegend.tsx'
6
6
  import MSACanvas from './MSACanvas.tsx'
7
7
  import MSAMouseoverCanvas from './MSAMouseoverCanvas.tsx'
8
8
 
@@ -14,7 +14,7 @@ const MSAPanel = observer(function ({ model }: { model: MsaViewModel }) {
14
14
  <div style={{ position: 'relative' }}>
15
15
  <MSACanvas model={model} />
16
16
  <MSAMouseoverCanvas model={model} />
17
- <DomainLegend model={model} />
17
+ <AnnotationLegend model={model} />
18
18
  </div>
19
19
  )
20
20
  })
@@ -0,0 +1,41 @@
1
+ import type { DomainBand } from '../../types.ts'
2
+
3
+ /**
4
+ * Left-to-right cursor over one row's domain bands, answering "which band is
5
+ * drawn on top at this column" — the fill a letter there has to contrast
6
+ * against.
7
+ *
8
+ * Not simply the first band covering the column. The overlay paints in
9
+ * `domainBands` order, largest first, so where signatures nest — a family
10
+ * enclosing a domain, which InterProScan emits routinely — the box a reader
11
+ * actually sees is the last one painted over that column. `stackIndex` is that
12
+ * paint order.
13
+ *
14
+ * `bands` must be sorted by start column (`domainBandsByStart`), and columns
15
+ * must be visited in increasing order, which is what the block renderer does.
16
+ * Both keep this to one pass over the bands per row rather than a scan per cell.
17
+ */
18
+ export function domainBandCursor(bands: DomainBand[] | undefined) {
19
+ let next = 0
20
+ let active: DomainBand[] = []
21
+
22
+ return (col: number) => {
23
+ if (!bands) {
24
+ return undefined
25
+ }
26
+ while (next < bands.length && bands[next]!.startCol <= col) {
27
+ active.push(bands[next]!)
28
+ next++
29
+ }
30
+ if (active.some(band => band.endCol <= col)) {
31
+ active = active.filter(band => band.endCol > col)
32
+ }
33
+ let top: DomainBand | undefined
34
+ for (const band of active) {
35
+ if (!top || band.stackIndex > top.stackIndex) {
36
+ top = band
37
+ }
38
+ }
39
+ return top
40
+ }
41
+ }
@@ -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
+ }