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.
Files changed (122) 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 +16 -1
  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 +27 -21
  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/treeModel.d.ts +1 -1
  53. package/dist/model/treeModel.js +1 -1
  54. package/dist/model.d.ts +36 -7
  55. package/dist/model.js +113 -58
  56. package/dist/model.js.map +1 -1
  57. package/dist/neighborJoining.js +34 -18
  58. package/dist/neighborJoining.js.map +1 -1
  59. package/dist/renderTestEnv.d.ts +43 -0
  60. package/dist/renderTestEnv.js +88 -0
  61. package/dist/renderTestEnv.js.map +1 -0
  62. package/dist/renderToSvg.js +1 -1
  63. package/dist/renderToSvg.js.map +1 -1
  64. package/dist/sequenceLogo.d.ts +28 -0
  65. package/dist/sequenceLogo.js +47 -0
  66. package/dist/sequenceLogo.js.map +1 -0
  67. package/dist/types.d.ts +2 -0
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/package.json +3 -3
  71. package/src/columnCounts.test.ts +15 -0
  72. package/src/columnCounts.ts +19 -0
  73. package/src/components/DragHandle.tsx +81 -0
  74. package/src/components/ResizeHandles.tsx +46 -54
  75. package/src/components/VerticalScrollbar.tsx +3 -2
  76. package/src/components/dialogs/InterProScanDialog.tsx +54 -131
  77. package/src/components/header/settingsMenuItems.ts +19 -1
  78. package/src/components/minimap/Minimap.tsx +47 -4
  79. package/src/components/msa/MSACanvas.tsx +30 -10
  80. package/src/components/msa/MSACanvasBlock.tsx +13 -3
  81. package/src/components/msa/msaRaster.test.ts +145 -0
  82. package/src/components/msa/msaRaster.ts +350 -0
  83. package/src/components/msa/renderMSABlock.ts +15 -20
  84. package/src/components/msa/tileColor.ts +37 -0
  85. package/src/components/renderCtx.ts +2 -0
  86. package/src/components/tracks/TrackBlocks.tsx +111 -0
  87. package/src/components/tracks/drawTracks.ts +323 -0
  88. package/src/components/tree/TreeCanvas.tsx +16 -4
  89. package/src/components/tree/TreeCanvasBlock.tsx +18 -11
  90. package/src/components/tree/renderTreeCanvas.test.ts +55 -0
  91. package/src/components/tree/renderTreeCanvas.ts +28 -19
  92. package/src/constants.ts +10 -0
  93. package/src/impgRender.test.tsx +2 -57
  94. package/src/largeTree.test.ts +16 -2
  95. package/src/model/DataModel.ts +11 -11
  96. package/src/model/treeModel.ts +1 -1
  97. package/src/model.ts +128 -60
  98. package/src/neighborJoining.ts +36 -18
  99. package/src/renderDomainsSvg.test.tsx +2 -57
  100. package/src/renderMinimapSvg.test.tsx +2 -56
  101. package/src/renderSequenceLogoSvg.test.tsx +176 -0
  102. package/src/renderTestEnv.ts +96 -0
  103. package/src/renderToSvg.tsx +1 -1
  104. package/src/sequenceLogo.test.ts +88 -0
  105. package/src/sequenceLogo.ts +63 -0
  106. package/src/types.ts +7 -1
  107. package/src/version.ts +1 -1
  108. package/dist/components/ConservationTrack.d.ts +0 -8
  109. package/dist/components/ConservationTrack.js +0 -49
  110. package/dist/components/ConservationTrack.js.map +0 -1
  111. package/dist/components/ScrollbarThumb.d.ts +0 -14
  112. package/dist/components/ScrollbarThumb.js +0 -28
  113. package/dist/components/ScrollbarThumb.js.map +0 -1
  114. package/dist/components/TextTrack.d.ts +0 -8
  115. package/dist/components/TextTrack.js +0 -58
  116. package/dist/components/TextTrack.js.map +0 -1
  117. package/dist/components/tracks/renderTracksSvg.js +0 -126
  118. package/dist/components/tracks/renderTracksSvg.js.map +0 -1
  119. package/src/components/ConservationTrack.tsx +0 -99
  120. package/src/components/ScrollbarThumb.tsx +0 -48
  121. package/src/components/TextTrack.tsx +0 -103
  122. package/src/components/tracks/renderTracksSvg.ts +0 -247
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import { calcDepthToLeaf, findMaxBranchLen } from '../../hierarchy.ts'
4
4
  import stateModelFactory from '../../model.ts'
