react-msaview 6.0.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 (118) hide show
  1. package/bundle/index.js +98 -98
  2. package/bundle/index.js.map +4 -4
  3. package/dist/columnCounts.d.ts +7 -0
  4. package/dist/columnCounts.js +18 -0
  5. package/dist/columnCounts.js.map +1 -1
  6. package/dist/components/DragHandle.d.ts +25 -0
  7. package/dist/components/DragHandle.js +49 -0
  8. package/dist/components/DragHandle.js.map +1 -0
  9. package/dist/components/ResizeHandles.d.ts +4 -2
  10. package/dist/components/ResizeHandles.js +29 -32
  11. package/dist/components/ResizeHandles.js.map +1 -1
  12. package/dist/components/VerticalScrollbar.js +2 -2
  13. package/dist/components/VerticalScrollbar.js.map +1 -1
  14. package/dist/components/dialogs/InterProScanDialog.js +52 -129
  15. package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
  16. package/dist/components/header/settingsMenuItems.js +15 -0
  17. package/dist/components/header/settingsMenuItems.js.map +1 -1
  18. package/dist/components/minimap/Minimap.js +32 -4
  19. package/dist/components/minimap/Minimap.js.map +1 -1
  20. package/dist/components/msa/MSACanvas.js +8 -2
  21. package/dist/components/msa/MSACanvas.js.map +1 -1
  22. package/dist/components/msa/MSACanvasBlock.js +12 -3
  23. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  24. package/dist/components/msa/msaRaster.d.ts +63 -0
  25. package/dist/components/msa/msaRaster.js +252 -0
  26. package/dist/components/msa/msaRaster.js.map +1 -0
  27. package/dist/components/msa/renderMSABlock.d.ts +2 -1
  28. package/dist/components/msa/renderMSABlock.js +13 -21
  29. package/dist/components/msa/renderMSABlock.js.map +1 -1
  30. package/dist/components/msa/tileColor.d.ts +6 -0
  31. package/dist/components/msa/tileColor.js +35 -0
  32. package/dist/components/msa/tileColor.js.map +1 -0
  33. package/dist/components/renderCtx.d.ts +1 -1
  34. package/dist/components/tracks/TrackBlocks.d.ts +14 -0
  35. package/dist/components/tracks/TrackBlocks.js +65 -0
  36. package/dist/components/tracks/TrackBlocks.js.map +1 -0
  37. package/dist/components/tracks/{renderTracksSvg.d.ts → drawTracks.d.ts} +22 -10
  38. package/dist/components/tracks/drawTracks.js +193 -0
  39. package/dist/components/tracks/drawTracks.js.map +1 -0
  40. package/dist/components/tree/TreeCanvas.js +8 -2
  41. package/dist/components/tree/TreeCanvas.js.map +1 -1
  42. package/dist/components/tree/TreeCanvasBlock.js +7 -4
  43. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  44. package/dist/components/tree/renderTreeCanvas.js +5 -2
  45. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  46. package/dist/constants.d.ts +3 -0
  47. package/dist/constants.js +8 -0
  48. package/dist/constants.js.map +1 -1
  49. package/dist/model/DataModel.d.ts +1 -4
  50. package/dist/model/DataModel.js +8 -10
  51. package/dist/model/DataModel.js.map +1 -1
  52. package/dist/model.d.ts +36 -7
  53. package/dist/model.js +113 -58
  54. package/dist/model.js.map +1 -1
  55. package/dist/neighborJoining.js +34 -18
  56. package/dist/neighborJoining.js.map +1 -1
  57. package/dist/renderTestEnv.d.ts +43 -0
  58. package/dist/renderTestEnv.js +88 -0
  59. package/dist/renderTestEnv.js.map +1 -0
  60. package/dist/renderToSvg.js +1 -1
  61. package/dist/renderToSvg.js.map +1 -1
  62. package/dist/sequenceLogo.d.ts +28 -0
  63. package/dist/sequenceLogo.js +47 -0
  64. package/dist/sequenceLogo.js.map +1 -0
  65. package/dist/types.d.ts +2 -0
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +3 -3
  69. package/src/columnCounts.test.ts +15 -0
  70. package/src/columnCounts.ts +19 -0
  71. package/src/components/DragHandle.tsx +81 -0
  72. package/src/components/ResizeHandles.tsx +46 -54
  73. package/src/components/VerticalScrollbar.tsx +3 -2
  74. package/src/components/dialogs/InterProScanDialog.tsx +54 -131
  75. package/src/components/header/settingsMenuItems.ts +18 -0
  76. package/src/components/minimap/Minimap.tsx +47 -4
  77. package/src/components/msa/MSACanvas.tsx +30 -10
  78. package/src/components/msa/MSACanvasBlock.tsx +13 -3
  79. package/src/components/msa/msaRaster.test.ts +145 -0
  80. package/src/components/msa/msaRaster.ts +350 -0
  81. package/src/components/msa/renderMSABlock.ts +15 -20
  82. package/src/components/msa/tileColor.ts +37 -0
  83. package/src/components/renderCtx.ts +2 -0
  84. package/src/components/tracks/TrackBlocks.tsx +111 -0
  85. package/src/components/tracks/drawTracks.ts +323 -0
  86. package/src/components/tree/TreeCanvas.tsx +16 -4
  87. package/src/components/tree/TreeCanvasBlock.tsx +18 -11
  88. package/src/components/tree/renderTreeCanvas.ts +5 -1
  89. package/src/constants.ts +10 -0
  90. package/src/impgRender.test.tsx +2 -57
  91. package/src/largeTree.test.ts +16 -2
  92. package/src/model/DataModel.ts +11 -11
  93. package/src/model.ts +128 -60
  94. package/src/neighborJoining.ts +36 -18
  95. package/src/renderDomainsSvg.test.tsx +2 -57
  96. package/src/renderMinimapSvg.test.tsx +2 -56
  97. package/src/renderSequenceLogoSvg.test.tsx +176 -0
  98. package/src/renderTestEnv.ts +96 -0
  99. package/src/renderToSvg.tsx +1 -1
  100. package/src/sequenceLogo.test.ts +88 -0
  101. package/src/sequenceLogo.ts +63 -0
  102. package/src/types.ts +7 -1
  103. package/src/version.ts +1 -1
  104. package/dist/components/ConservationTrack.d.ts +0 -8
  105. package/dist/components/ConservationTrack.js +0 -49
  106. package/dist/components/ConservationTrack.js.map +0 -1
  107. package/dist/components/ScrollbarThumb.d.ts +0 -14
  108. package/dist/components/ScrollbarThumb.js +0 -28
  109. package/dist/components/ScrollbarThumb.js.map +0 -1
  110. package/dist/components/TextTrack.d.ts +0 -8
  111. package/dist/components/TextTrack.js +0 -58
  112. package/dist/components/TextTrack.js.map +0 -1
  113. package/dist/components/tracks/renderTracksSvg.js +0 -126
  114. package/dist/components/tracks/renderTracksSvg.js.map +0 -1
  115. package/src/components/ConservationTrack.tsx +0 -99
  116. package/src/components/ScrollbarThumb.tsx +0 -48
  117. package/src/components/TextTrack.tsx +0 -103
  118. package/src/components/tracks/renderTracksSvg.ts +0 -247
