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
@@ -31,25 +31,43 @@ const BLOSUM62_DATA = [
31
31
  [-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4,-4, 1],
32
32
  ]
33
33
 
34
- const BLOSUM62 = new Map<string, Map<string, number>>()
35
- for (let i = 0; i < BLOSUM62_KEYS.length; i++) {
36
- const row = new Map<string, number>()
37
- for (let j = 0; j < BLOSUM62_KEYS.length; j++) {
38
- row.set(BLOSUM62_KEYS[j]!, BLOSUM62_DATA[i]![j]!)
39
- }
40
- BLOSUM62.set(BLOSUM62_KEYS[i]!, row)
34
+ const NUM_SYMBOLS = BLOSUM62_KEYS.length
35
+ const BLOSUM62 = Int8Array.from(BLOSUM62_DATA.flat())
36
+
37
+ // charCode -> row/column in BLOSUM62, -1 for anything the matrix does not name.
38
+ // A flat table rather than a Map of Maps keyed by an upper-cased character, for
39
+ // the reason columnCounts.ts gives: the distance matrix is O(rows^2 * columns)
40
+ // lookups, and on a 120x800 alignment the Map form spent nearly all of its time
41
+ // allocating one-character strings in toUpperCase: 2.6s to build a tree, versus
42
+ // 0.34s here for a byte-identical newick.
43
+ const symbolOfCode = new Int8Array(128).fill(-1)
44
+ for (let i = 0; i < NUM_SYMBOLS; i++) {
45
+ const key = BLOSUM62_KEYS[i]!
46
+ symbolOfCode[key.charCodeAt(0)] = i
47
+ symbolOfCode[key.toLowerCase().charCodeAt(0)] = i
41
48
  }
42
49
 
43
- function getBlosum62Score(a: string, b: string) {
44
- return BLOSUM62.get(a.toUpperCase())?.get(b.toUpperCase()) ?? -4
45
- }
50
+ // gap, or past the end of a row shorter than the alignment
51
+ const GAP = -2
46
52
 
47
53
  // stockholm and a3m write gaps as '.', not just '-'. Scoring a '.' as a residue
48
54
  // charges the pair the unknown-symbol penalty on every padded column, so two
49
55
  // sequences that differ only in where an unrelated row forced padding come out
50
56
  // as divergent.
51
- function isGap(c: string) {
52
- return c === '-' || c === '.'
57
+ function symbolAt(seq: string, i: number) {
58
+ if (i >= seq.length) {
59
+ return GAP
60
+ }
61
+ const code = seq.charCodeAt(i)
62
+ // bit trick: (code - 45) >>> 0 <= 1 checks for '-' (45) or '.' (46)
63
+ if ((code - 45) >>> 0 <= 1) {
64
+ return GAP
65
+ }
66
+ return code < 128 ? symbolOfCode[code]! : -1
67
+ }
68
+
69
+ function blosumScore(a: number, b: number) {
70
+ return a < 0 || b < 0 ? -4 : BLOSUM62[a * NUM_SYMBOLS + b]!
53
71
  }
54
72
 
55
73
  function computePairwiseDistance(seq1: string, seq2: string) {
@@ -63,11 +81,11 @@ function computePairwiseDistance(seq1: string, seq2: string) {
63
81
  let maxPossibleScore = 0
64
82
 
65
83
  for (let i = 0; i < len; i++) {
66
- const a = seq1[i] ?? '-'
67
- const b = seq2[i] ?? '-'
84
+ const a = symbolAt(seq1, i)
85
+ const b = symbolAt(seq2, i)
68
86
 
69
- const aGap = isGap(a)
70
- const bGap = isGap(b)
87
+ const aGap = a === GAP
88
+ const bGap = b === GAP
71
89
  if (aGap && bGap) {
72
90
  continue
73
91
  }
@@ -77,8 +95,8 @@ function computePairwiseDistance(seq1: string, seq2: string) {
77
95
  continue
78
96
  }
79
97
 
80
- totalScore += getBlosum62Score(a, b)
81
- maxPossibleScore += Math.max(getBlosum62Score(a, a), getBlosum62Score(b, b))
98
+ totalScore += blosumScore(a, b)
99
+ maxPossibleScore += Math.max(blosumScore(a, a), blosumScore(b, b))
82
100
  }
83
101
 
84
102
  if (compared === 0) {
@@ -9,67 +9,12 @@ import { enableStaticRendering } from 'mobx-react'
9
9
  import { beforeAll, expect, test } from 'vitest'
10
10
 
11
11
  import MSAModelF from './model.ts'
12
+ import { installRenderTestEnv } from './renderTestEnv.ts'
12
13
  import { renderToSvg } from './renderToSvg.tsx'
13
14
 
14
- class Mat {
15
- constructor(
16
- public a = 1,
17
- public b = 0,
18
- public c = 0,
19
- public d = 1,
20
- public e = 0,
21
- public f = 0,
22
- ) {}
23
- multiply(o: Mat) {
24
- return new Mat(
25
- this.a * o.a + this.c * o.b,
26
- this.b * o.a + this.d * o.b,
27
- this.a * o.c + this.c * o.d,
28
- this.b * o.c + this.d * o.d,
29
- this.a * o.e + this.c * o.f + this.e,
30
- this.b * o.e + this.d * o.f + this.f,
31
- )
32
- }
33
- translate(x: number, y = 0) {
34
- return this.multiply(new Mat(1, 0, 0, 1, x, y))
35
- }
36
- scale(x: number, y = x) {
37
- return this.multiply(new Mat(x, 0, 0, y, 0, 0))
38
- }
39
- }
40
- class Pt {
41
- constructor(
42
- public x = 0,
43
- public y = 0,
44
- ) {}
45
- matrixTransform(m: Mat) {
46
- return new Pt(
47
- m.a * this.x + m.c * this.y + m.e,
48
- m.b * this.x + m.d * this.y + m.f,
49
- )
50
- }
51
- }
52
-
53
15
  beforeAll(() => {
54
16
  enableStaticRendering(true)
55
- const g = globalThis as Record<string, unknown>
56
- g.DOMMatrix = Mat
57
- g.DOMPoint = Pt
58
- HTMLCanvasElement.prototype.getContext = function () {
59
- let font = '10px sans-serif'
60
- return {
61
- get font() {
62
- return font
63
- },
64
- set font(v: string) {
65
- font = v
66
- },
67
- measureText(t: string) {
68
- const size = Number.parseFloat(font) || 10
69
- return { width: t.length * size * 0.6 } as TextMetrics
70
- },
71
- } as unknown as CanvasRenderingContext2D
72
- } as unknown as typeof HTMLCanvasElement.prototype.getContext
17
+ installRenderTestEnv()
73
18
  })
74
19
 
75
20
  const msa = `>seq1
@@ -0,0 +1,55 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // The viewport export draws a minimap by default, and nothing covered it. The
4
+ // export lays the figure out on its own grid rather than the on-screen one, so
5
+ // the minimap has to be told how wide to draw -- reading a width off the model
6
+ // puts its trapezoid short of the alignment column beside it.
7
+ import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
8
+ import { enableStaticRendering } from 'mobx-react'
9
+ import { beforeAll, expect, test } from 'vitest'
10
+
11
+ import MSAModelF from './model.ts'
12
+ import { installRenderTestEnv } from './renderTestEnv.ts'
13
+ import { renderToSvg } from './renderToSvg.tsx'
14
+
15
+ beforeAll(() => {
16
+ enableStaticRendering(true)
17
+ installRenderTestEnv()
18
+ })
19
+
20
+ const width = 1000
21
+
22
+ function makeModel() {
23
+ const row = 'ACDEFGHIKL'.repeat(40)
24
+ const model = MSAModelF().create({
25
+ type: 'MsaView',
26
+ msaFormat: 'fasta',
27
+ height: 300,
28
+ data: { msa: `>seq1\n${row}\n>seq2\n${row}` },
29
+ })
30
+ model.setWidth(width)
31
+ return model
32
+ }
33
+
34
+ test('the exported minimap spans the exported alignment column', async () => {
35
+ const model = makeModel()
36
+ expect(model.showHorizontalScrollbar).toBe(true)
37
+
38
+ const svg = await renderToSvg(model, {
39
+ theme: createJBrowseTheme(),
40
+ exportType: 'viewport',
41
+ includeMinimap: true,
42
+ includeTracks: false,
43
+ })
44
+
45
+ // the minimap column runs from the tree area to the right edge of the figure
46
+ const minimapWidth = width - model.treeAreaWidth
47
+ const polygon = /<polygon[^>]*points="([^"]*)"/.exec(svg)?.[1]
48
+ expect(polygon).toBeDefined()
49
+ // bottom edge of the trapezoid reaches the full width it was given
50
+ expect(polygon!.endsWith(`${minimapWidth},${model.minimapHeight - 12}`)).toBe(
51
+ true,
52
+ )
53
+ // and the bar outline is drawn to the same width
54
+ expect(svg).toContain(`width="${minimapWidth}"`)
55
+ })
@@ -0,0 +1,176 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // The logo track is the first track kind whose letters are drawn under a
4
+ // per-letter transform rather than at a fixed size, so it is also the first that
5
+ // can silently draw nothing if the export context loses that transform. These
6
+ // tests go through the real SVG export to pin the whole path: the track is off
7
+ // until asked for, the stack is ordered and scaled, and the glyphs land inside
8
+ // the track's own band.
9
+ import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
10
+ import { enableStaticRendering } from 'mobx-react'
11
+ import { beforeAll, expect, test } from 'vitest'
12
+
13
+ import MSAModelF from './model.ts'
14
+ import { TEST_CHAR_WIDTH_RATIO, installRenderTestEnv } from './renderTestEnv.ts'
15
+ import { renderToSvg } from './renderToSvg.tsx'
16
+
17
+ beforeAll(() => {
18
+ enableStaticRendering(true)
19
+ installRenderTestEnv()
20
+ })
21
+
22
+ // column 0 is fully conserved A, column 1 is a 3:1 split of C over G, and
23
+ // column 2 is an even four-way split carrying no information at all
24
+ const msa = `>s1\nACA\n>s2\nACC\n>s3\nACG\n>s4\nAGT\n`
25
+ const colWidth = 40
26
+
27
+ function makeModel() {
28
+ const model = MSAModelF().create({
29
+ type: 'MsaView',
30
+ msaFormat: 'fasta',
31
+ height: 400,
32
+ colWidth,
33
+ data: { msa },
34
+ })
35
+ model.setWidth(1000)
36
+ return model
37
+ }
38
+
39
+ async function exportSvg(model: ReturnType<typeof makeModel>) {
40
+ return renderToSvg(model, {
41
+ theme: createJBrowseTheme(),
42
+ exportType: 'entire',
43
+ includeTracks: true,
44
+ })
45
+ }
46
+
47
+ // The logo's glyphs are the only <text> drawn at the track's own font size, and
48
+ // the only ones carrying a non-unit vertical scale, which is what separates them
49
+ // from the alignment letters and tree labels in the same document.
50
+ function logoGlyphs(svg: string, fontSize: number) {
51
+ return [
52
+ ...svg.matchAll(
53
+ /<text[^>]*font-size="([\d.]+)px"[^>]*transform="matrix\(([^)]*)\)"[^>]*>([A-Z])<\/text>/g,
54
+ ),
55
+ ]
56
+ .filter(m => Number(m[1]) === fontSize)
57
+ .map(m => {
58
+ const [sx, , , sy, tx, ty] = m[2]!.split(/[\s,]+/).map(Number)
59
+ return { letter: m[3]!, sx: sx!, sy: sy!, x: tx!, y: ty! }
60
+ })
61
+ }
62
+
63
+ async function glyphsOf(model: ReturnType<typeof makeModel>) {
64
+ return logoGlyphs(await exportSvg(model), model.sequenceLogoTrackHeight)
65
+ }
66
+
67
+ test('the logo track is hidden until it is asked for', () => {
68
+ const model = makeModel()
69
+ expect(model.tracks.map(t => t.model.id)).toContain('sequence-logo')
70
+ expect(model.turnedOnTracks.map(t => t.model.id)).not.toContain(
71
+ 'sequence-logo',
72
+ )
73
+ // and being default-off writes nothing into the shareable snapshot
74
+ expect(model.turnedOffTracks.size).toBe(0)
75
+ })
76
+
77
+ test('toggling it on shows it, and off again hides it', () => {
78
+ const model = makeModel()
79
+ model.toggleTrack('sequence-logo')
80
+ expect(model.turnedOnTracks.map(t => t.model.id)).toContain('sequence-logo')
81
+ model.toggleTrack('sequence-logo')
82
+ expect(model.turnedOnTracks.map(t => t.model.id)).not.toContain(
83
+ 'sequence-logo',
84
+ )
85
+ })
86
+
87
+ test('an on-by-default track still toggles off and back on', () => {
88
+ const model = makeModel()
89
+ expect(model.turnedOnTracks.map(t => t.model.id)).toContain('conservation')
90
+ model.toggleTrack('conservation')
91
+ expect(model.turnedOnTracks.map(t => t.model.id)).not.toContain(
92
+ 'conservation',
93
+ )
94
+ model.toggleTrack('conservation')
95
+ expect(model.turnedOnTracks.map(t => t.model.id)).toContain('conservation')
96
+ })
97
+
98
+ test('the conserved column draws one full-height letter', async () => {
99
+ const model = makeModel()
100
+ model.toggleTrack('sequence-logo')
101
+ const col0 = (await glyphsOf(model)).filter(g => g.x === 0)
102
+
103
+ expect(col0.map(g => g.letter)).toEqual(['A'])
104
+ // a fully conserved nucleotide column is 2 of 2 bits, so the glyph stretches
105
+ // to the whole track height over the cap height of the reference font
106
+ expect(col0[0]!.sy).toBeCloseTo(1 / 0.72, 5)
107
+ })
108
+
109
+ test('a split column stacks the minor letter below the major one', async () => {
110
+ const model = makeModel()
111
+ model.toggleTrack('sequence-logo')
112
+ const col1 = (await glyphsOf(model)).filter(g => g.x === colWidth)
113
+
114
+ // ascending order means C (3 of 4) is drawn last, so it ends up on top
115
+ expect(col1.map(g => g.letter)).toEqual(['G', 'C'])
116
+ const [g, c] = col1
117
+ expect(c!.sy).toBeGreaterThan(g!.sy)
118
+ // and the taller letter sits higher up the track (smaller y is nearer the top)
119
+ expect(c!.y).toBeLessThan(g!.y)
120
+ })
121
+
122
+ test('the uninformative column draws nothing', async () => {
123
+ const model = makeModel()
124
+ model.toggleTrack('sequence-logo')
125
+ const glyphs = await glyphsOf(model)
126
+ expect(glyphs.filter(g => g.x === colWidth * 2)).toEqual([])
127
+ // the other two columns did draw, so an empty column 2 is the logo's own
128
+ // verdict rather than the whole track missing
129
+ expect(glyphs).toHaveLength(3)
130
+ })
131
+
132
+ test('every glyph stays inside the track band', async () => {
133
+ const model = makeModel()
134
+ model.toggleTrack('sequence-logo')
135
+
136
+ // the export stacks the tracks, so the logo's band is offset by whatever is
137
+ // drawn above it -- assert against that band rather than against 0..height,
138
+ // which would pass only while the logo happened to be the first track
139
+ const above = model.turnedOnTracks
140
+ .slice(
141
+ 0,
142
+ model.turnedOnTracks.findIndex(t => t.model.id === 'sequence-logo'),
143
+ )
144
+ .reduce((a, t) => a + t.model.height, 0)
145
+ const bandTop = above
146
+ const bandBottom = above + model.sequenceLogoTrackHeight
147
+
148
+ const glyphs = await glyphsOf(model)
149
+ expect(glyphs.length).toBeGreaterThan(0)
150
+ for (const glyph of glyphs) {
151
+ // baselines run from the band's bottom edge upward, never past either end
152
+ expect(glyph.y).toBeGreaterThan(bandTop)
153
+ expect(glyph.y).toBeLessThanOrEqual(bandBottom)
154
+ }
155
+ })
156
+
157
+ test('letters are stretched to fill their column', async () => {
158
+ const model = makeModel()
159
+ model.toggleTrack('sequence-logo')
160
+ const glyphs = await glyphsOf(model)
161
+
162
+ const expected =
163
+ colWidth / (model.sequenceLogoTrackHeight * TEST_CHAR_WIDTH_RATIO)
164
+ for (const glyph of glyphs) {
165
+ expect(glyph.sx).toBeCloseTo(expected, 5)
166
+ }
167
+ })
168
+
169
+ test('the logo appears in the export only when the track is on', async () => {
170
+ const off = makeModel()
171
+ expect(await glyphsOf(off)).toEqual([])
172
+
173
+ const on = makeModel()
174
+ on.toggleTrack('sequence-logo')
175
+ expect((await glyphsOf(on)).length).toBeGreaterThan(0)
176
+ })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * jsdom shims for the SVG-export tests. Test-only -- nothing in the viewer
3
+ * imports this.
4
+ *
5
+ * jsdom implements neither DOMMatrix nor a canvas 2D context, and
6
+ * `@jbrowse/svgcanvas` needs both: it tracks the current transform as a real
7
+ * matrix, and the renderers ask a throwaway canvas to measure text.
8
+ *
9
+ * The matrix stub has to accept the ARRAY constructor. svgcanvas normalizes
10
+ * every transform through `new DOMMatrix([a, b, c, d, e, f])`, so a stub that
11
+ * only takes six positional arguments silently binds the whole array to `a` and
12
+ * leaves the rest undefined -- every emitted transform then carries `NaN` in its
13
+ * x components, which no assertion on a drawn x-position can survive.
14
+ */
15
+ export class TestDOMMatrix {
16
+ a: number
17
+ b: number
18
+ c: number
19
+ d: number
20
+ e: number
21
+ f: number
22
+
23
+ constructor(init?: number[]) {
24
+ const [a = 1, b = 0, c = 0, d = 1, e = 0, f = 0] = init ?? []
25
+ this.a = a
26
+ this.b = b
27
+ this.c = c
28
+ this.d = d
29
+ this.e = e
30
+ this.f = f
31
+ }
32
+
33
+ multiply(o: TestDOMMatrix) {
34
+ return new TestDOMMatrix([
35
+ this.a * o.a + this.c * o.b,
36
+ this.b * o.a + this.d * o.b,
37
+ this.a * o.c + this.c * o.d,
38
+ this.b * o.c + this.d * o.d,
39
+ this.a * o.e + this.c * o.f + this.e,
40
+ this.b * o.e + this.d * o.f + this.f,
41
+ ])
42
+ }
43
+
44
+ translate(x: number, y = 0) {
45
+ return this.multiply(new TestDOMMatrix([1, 0, 0, 1, x, y]))
46
+ }
47
+
48
+ scale(x: number, y = x) {
49
+ return this.multiply(new TestDOMMatrix([x, 0, 0, y, 0, 0]))
50
+ }
51
+ }
52
+
53
+ export class TestDOMPoint {
54
+ constructor(
55
+ public x = 0,
56
+ public y = 0,
57
+ ) {}
58
+
59
+ matrixTransform(m: TestDOMMatrix) {
60
+ return new TestDOMPoint(
61
+ m.a * this.x + m.c * this.y + m.e,
62
+ m.b * this.x + m.d * this.y + m.f,
63
+ )
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Install the shims. Call from `beforeAll` in any test that exports SVG.
69
+ *
70
+ * `measureText` reports 0.6em per character, close enough to a real sans-serif
71
+ * average for layout assertions and, more usefully, exactly predictable: a test
72
+ * can compute the width a renderer will see rather than hardcoding a number
73
+ * measured from one machine's fonts.
74
+ */
75
+ export function installRenderTestEnv() {
76
+ const g = globalThis as Record<string, unknown>
77
+ g.DOMMatrix = TestDOMMatrix
78
+ g.DOMPoint = TestDOMPoint
79
+ HTMLCanvasElement.prototype.getContext = function () {
80
+ let font = '10px sans-serif'
81
+ return {
82
+ get font() {
83
+ return font
84
+ },
85
+ set font(v: string) {
86
+ font = v
87
+ },
88
+ measureText: (t: string) => ({
89
+ width: t.length * (Number.parseFloat(font) || 10) * 0.6,
90
+ }),
91
+ } as unknown as CanvasRenderingContext2D
92
+ } as unknown as typeof HTMLCanvasElement.prototype.getContext
93
+ }
94
+
95
+ /** the per-character width factor `measureText` above reports */
96
+ export const TEST_CHAR_WIDTH_RATIO = 0.6
@@ -7,7 +7,7 @@ import { when } from 'mobx'
7
7
  import MinimapSVG from './components/minimap/MinimapSVG.tsx'
8
8
  import { renderBoxFeatureCanvasBlock } from './components/msa/renderBoxFeatureCanvasBlock.ts'
9
9
  import { renderMSABlock } from './components/msa/renderMSABlock.ts'
10
- import { renderAllTracks } from './components/tracks/renderTracksSvg.ts'
10
+ import { renderAllTracks } from './components/tracks/drawTracks.ts'
11
11
  import { renderTreeCanvas } from './components/tree/renderTreeCanvas.ts'
12
12
  import { colorContrast } from './util.ts'
13
13
 
@@ -161,7 +161,7 @@ function MsaSvg({
161
161
  {includeMinimap ? (
162
162
  <>
163
163
  <g transform={`translate(${treeAreaWidth} 0)`}>
164
- <MinimapSVG model={model} />
164
+ <MinimapSVG model={model} width={width - treeAreaWidth} />
165
165
  </g>
166
166
  <g transform={`translate(0 ${minimapHeight})`}>{body}</g>
167
167
  </>
@@ -178,7 +178,7 @@ function MsaSvg({
178
178
  }
179
179
 
180
180
  // the domain color key drawn as a reserved column to the right of the
181
- // alignment, mirroring the on-screen DomainLegend overlay
181
+ // alignment, mirroring the on-screen AnnotationLegend overlay
182
182
  function LegendSVG({ model, width }: { model: MsaViewModel; width: number }) {
183
183
  const { visibleDomainTypes, fillPalette, strokePalette } = model
184
184
  const boxHeight = LEGEND_PAD * 2 + visibleDomainTypes.length * LEGEND_ROW_H
@@ -18,6 +18,25 @@ import { isBlank } from './util.ts'
18
18
  * Each row can have different seqPos values for the same globalCol due to gaps.
19
19
  */
20
20
 
21
+ /**
22
+ * Index of the first element in `[0, len)` for which `pred` holds, over a range
23
+ * where `pred` is false up to some point and true from there on. Both
24
+ * conversions below are that shape, since `blanks` is sorted ascending.
25
+ */
26
+ function partitionPoint(len: number, pred: (i: number) => boolean) {
27
+ let left = 0
28
+ let right = len
29
+ while (left < right) {
30
+ const mid = (left + right) >>> 1
31
+ if (pred(mid)) {
32
+ right = mid
33
+ } else {
34
+ left = mid + 1
35
+ }
36
+ }
37
+ return left
38
+ }
39
+
21
40
  /**
22
41
  * Convert a visible column index to a global column index.
23
42
  * This is used when translating mouse/screen coordinates to MSA coordinates.
@@ -27,32 +46,12 @@ import { isBlank } from './util.ts'
27
46
  * @returns The corresponding global column index in the full MSA
28
47
  */
29
48
  export function visibleColToGlobalCol(blanks: number[], visibleCol: number) {
30
- let currentVisibleCol = 0
31
- let blankArrayIndex = 0
32
- let globalCol = 0
33
- const blanksLen = blanks.length
34
-
35
- // Skip any leading blank columns (blanks at the very beginning)
36
- while (blankArrayIndex < blanksLen && blanks[blankArrayIndex] === globalCol) {
37
- blankArrayIndex++
38
- globalCol++
39
- }
40
-
41
- while (currentVisibleCol < visibleCol) {
42
- currentVisibleCol++
43
- globalCol++
44
-
45
- // Skip any blank columns after incrementing
46
- while (
47
- blankArrayIndex < blanksLen &&
48
- blanks[blankArrayIndex] === globalCol
49
- ) {
50
- blankArrayIndex++
51
- globalCol++
52
- }
53
- }
54
-
55
- return globalCol
49
+ // `blanks[i] - i` counts the visible columns before the i-th blank, so the
50
+ // first blank with more than visibleCol of them in front of it is the first
51
+ // one sitting past this column. Its index is how many blanks shift it right.
52
+ return (
53
+ visibleCol + partitionPoint(blanks.length, i => blanks[i]! - i > visibleCol)
54
+ )
56
55
  }
57
56
 
58
57
  /**
@@ -67,25 +66,30 @@ export function globalColToVisibleCol(
67
66
  blanks: number[],
68
67
  globalCol: number,
69
68
  ): number | undefined {
70
- // Check if this column is hidden
71
- // Use binary search since blanks is sorted
72
- let left = 0
73
- let right = blanks.length - 1
74
- while (left <= right) {
75
- const mid = Math.floor((left + right) / 2)
76
- if (blanks[mid] === globalCol) {
77
- return undefined // Column is hidden
78
- }
79
- if (blanks[mid]! < globalCol) {
80
- left = mid + 1
81
- } else {
82
- right = mid - 1
83
- }
84
- }
69
+ const blanksBefore = countBlanksBefore(blanks, globalCol)
70
+ return blanks[blanksBefore] === globalCol
71
+ ? undefined // Column is hidden
72
+ : globalCol - blanksBefore
73
+ }
85
74
 
86
- // Count blanks before this column (left is now the insertion point)
87
- const blanksBefore = left
88
- return globalCol - blanksBefore
75
+ function countBlanksBefore(blanks: number[], globalCol: number) {
76
+ return partitionPoint(blanks.length, i => blanks[i]! >= globalCol)
77
+ }
78
+
79
+ /**
80
+ * How many visible columns come before a global column. For a visible column
81
+ * that is its own visible index; for a hidden one it is the index of the next
82
+ * visible column, i.e. where the hidden column collapses to.
83
+ *
84
+ * That total answer is what spans want: resolving both ends of a span through
85
+ * globalColToVisibleCol instead loses the whole span whenever either end lands
86
+ * in a hidden column.
87
+ *
88
+ * @param blanks - Sorted array of global column indices that are hidden
89
+ * @param globalCol - The global column index in the full MSA
90
+ */
91
+ export function visibleColsBefore(blanks: number[], globalCol: number) {
92
+ return globalCol - countBlanksBefore(blanks, globalCol)
89
93
  }
90
94
 
91
95
  /**