react-msaview 5.1.1 → 5.2.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 (190) hide show
  1. package/README.md +41 -8
  2. package/bundle/index.js +17 -17
  3. package/bundle/index.js.LICENSE.txt +1 -1
  4. package/bundle/index.js.map +1 -1
  5. package/dist/calculateBlocks.d.ts +4 -10
  6. package/dist/calculateBlocks.js +12 -20
  7. package/dist/calculateBlocks.js.map +1 -1
  8. package/dist/components/ConservationTrack.js +20 -37
  9. package/dist/components/ConservationTrack.js.map +1 -1
  10. package/dist/components/MSAView.js +8 -1
  11. package/dist/components/MSAView.js.map +1 -1
  12. package/dist/components/ResizeHandles.d.ts +3 -0
  13. package/dist/components/ResizeHandles.js +60 -105
  14. package/dist/components/ResizeHandles.js.map +1 -1
  15. package/dist/components/TextTrack.js +25 -34
  16. package/dist/components/TextTrack.js.map +1 -1
  17. package/dist/components/Track.js +1 -1
  18. package/dist/components/Track.js.map +1 -1
  19. package/dist/components/VerticalScrollbar.js +2 -2
  20. package/dist/components/VerticalScrollbar.js.map +1 -1
  21. package/dist/components/dialogs/ExportSVGDialog.js +20 -20
  22. package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
  23. package/dist/components/dialogs/InterProScanDialog.js +6 -1
  24. package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
  25. package/dist/components/dialogs/UserProvidedDomainsDialog.js +1 -2
  26. package/dist/components/dialogs/UserProvidedDomainsDialog.js.map +1 -1
  27. package/dist/components/header/ColorSchemeMenu.js +1 -1
  28. package/dist/components/header/ColorSchemeMenu.js.map +1 -1
  29. package/dist/components/header/FileMenu.js +3 -2
  30. package/dist/components/header/FileMenu.js.map +1 -1
  31. package/dist/components/header/Header.js +1 -1
  32. package/dist/components/header/HeaderInfoArea.js +28 -8
  33. package/dist/components/header/HeaderInfoArea.js.map +1 -1
  34. package/dist/components/header/MSASettingsMenu.js +1 -1
  35. package/dist/components/header/MSASettingsMenu.js.map +1 -1
  36. package/dist/components/header/TreeSettingsMenu.js +1 -1
  37. package/dist/components/header/TreeSettingsMenu.js.map +1 -1
  38. package/dist/components/header/ZoomControls.js +7 -2
  39. package/dist/components/header/ZoomControls.js.map +1 -1
  40. package/dist/components/header/ZoomMenu.js +9 -0
  41. package/dist/components/header/ZoomMenu.js.map +1 -1
  42. package/dist/components/header/settingsMenuItems.js +24 -8
  43. package/dist/components/header/settingsMenuItems.js.map +1 -1
  44. package/dist/components/minimap/Minimap.js +6 -17
  45. package/dist/components/minimap/Minimap.js.map +1 -1
  46. package/dist/components/minimap/MinimapSVG.js +7 -17
  47. package/dist/components/minimap/MinimapSVG.js.map +1 -1
  48. package/dist/components/minimap/minimapLayout.d.ts +9 -0
  49. package/dist/components/minimap/minimapLayout.js +15 -0
  50. package/dist/components/minimap/minimapLayout.js.map +1 -0
  51. package/dist/components/msa/MSACanvas.js +6 -1
  52. package/dist/components/msa/MSACanvas.js.map +1 -1
  53. package/dist/components/msa/MSACanvasBlock.js +5 -1
  54. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  55. package/dist/components/msa/MSAMouseoverCanvas.js +2 -2
  56. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  57. package/dist/components/msa/getVisibleLeaves.d.ts +6 -0
  58. package/dist/components/msa/getVisibleLeaves.js +9 -0
  59. package/dist/components/msa/getVisibleLeaves.js.map +1 -0
  60. package/dist/components/msa/renderBoxFeatureCanvasBlock.d.ts +2 -1
  61. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +5 -6
  62. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  63. package/dist/components/msa/renderMSABlock.d.ts +2 -1
  64. package/dist/components/msa/renderMSABlock.js +100 -88
  65. package/dist/components/msa/renderMSABlock.js.map +1 -1
  66. package/dist/components/msa/renderMSAMouseover.js +5 -4
  67. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  68. package/dist/components/msa/visibleColRange.d.ts +8 -0
  69. package/dist/components/msa/visibleColRange.js +10 -0
  70. package/dist/components/msa/visibleColRange.js.map +1 -0
  71. package/dist/components/renderCtx.d.ts +7 -0
  72. package/dist/components/renderCtx.js +2 -0
  73. package/dist/components/renderCtx.js.map +1 -0
  74. package/dist/components/tracks/renderTracksSvg.d.ts +6 -5
  75. package/dist/components/tracks/renderTracksSvg.js +13 -5
  76. package/dist/components/tracks/renderTracksSvg.js.map +1 -1
  77. package/dist/components/tree/TreeCanvas.js.map +1 -1
  78. package/dist/components/tree/TreeCanvasBlock.js +2 -10
  79. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  80. package/dist/components/tree/renderTreeCanvas.d.ts +5 -4
  81. package/dist/components/tree/renderTreeCanvas.js +18 -26
  82. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  83. package/dist/components/util.d.ts +0 -14
  84. package/dist/components/util.js +0 -58
  85. package/dist/components/util.js.map +1 -1
  86. package/dist/constants.d.ts +4 -0
  87. package/dist/constants.js +7 -0
  88. package/dist/constants.js.map +1 -1
  89. package/dist/flatToTree.js +6 -0
  90. package/dist/flatToTree.js.map +1 -1
  91. package/dist/hierarchy.d.ts +3 -1
  92. package/dist/hierarchy.js +142 -89
  93. package/dist/hierarchy.js.map +1 -1
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +8 -0
  96. package/dist/index.js.map +1 -1
  97. package/dist/launchInterProScan.js +22 -9
  98. package/dist/launchInterProScan.js.map +1 -1
  99. package/dist/measureTextCanvas.js +2 -1
  100. package/dist/measureTextCanvas.js.map +1 -1
  101. package/dist/model.d.ts +234 -176
  102. package/dist/model.js +285 -259
  103. package/dist/model.js.map +1 -1
  104. package/dist/parseAsn1.js +8 -2
  105. package/dist/parseAsn1.js.map +1 -1
  106. package/dist/renderToSvg.d.ts +1 -1
  107. package/dist/renderToSvg.js +43 -46
  108. package/dist/renderToSvg.js.map +1 -1
  109. package/dist/rowCoordinateCalculations.d.ts +0 -9
  110. package/dist/rowCoordinateCalculations.js +0 -20
  111. package/dist/rowCoordinateCalculations.js.map +1 -1
  112. package/dist/setFontSize.d.ts +3 -0
  113. package/dist/setFontSize.js +14 -0
  114. package/dist/setFontSize.js.map +1 -0
  115. package/dist/useDevicePixelRatio.d.ts +1 -0
  116. package/dist/useDevicePixelRatio.js +27 -0
  117. package/dist/useDevicePixelRatio.js.map +1 -0
  118. package/dist/useWheelScroll.d.ts +3 -1
  119. package/dist/useWheelScroll.js +64 -22
  120. package/dist/useWheelScroll.js.map +1 -1
  121. package/dist/util.d.ts +5 -0
  122. package/dist/util.js +59 -1
  123. package/dist/util.js.map +1 -1
  124. package/dist/version.d.ts +1 -1
  125. package/dist/version.js +1 -1
  126. package/dist/webpack.js +0 -1
  127. package/dist/webpack.js.map +1 -1
  128. package/dist/wheelZoom.d.ts +4 -0
  129. package/dist/wheelZoom.js +43 -0
  130. package/dist/wheelZoom.js.map +1 -0
  131. package/package.json +5 -5
  132. package/src/calculateBlocks.ts +18 -51
  133. package/src/components/ConservationTrack.tsx +20 -45
  134. package/src/components/MSAView.tsx +10 -1
  135. package/src/components/ResizeHandles.tsx +101 -113
  136. package/src/components/TextTrack.tsx +31 -38
  137. package/src/components/Track.tsx +1 -1
  138. package/src/components/VerticalScrollbar.tsx +2 -2
  139. package/src/components/dialogs/ExportSVGDialog.tsx +27 -17
  140. package/src/components/dialogs/InterProScanDialog.tsx +6 -1
  141. package/src/components/dialogs/UserProvidedDomainsDialog.tsx +1 -2
  142. package/src/components/header/ColorSchemeMenu.tsx +1 -0
  143. package/src/components/header/FileMenu.tsx +3 -1
  144. package/src/components/header/Header.tsx +1 -1
  145. package/src/components/header/HeaderInfoArea.tsx +32 -7
  146. package/src/components/header/MSASettingsMenu.tsx +1 -0
  147. package/src/components/header/TreeSettingsMenu.tsx +1 -0
  148. package/src/components/header/ZoomControls.tsx +14 -1
  149. package/src/components/header/ZoomMenu.tsx +10 -0
  150. package/src/components/header/settingsMenuItems.ts +24 -8
  151. package/src/components/minimap/Minimap.tsx +6 -20
  152. package/src/components/minimap/MinimapSVG.tsx +10 -27
  153. package/src/components/minimap/minimapLayout.ts +17 -0
  154. package/src/components/msa/MSACanvas.tsx +9 -0
  155. package/src/components/msa/MSACanvasBlock.tsx +7 -1
  156. package/src/components/msa/MSAMouseoverCanvas.tsx +4 -3
  157. package/src/components/msa/getVisibleLeaves.ts +21 -0
  158. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +9 -9
  159. package/src/components/msa/renderMSABlock.ts +123 -125
  160. package/src/components/msa/renderMSAMouseover.ts +5 -4
  161. package/src/components/msa/visibleColRange.ts +17 -0
  162. package/src/components/renderCtx.ts +29 -0
  163. package/src/components/tracks/renderTracksSvg.ts +20 -10
  164. package/src/components/tree/TreeCanvas.tsx +3 -1
  165. package/src/components/tree/TreeCanvasBlock.tsx +2 -12
  166. package/src/components/tree/renderTreeCanvas.ts +27 -30
  167. package/src/components/util.ts +0 -81
  168. package/src/constants.ts +8 -0
  169. package/src/flatToTree.test.ts +30 -0
  170. package/src/flatToTree.ts +10 -1
  171. package/src/hierarchy.test.ts +53 -1
  172. package/src/hierarchy.ts +153 -100
  173. package/src/index.ts +8 -0
  174. package/src/launchInterProScan.ts +33 -11
  175. package/src/measureTextCanvas.ts +3 -1
  176. package/src/model.ts +359 -306
  177. package/src/parseAsn1.test.ts +6 -0
  178. package/src/parseAsn1.ts +11 -3
  179. package/src/renderToSvg.tsx +99 -95
  180. package/src/rowCoordinateCalculations.test.ts +0 -51
  181. package/src/rowCoordinateCalculations.ts +0 -23
  182. package/src/setFontSize.test.ts +33 -0
  183. package/src/setFontSize.ts +20 -0
  184. package/src/useDevicePixelRatio.ts +29 -0
  185. package/src/useWheelScroll.ts +79 -21
  186. package/src/util.ts +60 -1
  187. package/src/version.ts +1 -1
  188. package/src/webpack.ts +0 -1
  189. package/src/wheelZoom.test.ts +46 -0
  190. package/src/wheelZoom.ts +47 -0