5
+ import { ClickMapIndex } from './clickMap.ts'
5
6
  import { getNodeX, renderTreeCanvas } from './renderTreeCanvas.ts'
6
7
 
7
8
  import type { HierarchyNode } from '../../hierarchy.ts'
@@ -174,3 +175,57 @@ describe('renderTreeCanvas horizontal extent', () => {
174
175
  expect(model.treeWidth).toBe(model.treeAreaWidth - 10 - model.marginLeft)
175
176
  })
176
177
  })
178
+
179
+ describe('node bubble click targets', () => {
180
+ const bounds = { minX: 0, minY: 0, maxX: 10000, maxY: 10000 }
181
+
182
+ // "draw clickable bubbles" only controls the painting; the branches have to
183
+ // stay clickable with it off
184
+ function renderWithBubbles(drawNodeBubbles: boolean) {
185
+ const model = stateModelFactory().create({
186
+ type: 'MsaView',
187
+ data: { msa: '>a\nA\n>b\nA\n>c\nA\n>d\nA', tree: '((a,b),(c,d));' },
188
+ })
189
+ model.setWidth(1000)
190
+ model.setDrawLabels(false)
191
+ model.setDrawNodeBubbles(drawNodeBubbles)
192
+
193
+ let arcs = 0
194
+ const ctx = {
195
+ font: '12px sans-serif',
196
+ beginPath() {},
197
+ stroke() {},
198
+ fill() {},
199
+ resetTransform() {},
200
+ scale() {},
201
+ translate() {},
202
+ moveTo() {},
203
+ lineTo() {},
204
+ arc() {
205
+ arcs++
206
+ },
207
+ } as unknown as RenderCtx
208
+
209
+ const clickMap = new ClickMapIndex()
210
+ renderTreeCanvas({
211
+ model,
212
+ ctx,
213
+ clickMap,
214
+ offsetY: 0,
215
+ theme: { palette: { text: { primary: '#000' } } } as Theme,
216
+ })
217
+ return { arcs, hits: clickMap.search(bounds) }
218
+ }
219
+
220
+ it('indexes the internal nodes when the bubbles are drawn', () => {
221
+ const { arcs, hits } = renderWithBubbles(true)
222
+ expect(arcs).toBe(3)
223
+ expect(hits.filter(h => h.branch).length).toBe(3)
224
+ })
225
+
226
+ it('indexes them just the same when the bubbles are off', () => {
227
+ const { arcs, hits } = renderWithBubbles(false)
228
+ expect(arcs).toBe(0)
229
+ expect(hits.filter(h => h.branch).length).toBe(3)
230
+ })
231
+ })
@@ -171,6 +171,8 @@ function renderCollapsedTriangles({
171
171
  })
172
172
  }
173
173
 