@@ -0,0 +1,111 @@
1
+ import React from 'react'
2
+
3
+ import { observer } from 'mobx-react'
4
+
5
+ import { useCanvasAutorun } from '../../useCanvasAutorun.ts'
6
+ import { useColorContrast } from '../../useColorContrast.ts'
7
+ import { TrackResizeHandle } from '../ResizeHandles.tsx'
8
+ import { drawTrackBlock } from './drawTracks.ts'
9
+
10
+ import type { MsaViewModel } from '../../model.ts'
11
+ import type { BasicTrack } from '../../types.ts'
12
+
13
+ const TrackBlock = observer(function ({
14
+ model,
15
+ track,
16
+ offsetX,
17
+ }: {
18
+ model: MsaViewModel
19
+ track: BasicTrack
20
+ offsetX: number
21
+ }) {
22
+ const { blockSize, colorScheme, highResScaleFactor } = model
23
+ const { height, customColorScheme } = track.model
24
+ const { theme, contrastScheme } = useColorContrast(
25
+ customColorScheme ?? colorScheme,
26
+ )
27
+ const canvasWidth = blockSize * highResScaleFactor
28
+ const canvasHeight = height * highResScaleFactor
29
+
30
+ const ref = useCanvasAutorun({
31
+ draw: ctx => {
32
+ ctx.resetTransform()
33
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight)
34
+ drawTrackBlock({ model, ctx, track, offsetX, theme, contrastScheme })
35
+ },
36
+ width: canvasWidth,
37
+ height: canvasHeight,
38
+ deps: [model, track, offsetX, theme, contrastScheme],
39
+ })
40
+
41
+ return (
42
+ <canvas
43
+ ref={ref}
44
+ width={canvasWidth}
45
+ height={canvasHeight}
46
+ style={{
47
+ position: 'absolute',
48
+ left: offsetX,
49
+ width: blockSize,
50
+ height,
51
+ }}
52
+ />
53
+ )
54
+ })
55
+
56
+ /**
57
+ * The canvas blocks covering one track's row of the alignment. Every track kind
58
+ * shares this host: what differs between conservation, sequence logo and the
59
+ * Stockholm text tracks is which draw function `drawTrackBlock` dispatches to,
60
+ * not how the blocks are laid out or kept in sync with the horizontal scroll.
61
+ */
62
+ const TrackBlocks = observer(function ({
63
+ model,
64
+ track,
65
+ }: {
66
+ model: MsaViewModel
67
+ track: BasicTrack
68
+ }) {
69
+ // msaCanvasWidth, not msaAreaWidth: a track row is a legend for the columns
70
+ // directly below it, so it has to stop where the alignment canvas does rather
71
+ // than running on another 20px under the vertical scrollbar -- otherwise the
72
+ // last columns appear in the tracks but not in the alignment, and the hovered
73
+ // -column indicator (sized to the canvas) stops short of the track it crosses
74
+ const { blocksX, msaCanvasWidth, scrollX } = model
75
+ const { kind, height, data } = track.model
76
+
77
+ // a text track with no data draws nothing, and an empty row of its own would
78
+ // still take up vertical space
79
+ if (kind === 'text' && !data) {
80
+ return null
81
+ }
82
+
83
+ return (
84
+ <div
85
+ style={{
86
+ position: 'relative',
87
+ height,
88
+ width: msaCanvasWidth,
89
+ overflow: 'hidden',
90
+ }}
91
+ >
92
+ {/* one transform for the whole block set, matching MSACanvas */}
93
+ <div
94
+ style={{
95
+ position: 'absolute',
96
+ top: 0,
97
+ left: 0,
98
+ transform: `translateX(${scrollX}px)`,
99
+ willChange: 'transform',
100
+ }}
101
+ >
102
+ {blocksX.map(bx => (
103
+ <TrackBlock key={bx} model={model} track={track} offsetX={bx} />
104
+ ))}
105
+ </div>
106
+ <TrackResizeHandle model={model} kind={kind} />
107
+ </div>
108
+ )
109
+ })
110
+
111
+ export default TrackBlocks
@@ -0,0 +1,323 @@
1
+ import { columnLogoStack } from '../../sequenceLogo.ts'
2
+ import { setFontSize } from '../../setFontSize.ts'
3
+ import { visibleColRange } from '../msa/visibleColRange.ts'
4
+
5
+ import type { ColumnCounts } from '../../columnCounts.ts'
6
+ import type { MsaViewModel } from '../../model.ts'
7
+ import type { BasicTrack } from '../../types.ts'
8
+ import type { RenderCtx } from '../renderCtx.ts'
9
+ import type { Theme } from '@mui/material'
10
+
11
+ export function drawConservationBars({
12
+ ctx,
13
+ values,
14
+ color,
15
+ colWidth,
16
+ trackHeight,
17
+ offsetX,
18
+ blockSize,
19
+ }: {
20
+ ctx: RenderCtx
21
+ values: number[]
22
+ color: string
23
+ colWidth: number
24
+ trackHeight: number
25
+ offsetX: number
26
+ blockSize: number
27
+ }) {
28
+ const { xStart, xEnd } = visibleColRange({
29
+ offsetX,
30
+ blockWidth: blockSize,
31
+ colWidth,
32
+ })
33
+
34
+ ctx.fillStyle = color
35
+ for (let i = xStart; i < xEnd && i < values.length; i++) {
36
+ const value = values[i]!
37
+ const barHeight = value * trackHeight
38
+ const x = i * colWidth
39
+ ctx.fillRect(x, trackHeight - barHeight, colWidth, barHeight)
40
+ }
41
+ }
42
+
43
+ // the bar-track values live on model getters (kept off the plain track object so
44
+ // the live canvas autorun stays reactive); select the right one by track id
45
+ export function barTrackValues(model: MsaViewModel, trackId: string) {
46
+ return trackId === 'property-conservation'
47
+ ? model.propertyConservation
48
+ : model.conservation
49
+ }
50
+
51
+ export function drawTextTrackContent({
52
+ ctx,
53
+ data,
54
+ colorScheme,
55
+ contrastScheme,
56
+ bgColor,
57
+ textColor,
58
+ colWidth,
59
+ rowHeight,
60
+ offsetX,
61
+ blockSize,
62
+ }: {
63
+ ctx: RenderCtx
64
+ data: string | undefined
65
+ colorScheme: Record<string, string>
66
+ contrastScheme: Record<string, string>
67
+ bgColor: boolean
68
+ textColor: string
69
+ colWidth: number
70
+ rowHeight: number
71
+ offsetX: number
72
+ blockSize: number
73
+ }) {
74
+ const { xStart, xEnd } = visibleColRange({
75
+ offsetX,
76
+ blockWidth: blockSize,
77
+ colWidth,
78
+ })
79
+ const str = data?.slice(xStart, xEnd)
80
+ const drawLetters = rowHeight >= 10 && colWidth >= rowHeight / 2
81
+
82
+ for (let i = 0; str && i < str.length; i++) {
83
+ const letter = str[i]!
84
+ const upper = letter.toUpperCase()
85
+ const color = colorScheme[upper]
86
+ const x = (xStart + i) * colWidth
87
+ const filled = bgColor && !!color
88
+ if (filled) {
89
+ ctx.fillStyle = color!
90
+ ctx.fillRect(x, 0, colWidth, rowHeight)
91
+ }
92
+ if (drawLetters) {
93
+ // a letter on a colored tile needs the tile's contrast color; otherwise it
94
+ // sits on the plain background and takes the theme's text color
95
+ ctx.fillStyle = filled ? (contrastScheme[upper] ?? 'black') : textColor
96
+ ctx.fillText(letter, x + colWidth / 2, rowHeight / 2 + 1)
97
+ }
98
+ }
99
+ }
100
+
101
+ // Cap height as a fraction of the font size. Every logo letter is scaled from
102
+ // one reference font size to the height its frequency earns, and that scale is
103
+ // only right if we know how tall the glyph actually draws. Measuring per letter
104
+ // would be exact but `measureText` reports no vertical box in the SVG export
105
+ // backend, so the ratio is a constant: the cap height of the sans-serif faces
106
+ // both backends use, within a percent or two. It only has to be consistent --
107
+ // the same ratio for every letter is what makes the slices sum to the stack
108
+ // height instead of drifting apart.
109
+ const CAP_HEIGHT_RATIO = 0.72
110
+ // letters below this many pixels are noise, and a stack that thin reads better
111
+ // as the bar chart it approximates
112
+ const MIN_LETTER_PX = 3
113
+
114
+ export function drawSequenceLogo({
115
+ ctx,
116
+ colStats,
117
+ colorScheme,
118
+ maxBits,
119
+ textColor,
120
+ colWidth,
121
+ trackHeight,
122
+ offsetX,
123
+ blockSize,
124
+ }: {
125
+ ctx: RenderCtx
126
+ colStats: ColumnCounts
127
+ colorScheme: Record<string, string>
128
+ maxBits: number
129
+ textColor: string
130
+ colWidth: number
131
+ trackHeight: number
132
+ offsetX: number
133
+ blockSize: number
134
+ }) {
135
+ const { xStart, xEnd } = visibleColRange({
136
+ offsetX,
137
+ blockWidth: blockSize,
138
+ colWidth,
139
+ })
140
+ const end = Math.min(xEnd, colStats.numColumns)
141
+
142
+ // one reference size for every glyph; each letter's own transform takes it
143
+ // from here to the height it earned, so the font is set once for the block
144
+ const referenceFontSize = trackHeight
145
+ setFontSize(ctx, referenceFontSize)
146
+ ctx.textAlign = 'left'
147
+ ctx.textBaseline = 'alphabetic'
148
+ const capHeight = referenceFontSize * CAP_HEIGHT_RATIO
149
+
150
+ for (let col = xStart; col < end; col++) {
151
+ const stack = columnLogoStack(colStats, col, maxBits)
152
+ if (stack.length === 0) {
153
+ continue
154
+ }
155
+ const x = col * colWidth
156
+ // stack up from the baseline: the array is ascending, so the tallest letter
157
+ // lands on top, which is the convention that makes a logo readable at a
158
+ // glance
159
+ let bottom = trackHeight
160
+ for (const { letter, bits } of stack) {
161
+ const h = (bits / maxBits) * trackHeight
162
+ if (h < MIN_LETTER_PX) {
163
+ bottom -= h
164
+ continue
165
+ }
166
+ const width = ctx.measureText(letter).width
167
+ if (width === 0) {
168
+ bottom -= h
169
+ continue
170
+ }
171
+ ctx.fillStyle = colorScheme[letter] ?? textColor
172
+ ctx.save()
173
+ // land the glyph's baseline on the bottom of its slice, then stretch it to
174
+ // fill the slice vertically and the column horizontally
175
+ ctx.translate(x, bottom)
176
+ ctx.scale(colWidth / width, h / capHeight)
177
+ ctx.fillText(letter, 0, 0)
178
+ ctx.restore()
179
+ bottom -= h
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Draw one track's content into a block of the alignment's column space, at
186
+ * `offsetY` within the ctx. Owns the transform, so both callers -- the live
187
+ * canvas blocks and the SVG export -- draw into the same coordinate space, and
188
+ * adding a track kind means a `kind`, a draw function above, and a case here.
189
+ */
190
+ export function drawTrackBlock({
191
+ model,
192
+ ctx,
193
+ track,
194
+ offsetX,
195
+ offsetY = 0,
196
+ theme,
197
+ contrastScheme,
198
+ blockSizeXOverride,
199
+ highResScaleFactorOverride,
200
+ }: {
201
+ model: MsaViewModel
202
+ ctx: RenderCtx
203
+ track: BasicTrack
204
+ offsetX: number
205
+ offsetY?: number
206
+ theme: Theme
207
+ contrastScheme: Record<string, string>
208
+ blockSizeXOverride?: number
209
+ highResScaleFactorOverride?: number
210
+ }) {
211
+ const {
212
+ blockSize,
213
+ bgColor,
214
+ colWidth,
215
+ colStats,
216
+ colorScheme: modelColorScheme,
217
+ fontSize,
218
+ rowHeight,
219
+ alphabetMaxBits,
220
+ highResScaleFactor,
221
+ } = model
222
+ const {
223
+ id,
224
+ kind,
225
+ height: trackHeight,
226
+ barColor,
227
+ customColorScheme,
228
+ data,
229
+ } = track.model
230
+ const blockSizeX = blockSizeXOverride ?? blockSize
231
+ const k = highResScaleFactorOverride ?? highResScaleFactor
232
+ const textColor = theme.palette.text.primary
233
+
234
+ ctx.resetTransform()
235
+ ctx.scale(k, k)
236
+ ctx.translate(-offsetX, offsetY)
237
+
238
+ switch (kind) {
239
+ case 'bar': {
240
+ drawConservationBars({
241
+ ctx,
242
+ values: barTrackValues(model, id),
243
+ color: barColor ?? 'gray',
244
+ colWidth,
245
+ trackHeight,
246
+ offsetX,
247
+ blockSize: blockSizeX,
248
+ })
249
+ break
250
+ }
251
+ case 'logo': {
252
+ drawSequenceLogo({
253
+ ctx,
254
+ colStats,
255
+ colorScheme: modelColorScheme,
256
+ maxBits: alphabetMaxBits,
257
+ textColor,
258
+ colWidth,
259
+ trackHeight,
260
+ offsetX,
261
+ blockSize: blockSizeX,
262
+ })
263
+ break
264
+ }
265
+ case 'text': {
266
+ ctx.textAlign = 'center'
267
+ setFontSize(ctx, fontSize)
268
+ drawTextTrackContent({
269
+ ctx,
270
+ data,
271
+ colorScheme: customColorScheme ?? modelColorScheme,
272
+ contrastScheme,
273
+ bgColor,
274
+ textColor,
275
+ colWidth,
276
+ rowHeight,
277
+ offsetX,
278
+ blockSize: blockSizeX,
279
+ })
280
+ break
281
+ }
282
+ }
283
+
284
+ ctx.resetTransform()
285
+ }
286
+
287
+ /**
288
+ * Every turned-on track stacked top to bottom, for the SVG export. The live
289
+ * view draws the same content one canvas block at a time -- see TrackBlocks.
290
+ */
291
+ export function renderAllTracks({
292
+ model,
293
+ ctx,
294
+ offsetX,
295
+ contrastScheme,
296
+ theme,
297
+ blockSizeXOverride,
298
+ highResScaleFactorOverride,
299
+ }: {
300
+ model: MsaViewModel
301
+ ctx: RenderCtx
302
+ offsetX: number
303
+ contrastScheme: Record<string, string>
304
+ theme: Theme
305
+ blockSizeXOverride?: number
306
+ highResScaleFactorOverride?: number
307
+ }) {
308
+ let currentY = 0
309
+ for (const track of model.turnedOnTracks) {
310
+ drawTrackBlock({
311
+ model,
312
+ ctx,
313
+ track,
314
+ offsetX,
315
+ offsetY: currentY,
316
+ theme,
317
+ contrastScheme,
318
+ blockSizeXOverride,
319
+ highResScaleFactorOverride,
320
+ })
321
+ currentY += track.model.height
322
+ }
323
+ }
@@ -13,7 +13,7 @@ import type { MsaViewModel } from '../../model.ts'
13
13
 
14
14
  const TreeCanvas = observer(function ({ model }: { model: MsaViewModel }) {
15
15
  const ref = useRef<HTMLDivElement>(null)
16
- const { treeWidth, height, blocksY, treeAreaWidth } = model
16
+ const { treeWidth, height, blocksY, treeAreaWidth, scrollY } = model
17
17
  const onScrollY = useCallback(
18
18
  (d: number) => {
19
19
  model.doScrollY(d)
@@ -80,9 +80,21 @@ const TreeCanvas = observer(function ({ model }: { model: MsaViewModel }) {
80
80
  width: treeWidth + padding,
81
81
  }}
82
82
  >
83
- {blocksY.map(block => (
84
- <TreeCanvasBlock key={block} model={model} offsetY={block} />
85
- ))}
83
+ {/* one transform for the whole block set, matching MSACanvas: the tree
84
+ scrolls in lockstep with the alignment and on the same compositor move */}
85
+ <div
86
+ style={{
87
+ position: 'absolute',
88
+ top: 0,
89
+ left: 0,
90
+ transform: `translateY(${scrollY}px)`,
91
+ willChange: 'transform',
92
+ }}
93
+ >
94
+ {blocksY.map(block => (
95
+ <TreeCanvasBlock key={block} model={model} offsetY={block} />
96
+ ))}
97
+ </div>
86
98
  <canvas
87
99
  ref={mouseoverRef}
88
100
  width={treeAreaWidth}
@@ -3,6 +3,7 @@ import React, { useState } from 'react'
3
3
  import { makeStyles } from '@jbrowse/core/util/tss-react'
4
4
  import { useTheme } from '@mui/material'
5
5
  import { observer } from 'mobx-react'
6
+ import { createPortal } from 'react-dom'
6
7
 
7
8
  import { useCanvasAutorun } from '../../useCanvasAutorun.ts'
8
9
  import TreeBranchMenu from './TreeBranchMenu.tsx'
@@ -56,7 +57,7 @@ const TreeCanvasBlock = observer(function ({
56
57
  const { clickMap, hovered, hitTest, onMouseMove, onMouseLeave } =
57
58
  useTreeHover({ model, offsetY })
58
59
 
59
- const { scrollY, treeAreaWidth, blockSize, highResScaleFactor } = model
60
+ const { treeAreaWidth, blockSize, highResScaleFactor } = model
60
61
  const width = treeAreaWidth + padding
61
62
  const height = blockSize
62
63
 
@@ -82,7 +83,7 @@ const TreeCanvasBlock = observer(function ({
82
83
  const style = {
83
84
  width,
84
85
  height,
85
- top: scrollY + offsetY,
86
+ top: offsetY,
86
87
  left: 0,
87
88
  position: 'absolute',
88
89
  } as const
@@ -144,21 +145,27 @@ const TreeCanvasBlock = observer(function ({
144
145
  className={classes.hover}
145
146
  style={{
146
147
  left: hovered.minX,
147
- top: hovered.minY + scrollY,
148
+ top: hovered.minY,
148
149
  width: hovered.maxX - hovered.minX,
149
150
  height: hovered.maxY - hovered.minY,
150
151
  }}
151
152
  />
152
153
  ) : null}
153
154
 
154
- {hovered ? (
155
- <div
156
- className={classes.tooltip}
157
- style={{ left: hovered.clientX + 12, top: hovered.clientY + 12 }}
158
- >
159
- {hovered.name}
160
- </div>
161
- ) : null}
155
+ {/* portaled: the block set sits under a transform, which would otherwise
156
+ make this fixed tooltip position against the scrolled tree instead of the
157
+ window */}
158
+ {hovered
159
+ ? createPortal(
160
+ <div
161
+ className={classes.tooltip}
162
+ style={{ left: hovered.clientX + 12, top: hovered.clientY + 12 }}
163
+ >
164
+ {hovered.name}
165
+ </div>,
166
+ document.body,
167
+ )
168
+ : null}
162
169
  </>
163
170
  )
164
171
  })
@@ -264,6 +264,7 @@ function renderTreeLabels({
264
264
  marginLeft,
265
265
  noTree,
266
266
  labelWidthMap,
267
+ labelWidthScale,
267
268
  } = model
268
269
  const by = blockSizeYOverride ?? blockSize
269
270
  // labels only exist for leaves, which are laid out top to bottom, so take the
@@ -295,8 +296,11 @@ function renderTreeLabels({
295
296
  xp = getNodeX(node, showBranchLen, tipX, maxDepthToLeaf) ?? 0
296
297
  }
297
298
 
299
+ const measured = labelWidthMap.get(name)
298
300
  const width =
299
- labelWidthMap.get(name) ?? ctx.measureText(displayName).width
301
+ measured === undefined
302
+ ? ctx.measureText(displayName).width
303
+ : measured * labelWidthScale
300
304
 
301
305
  ctx.fillStyle = theme.palette.text.primary
302
306
  if (labelsAlignRight) {
package/src/constants.ts CHANGED
@@ -31,6 +31,16 @@ export const segmentShades = ['#9fb6d4', '#d4dcea']
31
31
  export const defaultDrawMsaLetters = true
32
32
  export const defaultScrollZoom = false
33
33
 
34
+ // Cell size floors for drawing residue letters and tree labels. Below these a
35
+ // 500px block holds thousands of glyphs, and fillText -- which no sprite atlas
36
+ // beats, measured -- dominates every zoom frame.
37
+ export const minLetterRowHeight = 8
38
+ export const minLetterColWidth = 5
39
+
40
+ // Tree labels are measured once at this size and scaled to the current font
41
+ // size, so a vertical zoom never re-measures the tree.
42
+ export const labelReferenceFontSize = 16
43
+
34
44
  // Zoom limits, px per cell. maxCellSize caps both smooth zoom and the stepwise
35
45
  // zoom-in buttons (which otherwise grow unbounded). minColWidth/minRowHeight are
36
46
  // the smooth-zoom floors.
@@ -12,67 +12,12 @@ import { enableStaticRendering } from 'mobx-react'
12
12
  import { beforeAll, expect, test } from 'vitest'
13
13
 
14
14
  import MSAModelF from './model.ts'
15
+ import { installRenderTestEnv } from './renderTestEnv.ts'
15
16
  import { renderToSvg } from './renderToSvg.tsx'
16
17
 
17
- class Mat {
18
- constructor(
19
- public a = 1,
20
- public b = 0,
21
- public c = 0,
22
- public d = 1,
23
- public e = 0,
24
- public f = 0,
25
- ) {}
26
- multiply(o: Mat) {
27
- return new Mat(
28
- this.a * o.a + this.c * o.b,
29
- this.b * o.a + this.d * o.b,
30
- this.a * o.c + this.c * o.d,
31
- this.b * o.c + this.d * o.d,
32
- this.a * o.e + this.c * o.f + this.e,
33
- this.b * o.e + this.d * o.f + this.f,
34
- )
35
- }
36
- translate(x: number, y = 0) {
37
- return this.multiply(new Mat(1, 0, 0, 1, x, y))
38
- }
39
- scale(x: number, y = x) {
40
- return this.multiply(new Mat(x, 0, 0, y, 0, 0))
41
- }
42
- }
43
- class Pt {
44
- constructor(
45
- public x = 0,
46
- public y = 0,
47
- ) {}
48
- matrixTransform(m: Mat) {
49
- return new Pt(
50
- m.a * this.x + m.c * this.y + m.e,
51
- m.b * this.x + m.d * this.y + m.f,
52
- )
53
- }
54
- }
55
-
56
18
  beforeAll(() => {
57
19
  enableStaticRendering(true)
58
- const g = globalThis as Record<string, unknown>
59
- g.DOMMatrix = Mat
60
- g.DOMPoint = Pt
61
- HTMLCanvasElement.prototype.getContext = function () {
62
- let font = '10px sans-serif'
63
- return {
64
- get font() {
65
- return font
66
- },
67
- set font(v: string) {
68
- font = v
69
- },
70
- measureText(t: string) {
71
- const size = Number.parseFloat(font) || 10
72
- return { width: t.length * size * 0.6 } as TextMetrics
73
- },
74
- } as unknown as CanvasRenderingContext2D
75
- } as unknown as typeof HTMLCanvasElement.prototype.getContext
20
+ installRenderTestEnv()
76
21
  })
77
22
 
78
23
  // representative `impg query -o fasta-aln` block: PanSN names with region
@@ -26,8 +26,9 @@ test('a tree far past the argument limit still lays out', () => {
26
26
  model.setWidth(1000)
27
27
 
28
28
  expect(model.numRows).toBe(200_000)
29
- // 's199999' is the longest name, at 7 chars * 7px
30
- expect(model.labelsWidth).toBe(49)
29
+ // 's199999' is the longest name, at 7 chars * 7px, scaled from the reference
30
+ // font size the labels are measured at down to the current one (13/16)
31
+ expect(model.labelsWidth).toBeCloseTo((49 * 13) / 16)
31
32
  expect(model.treeWidth).toBeGreaterThan(0)
32
33
  })
33
34
 
@@ -51,3 +52,16 @@ test('re-hovering the same tree node does not re-walk the tree', () => {
51
52
  model.setHoveredTreeNode(undefined)
52
53
  expect(model.hoveredTreeNode).toBeUndefined()
53
54
  })
55
+
56
+ test('a vertical zoom does not re-measure every label', () => {
57
+ const model = MSAModelF().create({
58
+ type: 'MsaView',
59
+ data: { tree: flatNewick(200_000) },
60
+ })
61
+ model.setWidth(1000)
62
+
63
+ const before = model.labelWidthMap
64
+ model.setRowHeight(24)
65
+ expect(model.labelWidthMap).toBe(before)
66
+ expect(model.labelsWidth).toBeCloseTo((49 * 18) / 16)
67
+ })
@@ -53,15 +53,15 @@ export function DataModelF() {
53
53
  self.gff = gff
54
54
  },
55
55
  }))
56
- .postProcessSnapshot(snap => {
57
- const { tree, msa, treeMetadata, gff } = snap
58
- const max = 50_000
59
- return {
60
- tree: tree && tree.length > max ? undefined : tree,
61
- msa: msa && msa.length > max ? undefined : msa,
62
- treeMetadata:
63
- treeMetadata && treeMetadata.length > max ? undefined : treeMetadata,
64
- gff: gff && gff.length > max ? undefined : gff,
65
- }
66
- })
56
+ .postProcessSnapshot(snap =>
57
+ // a large document is dropped from the snapshot rather than inlined
58
+ // into a session or a shared URL, and every field here holds one, so
59
+ // the rule is the same for all of them
60
+ Object.fromEntries(
61
+ Object.entries(snap).map(([key, text]) => [
62
+ key,
63
+ text && text.length > 50_000 ? undefined : text,
64
+ ]),
65
+ ),
66
+ )
67
67
  }