@@ -12,3 +12,9 @@ const r = readFileSync(
12
12
  test('real data file', () => {
13
13
  expect(parseAsn1(r)).toMatchSnapshot()
14
14
  })
15
+
16
+ test('throws a clear error on input missing required sections', () => {
17
+ expect(() => parseAsn1('BioTreeContainer ::= { something {} }')).toThrow(
18
+ /missing/,
19
+ )
20
+ })
package/src/parseAsn1.ts CHANGED
@@ -88,15 +88,23 @@ export function parseAsn1(
88
88
  .replace(/^.*?::=/, '')
89
89
 
90
90
  const sections = extractSections(normalized)
91
+ const { fdict, nodes } = sections
92
+ if (fdict === undefined || nodes === undefined) {
93
+ // a truncated/malformed BioTreeContainer would otherwise pass undefined to
94
+ // extractBracedBlocks and crash cryptically; fail with a clear message
95
+ throw new Error(
96
+ `invalid BioTreeContainer ASN.1: missing '${fdict === undefined ? 'fdict' : 'nodes'}' section`,
97
+ )
98
+ }
91
99
 
92
100
  const dict = Object.fromEntries(
93
- extractBracedBlocks(sections.fdict!).flatMap(block => {
101
+ extractBracedBlocks(fdict).flatMap(block => {
94
102
  const entry = parseDictEntry(block)
95
103
  return entry ? [[entry.id, remap[entry.name] || entry.name]] : []
96
104
  }),
97
105
  )
98
106
 
99
- return extractBracedBlocks(sections.nodes!).flatMap(block => {
107
+ return extractBracedBlocks(nodes).flatMap(block => {
100
108
  const node = parseNode(block)
101
109
  if (!node) {
102
110
  return []
@@ -108,7 +116,7 @@ export function parseAsn1(
108
116
  ...Object.fromEntries(
109
117
  features.map(f => [dict[f.featureid], f.value] as const),
110
118
  ),
111
- },
119
+ } as { id: number; parent: number; name: string },
112
120
  ]
113
121
  })
114
122
  }
@@ -19,13 +19,23 @@ export interface ExportSvgOptions {
19
19
  theme: Theme
20
20
  includeMinimap?: boolean
21
21
  includeTracks?: boolean
22
- exportType: string
22
+ exportType: 'entire' | 'viewport'
23
23
  }
24
24
  export async function renderToSvg(model: MsaViewModel, opts: ExportSvgOptions) {
25
- await when(() => !!model.dataInitialized)
26
- const { width, height, scrollX, scrollY, totalTrackAreaHeight } = model
27
- const { exportType, theme, includeMinimap, includeTracks } = opts
25
+ await when(() => model.dataInitialized)
26
+ const {
27
+ width,
28
+ height,
29
+ scrollX,
30
+ scrollY,
31
+ totalTrackAreaHeight,
32
+ minimapHeight,
33
+ } = model
34
+ const { exportType, theme, includeTracks } = opts
28
35
  const trackHeight = includeTracks ? totalTrackAreaHeight : 0
36
+ // the minimap reflects the live viewport scroll position, so it's only
37
+ // meaningful for a viewport export, never for the entire alignment
38
+ const includeMinimap = exportType === 'viewport' && !!opts.includeMinimap
29
39
 
30
40
  if (exportType === 'entire') {
31
41
  return render({
@@ -41,21 +51,18 @@ export async function renderToSvg(model: MsaViewModel, opts: ExportSvgOptions) {
41
51
  includeTracks,
42
52
  })
43
53
  }
44
- if (exportType === 'viewport') {
45
- return render({
46
- width,
47
- height: height + (includeMinimap ? model.minimapHeight : 0) + trackHeight,
48
- contentHeight: height,
49
- trackHeight,
50
- theme,
51
- model,
52
- offsetY: -scrollY,
53
- offsetX: -scrollX,
54
- includeMinimap,
55
- includeTracks,
56
- })
57
- }
58
- throw new Error('unknown export type')
54
+ return render({
55
+ width,
56
+ height: height + (includeMinimap ? minimapHeight : 0) + trackHeight,
57
+ contentHeight: height,
58
+ trackHeight,
59
+ theme,
60
+ model,
61
+ offsetY: -scrollY,
62
+ offsetX: -scrollX,
63
+ includeMinimap,
64
+ includeTracks,
65
+ })
59
66
  }
60
67
 
61
68
  async function render({
@@ -82,37 +89,48 @@ async function render({
82
89
  includeTracks?: boolean
83
90
  }) {
84
91
  const { Context } = await import('@jbrowse/svgcanvas')
85
- const Wrapper = includeMinimap ? MinimapWrapper : NullWrapper
92
+ const { treeAreaWidth, minimapHeight } = model
93
+
94
+ const content = (
95
+ <>
96
+ {includeTracks && trackHeight > 0 ? (
97
+ <TrackRendering
98
+ Context={Context}
99
+ model={model}
100
+ theme={theme}
101
+ offsetX={offsetX}
102
+ msaAreaWidth={width - treeAreaWidth}
103
+ trackHeight={trackHeight}
104
+ />
105
+ ) : null}
106
+ <g
107
+ transform={trackHeight > 0 ? `translate(0 ${trackHeight})` : undefined}
108
+ >
109
+ <CoreRendering
110
+ Context={Context}
111
+ model={model}
112
+ theme={theme}
113
+ offsetX={offsetX}
114
+ offsetY={offsetY}
115
+ msaAreaWidth={width - treeAreaWidth}
116
+ contentHeight={contentHeight}
117
+ />
118
+ </g>
119
+ </>
120
+ )
86
121
 
87
122
  return renderToStaticMarkup(
88
123
  <SvgWrapper width={width} height={height}>
89
- <Wrapper model={model}>
90
- {includeTracks && trackHeight > 0 ? (
91
- <TrackRendering
92
- Context={Context}
93
- model={model}
94
- theme={theme}
95
- offsetX={offsetX}
96
- width={width}
97
- trackHeight={trackHeight}
98
- />
99
- ) : null}
100
- <g
101
- transform={
102
- trackHeight > 0 ? `translate(0 ${trackHeight})` : undefined
103
- }
104
- >
105
- <CoreRendering
106
- Context={Context}
107
- model={model}
108
- theme={theme}
109
- offsetX={offsetX}
110
- offsetY={offsetY}
111
- width={width}
112
- contentHeight={contentHeight}
113
- />
114
- </g>
115
- </Wrapper>
124
+ {includeMinimap ? (
125
+ <>
126
+ <g transform={`translate(${treeAreaWidth} 0)`}>
127
+ <MinimapSVG model={model} />
128
+ </g>
129
+ <g transform={`translate(0 ${minimapHeight})`}>{content}</g>
130
+ </>
131
+ ) : (
132
+ content
133
+ )}
116
134
  </SvgWrapper>,
117
135
  )
118
136
  }
@@ -120,7 +138,7 @@ async function render({
120
138
  function CoreRendering({
121
139
  model,
122
140
  theme,
123
- width,
141
+ msaAreaWidth,
124
142
  contentHeight,
125
143
  offsetX,
126
144
  offsetY,
@@ -128,7 +146,7 @@ function CoreRendering({
128
146
  }: {
129
147
  model: MsaViewModel
130
148
  theme: Theme
131
- width: number
149
+ msaAreaWidth: number
132
150
  contentHeight: number
133
151
  offsetX: number
134
152
  offsetY: number
@@ -139,9 +157,8 @@ function CoreRendering({
139
157
  const clipId2 = `msa-${id}`
140
158
  const contrastScheme = colorContrast(colorScheme, theme)
141
159
 
142
- const ctx1 = new Context(width, contentHeight) as any
143
-
144
- const ctx2 = new Context(width, contentHeight) as any
160
+ const ctx1 = new Context(treeAreaWidth, contentHeight)
161
+ const ctx2 = new Context(msaAreaWidth, contentHeight)
145
162
  renderBoxFeatureCanvasBlock({
146
163
  ctx: ctx2,
147
164
  offsetX,
@@ -150,7 +167,6 @@ function CoreRendering({
150
167
  blockSizeYOverride: contentHeight,
151
168
  highResScaleFactorOverride: 1,
152
169
  })
153
- const msaAreaWidth = width - treeAreaWidth
154
170
  renderTreeCanvas({
155
171
  model,
156
172
  offsetY,
@@ -176,37 +192,52 @@ function CoreRendering({
176
192
  <clipPath id={clipId1}>
177
193
  <rect x={0} y={0} width={treeAreaWidth} height={contentHeight} />
178
194
  </clipPath>
179
- </defs>
180
- <defs>
181
195
  <clipPath id={clipId2}>
182
196
  <rect x={0} y={0} width={msaAreaWidth} height={contentHeight} />
183
197
  </clipPath>
184
198
  </defs>
185
199
 
186
- <g
187
- clipPath={`url(#${clipId1})`}
188
- dangerouslySetInnerHTML={{ __html: ctx1.getSvg().innerHTML }}
189
- />
190
- <g
191
- clipPath={`url(#${clipId2})`}
200
+ <ClipGroup clipId={clipId1} html={ctx1.getSvg().innerHTML} />
201
+ <ClipGroup
202
+ clipId={clipId2}
192
203
  transform={`translate(${treeAreaWidth} 0)`}
193
- dangerouslySetInnerHTML={{ __html: ctx2.getSvg().innerHTML }}
204
+ html={ctx2.getSvg().innerHTML}
194
205
  />
195
206
  </>
196
207
  )
197
208
  }
198
209
 
210
+ // Wraps SVG markup produced by a svgcanvas Context in a clipped group. The
211
+ // markup is injected verbatim since it's already serialized SVG, not React.
212
+ function ClipGroup({
213
+ clipId,
214
+ html,
215
+ transform,
216
+ }: {
217
+ clipId: string
218
+ html: string
219
+ transform?: string
220
+ }) {
221
+ return (
222
+ <g
223
+ clipPath={`url(#${clipId})`}
224
+ transform={transform}
225
+ dangerouslySetInnerHTML={{ __html: html }}
226
+ />
227
+ )
228
+ }
229
+
199
230
  function TrackRendering({
200
231
  model,
201
232
  theme,
202
- width,
233
+ msaAreaWidth,
203
234
  trackHeight,
204
235
  offsetX,
205
236
  Context,
206
237
  }: {
207
238
  model: MsaViewModel
208
239
  theme: Theme
209
- width: number
240
+ msaAreaWidth: number
210
241
  trackHeight: number
211
242
  offsetX: number
212
243
  Context: typeof ContextType
@@ -214,9 +245,8 @@ function TrackRendering({
214
245
  const { treeAreaWidth, colorScheme, id } = model
215
246
  const clipId = `tracks-${id}`
216
247
  const contrastScheme = colorContrast(colorScheme, theme)
217
- const msaAreaWidth = width - treeAreaWidth
218
248
 
219
- const ctx = new Context(msaAreaWidth, trackHeight) as any
249
+ const ctx = new Context(msaAreaWidth, trackHeight)
220
250
 
221
251
  renderAllTracks({
222
252
  model,
@@ -234,34 +264,11 @@ function TrackRendering({
234
264
  <rect x={0} y={0} width={msaAreaWidth} height={trackHeight} />
235
265
  </clipPath>
236
266
  </defs>
237
- <g
238
- clipPath={`url(#${clipId})`}
239
- dangerouslySetInnerHTML={{ __html: ctx.getSvg().innerHTML }}
240
- />
267
+ <ClipGroup clipId={clipId} html={ctx.getSvg().innerHTML} />
241
268
  </g>
242
269
  )
243
270
  }
244
271
 
245
- function MinimapWrapper({
246
- model,
247
- children,
248
- }: {
249
- model: MsaViewModel
250
- children: React.ReactNode
251
- }) {
252
- const { minimapHeight, treeAreaWidth } = model
253
-
254
- return (
255
- <>
256
- <g transform={`translate(${treeAreaWidth} 0)`}>
257
- <MinimapSVG model={model} />
258
- </g>
259
-
260
- <g transform={`translate(0 ${minimapHeight})`}>{children}</g>
261
- </>
262
- )
263
- }
264
-
265
272
  function SvgWrapper({
266
273
  width,
267
274
  height,
@@ -277,13 +284,10 @@ function SvgWrapper({
277
284
  height={height}
278
285
  xmlns="http://www.w3.org/2000/svg"
279
286
  xmlnsXlink="http://www.w3.org/1999/xlink"
280
- viewBox={[0, 0, width, height].toString()}
287
+ viewBox={`0 0 ${width} ${height}`}
281
288
  >
289
+ <rect width="100%" height="100%" fill="white" />
282
290
  {children}
283
291
  </svg>
284
292
  )
285
293
  }
286
-
287
- function NullWrapper({ children }: { children: React.ReactNode }) {
288
- return children
289
- }
@@ -1,7 +1,6 @@
1
1
  import { expect, test } from 'vitest'
2
2
 
3
3
  import {
4
- globalColToSeqPos,
5
4
  globalColToVisibleCol,
6
5
  visibleColToGlobalCol,
7
6
  visibleColToSeqPos,
@@ -126,56 +125,6 @@ test('globalColToVisibleCol with consecutive blanks', () => {
126
125
  expect(globalColToVisibleCol(blanks, 10)).toBe(5)
127
126
  })
128
127
 
129
- // Tests for globalColToSeqPos (global column → sequence position)
130
- test('globalColToSeqPos with gaps in sequence', () => {
131
- const sequence = 'AC-GT-A'
132
- expect(globalColToSeqPos(sequence, 0)).toBe(0)
133
- expect(globalColToSeqPos(sequence, 1)).toBe(1)
134
- expect(globalColToSeqPos(sequence, 2)).toBe(2)
135
- // Position 2 is a gap, so count before it is 2
136
- expect(globalColToSeqPos(sequence, 3)).toBe(2)
137
- expect(globalColToSeqPos(sequence, 4)).toBe(3)
138
- expect(globalColToSeqPos(sequence, 5)).toBe(4)
139
- // Position 5 is a gap, so count before it is 4
140
- expect(globalColToSeqPos(sequence, 6)).toBe(4)
141
- })
142
-
143
- test('globalColToSeqPos with mixed gap characters (- and .)', () => {
144
- const sequence = 'AC.GT-A'
145
- expect(globalColToSeqPos(sequence, 0)).toBe(0)
146
- expect(globalColToSeqPos(sequence, 1)).toBe(1)
147
- // Position 2 is a gap (.), so count before it is 2
148
- expect(globalColToSeqPos(sequence, 2)).toBe(2)
149
- expect(globalColToSeqPos(sequence, 3)).toBe(2)
150
- expect(globalColToSeqPos(sequence, 4)).toBe(3)
151
- // Position 5 is a gap (-), so count before it is 4
152
- expect(globalColToSeqPos(sequence, 5)).toBe(4)
153
- expect(globalColToSeqPos(sequence, 6)).toBe(4)
154
- })
155
-
156
- test('globalColToSeqPos with no gaps in sequence', () => {
157
- const sequence = 'ACGTA'
158
- expect(globalColToSeqPos(sequence, 0)).toBe(0)
159
- expect(globalColToSeqPos(sequence, 1)).toBe(1)
160
- expect(globalColToSeqPos(sequence, 2)).toBe(2)
161
- expect(globalColToSeqPos(sequence, 3)).toBe(3)
162
- expect(globalColToSeqPos(sequence, 4)).toBe(4)
163
- })
164
-
165
- test('globalColToSeqPos with all gaps in sequence', () => {
166
- const sequence = '-----'
167
- expect(globalColToSeqPos(sequence, 0)).toBe(0)
168
- expect(globalColToSeqPos(sequence, 1)).toBe(0)
169
- expect(globalColToSeqPos(sequence, 2)).toBe(0)
170
- expect(globalColToSeqPos(sequence, 3)).toBe(0)
171
- expect(globalColToSeqPos(sequence, 4)).toBe(0)
172
- })
173
-
174
- test('globalColToSeqPos with position exceeding sequence length', () => {
175
- const sequence = 'AC-GT'
176
- expect(globalColToSeqPos(sequence, 10)).toBe(4)
177
- })
178
-
179
128
  // Tests for visibleColToSeqPos (visible column → sequence position)
180
129
  test('visibleColToSeqPos returns sequence position or undefined for gaps', () => {
181
130
  const seq = 'AC--GT--CT'
@@ -88,29 +88,6 @@ export function globalColToVisibleCol(
88
88
  return globalCol - blanksBefore
89
89
  }
90
90
 
91
- /**
92
- * Convert a global column index to a row-specific sequence position.
93
- * This counts non-gap characters up to the given global column.
94
- *
95
- * @param seq - The row's sequence string (including gaps)
96
- * @param globalCol - The global column index
97
- * @returns The sequence position (count of non-gap characters before this column)
98
- */
99
- export function globalColToSeqPos(seq: string, globalCol: number) {
100
- let seqPos = 0
101
- let currentCol = 0
102
- const seqLen = seq.length
103
-
104
- while (currentCol < globalCol && currentCol < seqLen) {
105
- if (!isBlank(seq[currentCol])) {
106
- seqPos++
107
- }
108
- currentCol++
109
- }
110
-
111
- return seqPos
112
- }
113
-
114
91
  /**
115
92
  * Convert a visible column to a row-specific sequence position.
116
93
  * Returns undefined if the position is a gap in the sequence.
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { setFontSize } from './setFontSize.ts'
4
+
5
+ describe('setFontSize', () => {
6
+ test('replaces integer size, preserving family', () => {
7
+ const ctx = { font: '10px sans-serif' }
8
+ setFontSize(ctx, 16)
9
+ expect(ctx.font).toBe('16px sans-serif')
10
+ })
11
+
12
+ test('handles fractional sizes from smooth zoom', () => {
13
+ // regression: \d+ alone matched "8604" inside "14.8604px" and produced an
14
+ // invalid string like "14 0.18px sans-serif", silently breaking fillText
15
+ const ctx = { font: '14.8604px sans-serif' }
16
+ setFontSize(ctx, 16.9372)
17
+ expect(ctx.font).toBe('16.9372px sans-serif')
18
+ })
19
+
20
+ test('does not accumulate "bold " across re-renders', () => {
21
+ const ctx = { font: '10px sans-serif' }
22
+ setFontSize(ctx, 12, true)
23
+ expect(ctx.font).toBe('bold 12px sans-serif')
24
+ setFontSize(ctx, 14, true)
25
+ expect(ctx.font).toBe('bold 14px sans-serif')
26
+ })
27
+
28
+ test('drops "bold " when bold goes false', () => {
29
+ const ctx = { font: 'bold 12px sans-serif' }
30
+ setFontSize(ctx, 14)
31
+ expect(ctx.font).toBe('14px sans-serif')
32
+ })
33
+ })
@@ -0,0 +1,20 @@
1
+ // Replace just the px size token in a canvas font string, preserving the
2
+ // family (and optionally toggling bold). Accepts anything with a `font` field,
3
+ // so it works for both a real CanvasRenderingContext2D and the svgcanvas
4
+ // export context.
5
+ //
6
+ // [\d.]+ is load-bearing: it matches fractional sizes produced by smooth zoom.
7
+ // \d+ alone matches only the fraction ("8604" in "14.8604px"), splicing the new
8
+ // size into the middle to make an invalid string like "14 0.18px sans-serif" —
9
+ // which canvas silently rejects, freezing the font and making fillText draw
10
+ // nothing while fillRect still works.
11
+ export function setFontSize(
12
+ ctx: { font: string },
13
+ fontSize: number,
14
+ bold = false,
15
+ ) {
16
+ ctx.font = ctx.font.replace(
17
+ /(?:bold )?[\d.]+px/,
18
+ `${bold ? 'bold ' : ''}${fontSize}px`,
19
+ )
20
+ }
@@ -0,0 +1,29 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ // Subscribes to devicePixelRatio changes (moving the window between monitors,
4
+ // browser zoom). The matchMedia query is pinned to the current ratio, so on each
5
+ // change we re-register against the new ratio to keep tracking subsequent moves.
6
+ function subscribe(callback: () => void) {
7
+ if (typeof window === 'undefined') {
8
+ return () => {}
9
+ }
10
+ let mql = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
11
+ function onChange() {
12
+ callback()
13
+ mql.removeEventListener('change', onChange)
14
+ mql = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
15
+ mql.addEventListener('change', onChange)
16
+ }
17
+ mql.addEventListener('change', onChange)
18
+ return () => {
19
+ mql.removeEventListener('change', onChange)
20
+ }
21
+ }
22
+
23
+ function getSnapshot() {
24
+ return typeof window === 'undefined' ? 1 : window.devicePixelRatio
25
+ }
26
+
27
+ export function useDevicePixelRatio() {
28
+ return useSyncExternalStore(subscribe, getSnapshot, () => 1)
29
+ }
@@ -1,17 +1,33 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
 
3
+ import {
4
+ SCROLL_ZOOM_DIVISOR,
5
+ getZoomNormalizer,
6
+ normalizeWheelDelta,
7
+ zoomFactorFromAccum,
8
+ } from './wheelZoom.ts'
9
+
3
10
  export function useWheelScroll({
4
11
  ref,
5
12
  onScrollX,
6
13
  onScrollY,
14
+ onZoom,
15
+ scrollZoom,
7
16
  }: {
8
17
  ref: React.RefObject<HTMLDivElement | null>
9
18
  onScrollX?: (delta: number) => void
10
19
  onScrollY?: (delta: number) => void
20
+ onZoom?: (scaleFactor: number, offsetX: number, offsetY: number) => void
21
+ scrollZoom?: boolean
11
22
  }) {
12
23
  const scheduled = useRef(false)
24
+ const zoomScheduled = useRef(false)
13
25
  const deltaX = useRef(0)
14
26
  const deltaY = useRef(0)
27
+ const zoomAccum = useRef(0)
28
+ const zoomX = useRef(0)
29
+ const zoomY = useRef(0)
30
+ const lastZoomTime = useRef<number | null>(null)
15
31
  const prevX = useRef(0)
16
32
  const prevY = useRef(0)
17
33
  const [mouseDragging, setMouseDragging] = useState(false)
@@ -22,35 +38,77 @@ export function useWheelScroll({
22
38
  return
23
39
  }
24
40
  function onWheel(event: WheelEvent) {
25
- if (onScrollX) {
26
- deltaX.current += event.deltaX
27
- }
28
- if (onScrollY) {
29
- deltaY.current += event.deltaY
41
+ // ctrlKey is also set by trackpad pinch gestures on all platforms. when
42
+ // scrollZoom is on, a plain vertical-dominant wheel zooms too; holding
43
+ // shift is the escape hatch to pan instead.
44
+ const isCtrlZoom = event.ctrlKey || event.metaKey
45
+ const isScrollZoom =
46
+ scrollZoom &&
47
+ !event.shiftKey &&
48
+ Math.abs(event.deltaY) >= Math.abs(event.deltaX)
49
+ if (onZoom && (isCtrlZoom || isScrollZoom)) {
50
+ const rect = curr!.getBoundingClientRect()
51
+ // normalize away deltaMode (line/page), then divide by an adaptive
52
+ // factor so fine pinches and coarse notches feel consistent
53
+ const dY = normalizeWheelDelta(event.deltaY, event.deltaMode)
54
+ const divisor = isCtrlZoom ? getZoomNormalizer(dY) : SCROLL_ZOOM_DIVISOR
55
+ zoomAccum.current += dY / divisor
56
+ zoomX.current = event.clientX - rect.left
57
+ zoomY.current = event.clientY - rect.top
58
+ if (!zoomScheduled.current) {
59
+ zoomScheduled.current = true
60
+ requestAnimationFrame(now => {
61
+ const elapsed =
62
+ lastZoomTime.current === null ? 16.67 : now - lastZoomTime.current
63
+ lastZoomTime.current = now
64
+ onZoom(
65
+ zoomFactorFromAccum(zoomAccum.current, Math.min(100, elapsed)),
66
+ zoomX.current,
67
+ zoomY.current,
68
+ )
69
+ zoomAccum.current = 0
70
+ zoomScheduled.current = false
71
+ })
72
+ }
73
+ event.preventDefault()
74
+ event.stopPropagation()
75
+ return
30
76
  }
77
+ // only swallow the wheel event on an axis we actually handle, otherwise a
78
+ // vertical wheel over an x-only scroller (e.g. a Track) blocks page scroll
79
+ const consumeX = !!onScrollX && event.deltaX !== 0
80
+ const consumeY = !!onScrollY && event.deltaY !== 0
81
+ if (consumeX || consumeY) {
82
+ if (onScrollX) {
83
+ deltaX.current += event.deltaX
84
+ }
85
+ if (onScrollY) {
86
+ deltaY.current += event.deltaY
87
+ }
31
88
 
32
- if (!scheduled.current) {
33
- scheduled.current = true
34
- requestAnimationFrame(() => {
35
- if (onScrollX) {
36
- onScrollX(-deltaX.current)
37
- deltaX.current = 0
38
- }
39
- if (onScrollY) {
40
- onScrollY(-deltaY.current)
41
- deltaY.current = 0
42
- }
43
- scheduled.current = false
44
- })
89
+ if (!scheduled.current) {
90
+ scheduled.current = true
91
+ requestAnimationFrame(() => {
92
+ if (onScrollX) {
93
+ onScrollX(-deltaX.current)
94
+ deltaX.current = 0
95
+ }
96
+ if (onScrollY) {
97
+ onScrollY(-deltaY.current)
98
+ deltaY.current = 0
99
+ }
100
+ scheduled.current = false
101
+ })
102
+ }
103
+ event.preventDefault()
104
+ event.stopPropagation()
45
105
  }
46
- event.preventDefault()
47
- event.stopPropagation()
48
106
  }
49
107
  curr.addEventListener('wheel', onWheel, { passive: false })
50
108
  return () => {
51
109
  curr.removeEventListener('wheel', onWheel)
52
110
  }
53
- }, [ref, onScrollX, onScrollY])
111
+ }, [ref, onScrollX, onScrollY, onZoom, scrollZoom])
54
112
 
55
113
  useEffect(() => {
56
114
  if (!mouseDragging) {