174
+ // the bubbles are click targets whether or not they are painted, so this pass
175
+ // always runs and `draw` only decides whether it also strokes the circles
174
176
  function renderNodeBubbles({
175
177
  ctx,
176
178
  clickMap,
@@ -180,6 +182,7 @@ function renderNodeBubbles({
180
182
  maxDepthToLeaf,
181
183
  blockSizeYOverride,
182
184
  collapsedSet,
185
+ draw,
183
186
  }: {
184
187
  ctx: RenderCtx
185
188
  clickMap?: ClickMapIndex
@@ -189,6 +192,7 @@ function renderNodeBubbles({
189
192
  maxDepthToLeaf: number
190
193
  blockSizeYOverride?: number
191
194
  collapsedSet: Set<string>
195
+ draw: boolean
192
196
  }) {
193
197
  const {
194
198
  hierarchy,
@@ -207,12 +211,14 @@ function renderNodeBubbles({
207
211
  const { id, name } = data
208
212
  if (node.height >= 1 && inYBlock(y, offsetY, by)) {
209
213
  const isCollapsed = collapsedSet.has(id)
210
- ctx.strokeStyle = 'black'
211
- ctx.fillStyle = isCollapsed ? 'black' : 'white'
212
- ctx.beginPath()
213
- ctx.arc(x, y, radius, 0, 2 * Math.PI)
214
- ctx.fill()
215
- ctx.stroke()
214
+ if (draw) {
215
+ ctx.strokeStyle = 'black'
216
+ ctx.fillStyle = isCollapsed ? 'black' : 'white'
217
+ ctx.beginPath()
218
+ ctx.arc(x, y, radius, 0, 2 * Math.PI)
219
+ ctx.fill()
220
+ ctx.stroke()
221
+ }
216
222
 
217
223
  // collapsed nodes get their click/hover target from the triangle pass,
218
224
  // which covers the apex bubble and carries a descriptive label
@@ -264,6 +270,7 @@ function renderTreeLabels({
264
270
  marginLeft,
265
271
  noTree,
266
272
  labelWidthMap,
273
+ labelWidthScale,
267
274
  } = model
268
275
  const by = blockSizeYOverride ?? blockSize
269
276
  // labels only exist for leaves, which are laid out top to bottom, so take the
@@ -295,8 +302,11 @@ function renderTreeLabels({
295
302
  xp = getNodeX(node, showBranchLen, tipX, maxDepthToLeaf) ?? 0
296
303
  }
297
304
 
305
+ const measured = labelWidthMap.get(name)
298
306
  const width =
299
- labelWidthMap.get(name) ?? ctx.measureText(displayName).width
307
+ measured === undefined
308
+ ? ctx.measureText(displayName).width
309
+ : measured * labelWidthScale
300
310
 
301
311
  ctx.fillStyle = theme.palette.text.primary
302
312
  if (labelsAlignRight) {
@@ -413,18 +423,17 @@ export function renderTreeCanvas({
413
423
  collapsedSet,
414
424
  })
415
425
 
416
- if (drawNodeBubbles) {
417
- renderNodeBubbles({
418
- ctx,
419
- offsetY,
420
- clickMap,
421
- model,
422
- tipX,
423
- maxDepthToLeaf,
424
- blockSizeYOverride,
425
- collapsedSet,
426
- })
427
- }
426
+ renderNodeBubbles({
427
+ ctx,
428
+ offsetY,
429
+ clickMap,
430
+ model,
431
+ tipX,
432
+ maxDepthToLeaf,
433
+ blockSizeYOverride,
434
+ collapsedSet,
435
+ draw: drawNodeBubbles,
436
+ })
428
437
  }
429
438
 
430
439
  if (showTreeText) {
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
  }
@@ -56,7 +56,7 @@ export function TreeModelF() {
56
56
 
57
57
  /**
58
58
  * #property
59
- * draw clickable node bubbles on the tree
59
+ * draw node bubbles on the tree; the branches stay clickable either way
60
60
  */
61
61
  drawNodeBubbles: stripDefault(types.boolean, defaultDrawNodeBubbles),
62
62
 
package/src/model.ts CHANGED
@@ -18,8 +18,7 @@ import { calculateBlocks } from './calculateBlocks.ts'
18
18
  import { clustalXColumnColors } from './clustalX.ts'
19
19
  import colorSchemes from './colorSchemes.ts'
20
20
  import { columnCountsFromRows, letterOfResidueSlot } from './columnCounts.ts'
21
- import ConservationTrack from './components/ConservationTrack.tsx'
22
- import TextTrack from './components/TextTrack.tsx'
21
+ import TrackBlocks from './components/tracks/TrackBlocks.tsx'
23
22
  import {
24
23
  defaultAllowedGappyness,
25
24
  defaultColWidth,
@@ -35,8 +34,11 @@ import {
35
34
  defaultShowDomainLegend,
36
35
  defaultShowDomains,
37
36
  defaultSubFeatureRows,
37
+ labelReferenceFontSize,
38
38
  maxCellSize,
39
39
  minColWidth,
40
+ minLetterColWidth,
41
+ minLetterRowHeight,
40
42
  minRowHeight,
41
43
  segmentFeatureTypes,
42
44
  segmentShades,
@@ -75,6 +77,7 @@ import {
75
77
  visibleColsBefore,
76
78
  } from './rowCoordinateCalculations.ts'
77
79
  import { buildSeqPosIndex } from './seqPosToGlobalCol.ts'
80
+ import { maxBitsFor } from './sequenceLogo.ts'
78
81
  import { stripDefault } from './stripDefault.ts'
79
82
  import { computeRowInsertions, len, skipBlanks, transform } from './util.ts'
80
83
  import { saveAs } from './vendor/fileSaver.ts'
@@ -99,6 +102,22 @@ function parseTreeText(text: string) {
99
102
  return parseNewick(text.startsWith('SEQ') ? parseEmfTree(text).tree : text)
100
103
  }
101
104
 
105
+ // Tracks that start hidden. The sequence logo answers a narrower question than
106
+ // conservation does and costs three times the vertical space, so it waits to be
107
+ // asked for.
108
+ const defaultOffTracks = new Set(['sequence-logo'])
109
+
110
+ // `turnedOffTracks` records the user's explicit choices only: an id is absent
111
+ // until they touch that track, and then its value is whether the track is OFF.
112
+ // Reading the default through this is what lets a track ship hidden without
113
+ // writing an entry into every snapshot and shared URL.
114
+ function trackIsOff(
115
+ turnedOffTracks: { get: (id: string) => boolean | undefined },
116
+ id: string,
117
+ ) {
118
+ return turnedOffTracks.get(id) ?? defaultOffTracks.has(id)
119
+ }
120
+
102
121
  /**
103
122
  * #stateModel MsaView
104
123
  *
@@ -249,7 +268,10 @@ function stateModelFactory() {
249
268
  showOnly: types.maybe(types.string),
250
269
  /**
251
270
  * #property
252
- * turned off tracks
271
+ * the user's explicit show/hide choice per track id, keyed by id with
272
+ * the value meaning "off". A track the user has never touched is absent
273
+ * and falls back to its own default (see `defaultOffTracks`), so a
274
+ * hidden-by-default track adds nothing to the shared URL.
253
275
  */
254
276
  turnedOffTracks: stripDefault(types.map(types.boolean), {}),
255
277
 
@@ -375,6 +397,14 @@ function stateModelFactory() {
375
397
  */
376
398
  conservationTrackHeight: 40,
377
399
 
400
+ /**
401
+ * #volatile
402
+ * taller than the conservation track by default: the logo spends its
403
+ * height on stacked glyphs, and a 40px stack of four residues leaves each
404
+ * one too short to identify
405
+ */
406
+ sequenceLogoTrackHeight: 80,
407
+
378
408
  /**
379
409
  * #volatile
380
410
  */
@@ -1036,27 +1066,25 @@ function stateModelFactory() {
1036
1066
  * Returns values 0-1 where 1 = fully conserved, 0 = no conservation.
1037
1067
  */
1038
1068
  get conservation() {
1039
- const { colStats, sequenceType } = this
1040
- const alphabetSize = sequenceType === 'amino' ? 20 : 4
1041
- const maxEntropy = Math.log2(alphabetSize)
1042
-
1069
+ const { colStats, alphabetMaxBits } = this
1043
1070
  return Array.from({ length: colStats.numColumns }, (_, col) => {
1044
1071
  const total = colStats.total(col)
1045
1072
  const gapCount = colStats.gapCount(col)
1046
- const nonGapTotal = total - gapCount
1047
- let score = 0
1048
- if (nonGapTotal > 0) {
1049
- let entropy = 0
1050
- colStats.forEachResidue(col, (_slot, count) => {
1051
- const freq = count / nonGapTotal
1052
- entropy -= freq * Math.log2(freq)
1053
- })
1054
- score =
1055
- Math.max(0, 1 - entropy / maxEntropy) * (1 - gapCount / total)
1056
- }
1057
- return score
1073
+ return total > gapCount
1074
+ ? Math.max(0, 1 - colStats.entropy(col) / alphabetMaxBits) *
1075
+ (1 - gapCount / total)
1076
+ : 0
1058
1077
  })
1059
1078
  },
1079
+ /**
1080
+ * #getter
1081
+ * The information content of a fully conserved column, in bits, which
1082
+ * depends on the alphabet. Both the entropy ceiling `conservation`
1083
+ * normalizes against and the y-axis ceiling of the sequence logo track.
1084
+ */
1085
+ get alphabetMaxBits() {
1086
+ return maxBitsFor(this.sequenceType)
1087
+ },
1060
1088
  /**
1061
1089
  * #getter
1062
1090
  * Per-column conservation of physicochemical property class (amino acids
@@ -1168,12 +1196,24 @@ function stateModelFactory() {
1168
1196
  */
1169
1197
  get blocksY() {
1170
1198
  return calculateBlocks({
1171
- viewportSize: self.height,
1199
+ viewportSize: this.visibleMsaHeight,
1172
1200
  viewportPos: -self.scrollY,
1173
1201
  blockSize: self.blockSize,
1174
1202
  mapSize: self.totalHeight,
1175
1203
  })
1176
1204
  },
1205
+ /**
1206
+ * #getter
1207
+ * height of the alignment viewport, px. The same subtraction as
1208
+ * msaAreaHeight, which is defined later in the views chain
1209
+ */
1210
+ get visibleMsaHeight() {
1211
+ return (
1212
+ self.height -
1213
+ self.headerHeight -
1214
+ (self.msaAreaWidth < self.totalWidth ? self.minimapHeight : 0)
1215
+ )
1216
+ },
1177
1217
  }))
1178
1218
  .views(self => ({
1179
1219
  /**
@@ -1200,15 +1240,10 @@ function stateModelFactory() {
1200
1240
  /**
1201
1241
  * #getter
1202
1242
  * most-negative allowed scrollY, keeping the last row in view rather than
1203
- * letting the whole alignment scroll off the top. visible MSA height is
1204
- * computed inline here because msaAreaHeight is defined later in the chain.
1243
+ * letting the whole alignment scroll off the top.
1205
1244
  */
1206
1245
  get maxScrollY() {
1207
- const visibleHeight =
1208
- self.height -
1209
- self.headerHeight -
1210
- (self.msaAreaWidth < self.totalWidth ? self.minimapHeight : 0)
1211
- return Math.min(-self.totalHeight + visibleHeight, 0)
1246
+ return Math.min(-self.totalHeight + self.visibleMsaHeight, 0)
1212
1247
  },
1213
1248
  /**
1214
1249
  * #getter
@@ -1216,7 +1251,8 @@ function stateModelFactory() {
1216
1251
  get showMsaLetters() {
1217
1252
  return (
1218
1253
  self.drawMsaLetters &&
1219
- self.rowHeight >= 5 &&
1254
+ self.rowHeight >= minLetterRowHeight &&
1255
+ self.colWidth >= minLetterColWidth &&
1220
1256
  self.colWidth > self.rowHeight / 2
1221
1257
  )
1222
1258
  },
@@ -1224,7 +1260,7 @@ function stateModelFactory() {
1224
1260
  * #getter
1225
1261
  */
1226
1262
  get showTreeText() {
1227
- return self.drawLabels && self.rowHeight >= 5
1263
+ return self.drawLabels && self.rowHeight >= minLetterRowHeight
1228
1264
  },
1229
1265
  }))
1230
1266
  .actions(self => ({
@@ -1437,11 +1473,9 @@ function stateModelFactory() {
1437
1473
  * #action
1438
1474
  */
1439
1475
  toggleTrack(id: string) {
1440
- if (self.turnedOffTracks.has(id)) {
1441
- self.turnedOffTracks.delete(id)
1442
- } else {
1443
- self.turnedOffTracks.set(id, true)
1444
- }
1476
+ // the stored value is "is off", so the current shown state is exactly
1477
+ // what the flipped entry should hold
1478
+ self.turnedOffTracks.set(id, !trackIsOff(self.turnedOffTracks, id))
1445
1479
  },
1446
1480
  /**
1447
1481
  * #action
@@ -1455,10 +1489,13 @@ function stateModelFactory() {
1455
1489
  * #getter
1456
1490
  */
1457
1491
  get labelWidthMap() {
1458
- const { showTreeText, leaves, treeMetadata, fontSize } = self
1492
+ const { showTreeText, leaves, treeMetadata } = self
1459
1493
  // gated on the same condition the renderer draws labels under, so the
1460
1494
  // gutter labelsWidth reserves and the labels actually drawn cannot
1461
- // disagree -- and so turning labels off hands their space to the tree
1495
+ // disagree -- and so turning labels off hands their space to the tree.
1496
+ // Measured at a fixed reference size and scaled by labelWidthScale:
1497
+ // re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
1498
+ // 50k-leaf tree
1462
1499
  return showTreeText
1463
1500
  ? new Map(
1464
1501
  leaves.map(node => {
@@ -1467,12 +1504,24 @@ function stateModelFactory() {
1467
1504
  // to the row name, and measuring '' would size the gutter (and
1468
1505
  // the label's click target) to nothing
1469
1506
  const displayName = treeMetadata[name]?.genome || name
1470
- return [name, measureTextCanvas(displayName, fontSize)] as const
1507
+ return [
1508
+ name,
1509
+ measureTextCanvas(displayName, labelReferenceFontSize),
1510
+ ] as const
1471
1511
  }),
1472
1512
  )
1473
1513
  : new Map<string, number>()
1474
1514
  },
1475
1515
 
1516
+ /**
1517
+ * #getter
1518
+ * factor turning a labelWidthMap entry into its width at the current
1519
+ * font size
1520
+ */
1521
+ get labelWidthScale() {
1522
+ return self.fontSize / labelReferenceFontSize
1523
+ },
1524
+
1476
1525
  get labelsWidth() {
1477
1526
  // a loop, not Math.max(...widths.values()): spreading a map of every
1478
1527
  // leaf passes one argument per row, and the argument limit is somewhere
@@ -1485,7 +1534,7 @@ function stateModelFactory() {
1485
1534
  max = width
1486
1535
  }
1487
1536
  }
1488
- return max
1537
+ return max * this.labelWidthScale
1489
1538
  },
1490
1539
 
1491
1540
  /**
@@ -1513,10 +1562,11 @@ function stateModelFactory() {
1513
1562
  .map(t => ({
1514
1563
  model: {
1515
1564
  ...t,
1565
+ kind: 'text' as const,
1516
1566
  data: hideGapsEffective ? skipBlanks(blanks, t.data!) : t.data,
1517
1567
  height: rowHeight,
1518
1568
  },
1519
- ReactComponent: TextTrack,
1569
+ ReactComponent: TrackBlocks,
1520
1570
  }))
1521
1571
  },
1522
1572
 
@@ -1524,28 +1574,35 @@ function stateModelFactory() {
1524
1574
  * #getter
1525
1575
  */
1526
1576
  get tracks(): BasicTrack[] {
1527
- const conservationTrack: BasicTrack = {
1528
- model: {
1529
- id: 'conservation',
1530
- name: 'Conservation',
1531
- height: self.conservationTrackHeight,
1532
- barColor: 'gray',
1533
- },
1534
- ReactComponent: ConservationTrack,
1577
+ const conservationTrack = {
1578
+ id: 'conservation',
1579
+ name: 'Conservation',
1580
+ kind: 'bar' as const,
1581
+ height: self.conservationTrackHeight,
1582
+ barColor: 'gray',
1535
1583
  }
1536
- const propertyConservationTrack: BasicTrack = {
1537
- model: {
1538
- id: 'property-conservation',
1539
- name: 'Property conservation',
1540
- height: self.conservationTrackHeight,
1541
- barColor: '#6a51a3',
1542
- },
1543
- ReactComponent: ConservationTrack,
1584
+ const propertyConservationTrack = {
1585
+ id: 'property-conservation',
1586
+ name: 'Property conservation',
1587
+ kind: 'bar' as const,
1588
+ height: self.conservationTrackHeight,
1589
+ barColor: '#6a51a3',
1590
+ }
1591
+ const sequenceLogoTrack = {
1592
+ id: 'sequence-logo',
1593
+ name: 'Sequence logo',
1594
+ kind: 'logo' as const,
1595
+ height: self.sequenceLogoTrackHeight,
1544
1596
  }
1545
1597
  return [
1546
1598
  ...this.adapterTrackModels,
1547
- conservationTrack,
1548
- ...(self.sequenceType === 'amino' ? [propertyConservationTrack] : []),
1599
+ ...[
1600
+ conservationTrack,
1601
+ ...(self.sequenceType === 'amino'
1602
+ ? [propertyConservationTrack]
1603
+ : []),
1604
+ sequenceLogoTrack,
1605
+ ].map(model => ({ model, ReactComponent: TrackBlocks })),
1549
1606
  ]
1550
1607
  },
1551
1608
 
@@ -1553,7 +1610,9 @@ function stateModelFactory() {
1553
1610
  * #getter
1554
1611
  */
1555
1612
  get turnedOnTracks() {
1556
- return this.tracks.filter(f => !self.turnedOffTracks.has(f.model.id))
1613
+ return this.tracks.filter(
1614
+ f => !trackIsOff(self.turnedOffTracks, f.model.id),
1615
+ )
1557
1616
  },
1558
1617
 
1559
1618
  /**
@@ -1810,11 +1869,11 @@ function stateModelFactory() {
1810
1869
  * needs to keep residues readable on top of the boxes.
1811
1870
  */
1812
1871
  get domainBands() {
1872
+ const { blanks } = self
1813
1873
  const bands = new Map<string, DomainBand[]>()
1814
1874
  for (const [name, annotations] of Object.entries(
1815
1875
  self.annotationsByRow,
1816
1876
  )) {
1817
- const { blanks } = self
1818
1877
  const rowBands = annotations
1819
1878
  .toSorted((a, b) => len(b) - len(a))
1820
1879
  .map(annotation => {
@@ -1942,8 +2001,11 @@ function stateModelFactory() {
1942
2001
  return undefined
1943
2002
  }
1944
2003
  const { colStats } = self
2004
+ if (mouseCol >= colStats.numColumns) {
2005
+ return undefined
2006
+ }
1945
2007
  const total = colStats.total(mouseCol)
1946
- if (mouseCol >= colStats.numColumns || !total) {
2008
+ if (!total) {
1947
2009
  return undefined
1948
2010
  }
1949
2011
  const gaps = colStats.gapCount(mouseCol)
@@ -1988,6 +2050,12 @@ function stateModelFactory() {
1988
2050
  setConservationTrackHeight(arg: number) {
1989
2051
  self.conservationTrackHeight = arg
1990
2052
  },
2053
+ /**
2054
+ * #action
2055
+ */
2056
+ setSequenceLogoTrackHeight(arg: number) {
2057
+ self.sequenceLogoTrackHeight = arg
2058
+ },
1991
2059
  /**
1992
2060
  * #action
1993
2061
  */