react-msaview 5.2.0 → 5.3.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.
- package/README.md +41 -8
- package/bundle/index.js +17 -17
- package/bundle/index.js.LICENSE.txt +1 -1
- package/bundle/index.js.map +1 -1
- package/dist/components/ConservationTrack.js +2 -13
- package/dist/components/ConservationTrack.js.map +1 -1
- package/dist/components/MSAView.js +8 -1
- package/dist/components/MSAView.js.map +1 -1
- package/dist/components/ResizeHandles.d.ts +3 -0
- package/dist/components/ResizeHandles.js +60 -105
- package/dist/components/ResizeHandles.js.map +1 -1
- package/dist/components/TextTrack.js +2 -1
- package/dist/components/TextTrack.js.map +1 -1
- package/dist/components/Track.js +1 -1
- package/dist/components/Track.js.map +1 -1
- package/dist/components/VerticalScrollbar.js +2 -2
- package/dist/components/VerticalScrollbar.js.map +1 -1
- package/dist/components/dialogs/UserProvidedDomainsDialog.js +1 -2
- package/dist/components/dialogs/UserProvidedDomainsDialog.js.map +1 -1
- package/dist/components/header/ColorSchemeMenu.js +1 -1
- package/dist/components/header/ColorSchemeMenu.js.map +1 -1
- package/dist/components/header/FileMenu.js +1 -1
- package/dist/components/header/FileMenu.js.map +1 -1
- package/dist/components/header/HeaderInfoArea.js +28 -8
- package/dist/components/header/HeaderInfoArea.js.map +1 -1
- package/dist/components/header/MSASettingsMenu.js +1 -1
- package/dist/components/header/MSASettingsMenu.js.map +1 -1
- package/dist/components/header/TreeSettingsMenu.js +1 -1
- package/dist/components/header/TreeSettingsMenu.js.map +1 -1
- package/dist/components/header/ZoomControls.js +7 -2
- package/dist/components/header/ZoomControls.js.map +1 -1
- package/dist/components/header/ZoomMenu.js +9 -0
- package/dist/components/header/ZoomMenu.js.map +1 -1
- package/dist/components/minimap/Minimap.js +6 -12
- package/dist/components/minimap/Minimap.js.map +1 -1
- package/dist/components/minimap/MinimapSVG.js +7 -13
- package/dist/components/minimap/MinimapSVG.js.map +1 -1
- package/dist/components/minimap/minimapLayout.d.ts +9 -0
- package/dist/components/minimap/minimapLayout.js +15 -0
- package/dist/components/minimap/minimapLayout.js.map +1 -0
- package/dist/components/msa/MSACanvas.js +6 -1
- package/dist/components/msa/MSACanvas.js.map +1 -1
- package/dist/components/msa/MSACanvasBlock.js +5 -1
- package/dist/components/msa/MSACanvasBlock.js.map +1 -1
- package/dist/components/msa/MSAMouseoverCanvas.js +2 -2
- package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
- package/dist/components/msa/renderMSABlock.js +5 -7
- package/dist/components/msa/renderMSABlock.js.map +1 -1
- package/dist/components/msa/renderMSAMouseover.js +3 -2
- package/dist/components/msa/renderMSAMouseover.js.map +1 -1
- package/dist/components/tracks/renderTracksSvg.js +2 -1
- package/dist/components/tracks/renderTracksSvg.js.map +1 -1
- package/dist/components/tree/renderTreeCanvas.js +2 -2
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +7 -0
- package/dist/constants.js.map +1 -1
- package/dist/flatToTree.js +6 -0
- package/dist/flatToTree.js.map +1 -1
- package/dist/hierarchy.d.ts +1 -1
- package/dist/hierarchy.js +136 -102
- package/dist/hierarchy.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/launchInterProScan.js +22 -9
- package/dist/launchInterProScan.js.map +1 -1
- package/dist/measureTextCanvas.js +2 -1
- package/dist/measureTextCanvas.js.map +1 -1
- package/dist/model.d.ts +212 -150
- package/dist/model.js +239 -205
- package/dist/model.js.map +1 -1
- package/dist/parseAsn1.js +8 -2
- package/dist/parseAsn1.js.map +1 -1
- package/dist/renderToSvg.js +27 -24
- package/dist/renderToSvg.js.map +1 -1
- package/dist/setFontSize.d.ts +3 -0
- package/dist/setFontSize.js +14 -0
- package/dist/setFontSize.js.map +1 -0
- package/dist/useDevicePixelRatio.d.ts +1 -0
- package/dist/useDevicePixelRatio.js +27 -0
- package/dist/useDevicePixelRatio.js.map +1 -0
- package/dist/useWheelScroll.d.ts +3 -1
- package/dist/useWheelScroll.js +64 -22
- package/dist/useWheelScroll.js.map +1 -1
- package/dist/util.d.ts +4 -0
- package/dist/util.js +36 -0
- package/dist/util.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webpack.js +0 -1
- package/dist/webpack.js.map +1 -1
- package/dist/wheelZoom.d.ts +4 -0
- package/dist/wheelZoom.js +43 -0
- package/dist/wheelZoom.js.map +1 -0
- package/package.json +5 -5
- package/src/components/ConservationTrack.tsx +2 -18
- package/src/components/MSAView.tsx +10 -1
- package/src/components/ResizeHandles.tsx +101 -113
- package/src/components/TextTrack.tsx +2 -1
- package/src/components/Track.tsx +1 -1
- package/src/components/VerticalScrollbar.tsx +2 -2
- package/src/components/dialogs/UserProvidedDomainsDialog.tsx +1 -2
- package/src/components/header/ColorSchemeMenu.tsx +1 -0
- package/src/components/header/FileMenu.tsx +1 -0
- package/src/components/header/HeaderInfoArea.tsx +32 -7
- package/src/components/header/MSASettingsMenu.tsx +1 -0
- package/src/components/header/TreeSettingsMenu.tsx +1 -0
- package/src/components/header/ZoomControls.tsx +14 -1
- package/src/components/header/ZoomMenu.tsx +10 -0
- package/src/components/minimap/Minimap.tsx +6 -15
- package/src/components/minimap/MinimapSVG.tsx +9 -22
- package/src/components/minimap/minimapLayout.ts +17 -0
- package/src/components/msa/MSACanvas.tsx +9 -0
- package/src/components/msa/MSACanvasBlock.tsx +7 -1
- package/src/components/msa/MSAMouseoverCanvas.tsx +4 -3
- package/src/components/msa/renderMSABlock.ts +6 -9
- package/src/components/msa/renderMSAMouseover.ts +3 -1
- package/src/components/tracks/renderTracksSvg.ts +2 -1
- package/src/components/tree/renderTreeCanvas.ts +2 -2
- package/src/constants.ts +8 -0
- package/src/flatToTree.test.ts +30 -0
- package/src/flatToTree.ts +10 -1
- package/src/hierarchy.test.ts +53 -1
- package/src/hierarchy.ts +139 -113
- package/src/index.ts +1 -0
- package/src/launchInterProScan.ts +33 -11
- package/src/measureTextCanvas.ts +3 -1
- package/src/model.ts +312 -242
- package/src/parseAsn1.test.ts +6 -0
- package/src/parseAsn1.ts +11 -3
- package/src/renderToSvg.tsx +79 -67
- package/src/setFontSize.test.ts +33 -0
- package/src/setFontSize.ts +20 -0
- package/src/useDevicePixelRatio.ts +29 -0
- package/src/useWheelScroll.ts +79 -21
- package/src/util.ts +35 -0
- package/src/version.ts +1 -1
- package/src/webpack.ts +0 -1
- package/src/wheelZoom.test.ts +46 -0
- package/src/wheelZoom.ts +47 -0
package/src/parseAsn1.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
}
|
package/src/renderToSvg.tsx
CHANGED
|
@@ -22,10 +22,20 @@ export interface ExportSvgOptions {
|
|
|
22
22
|
exportType: 'entire' | 'viewport'
|
|
23
23
|
}
|
|
24
24
|
export async function renderToSvg(model: MsaViewModel, opts: ExportSvgOptions) {
|
|
25
|
-
await when(() =>
|
|
26
|
-
const {
|
|
27
|
-
|
|
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({
|
|
@@ -43,7 +53,7 @@ export async function renderToSvg(model: MsaViewModel, opts: ExportSvgOptions) {
|
|
|
43
53
|
}
|
|
44
54
|
return render({
|
|
45
55
|
width,
|
|
46
|
-
height: height + (includeMinimap ?
|
|
56
|
+
height: height + (includeMinimap ? minimapHeight : 0) + trackHeight,
|
|
47
57
|
contentHeight: height,
|
|
48
58
|
trackHeight,
|
|
49
59
|
theme,
|
|
@@ -79,37 +89,48 @@ async function render({
|
|
|
79
89
|
includeTracks?: boolean
|
|
80
90
|
}) {
|
|
81
91
|
const { Context } = await import('@jbrowse/svgcanvas')
|
|
82
|
-
const
|
|
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
|
+
)
|
|
83
121
|
|
|
84
122
|
return renderToStaticMarkup(
|
|
85
123
|
<SvgWrapper width={width} height={height}>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
) : null}
|
|
97
|
-
<g
|
|
98
|
-
transform={
|
|
99
|
-
trackHeight > 0 ? `translate(0 ${trackHeight})` : undefined
|
|
100
|
-
}
|
|
101
|
-
>
|
|
102
|
-
<CoreRendering
|
|
103
|
-
Context={Context}
|
|
104
|
-
model={model}
|
|
105
|
-
theme={theme}
|
|
106
|
-
offsetX={offsetX}
|
|
107
|
-
offsetY={offsetY}
|
|
108
|
-
msaAreaWidth={width - model.treeAreaWidth}
|
|
109
|
-
contentHeight={contentHeight}
|
|
110
|
-
/>
|
|
111
|
-
</g>
|
|
112
|
-
</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
|
+
)}
|
|
113
134
|
</SvgWrapper>,
|
|
114
135
|
)
|
|
115
136
|
}
|
|
@@ -176,19 +197,36 @@ function CoreRendering({
|
|
|
176
197
|
</clipPath>
|
|
177
198
|
</defs>
|
|
178
199
|
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
/>
|
|
183
|
-
<g
|
|
184
|
-
clipPath={`url(#${clipId2})`}
|
|
200
|
+
<ClipGroup clipId={clipId1} html={ctx1.getSvg().innerHTML} />
|
|
201
|
+
<ClipGroup
|
|
202
|
+
clipId={clipId2}
|
|
185
203
|
transform={`translate(${treeAreaWidth} 0)`}
|
|
186
|
-
|
|
204
|
+
html={ctx2.getSvg().innerHTML}
|
|
187
205
|
/>
|
|
188
206
|
</>
|
|
189
207
|
)
|
|
190
208
|
}
|
|
191
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
|
+
|
|
192
230
|
function TrackRendering({
|
|
193
231
|
model,
|
|
194
232
|
theme,
|
|
@@ -226,34 +264,11 @@ function TrackRendering({
|
|
|
226
264
|
<rect x={0} y={0} width={msaAreaWidth} height={trackHeight} />
|
|
227
265
|
</clipPath>
|
|
228
266
|
</defs>
|
|
229
|
-
<
|
|
230
|
-
clipPath={`url(#${clipId})`}
|
|
231
|
-
dangerouslySetInnerHTML={{ __html: ctx.getSvg().innerHTML }}
|
|
232
|
-
/>
|
|
267
|
+
<ClipGroup clipId={clipId} html={ctx.getSvg().innerHTML} />
|
|
233
268
|
</g>
|
|
234
269
|
)
|
|
235
270
|
}
|
|
236
271
|
|
|
237
|
-
function MinimapWrapper({
|
|
238
|
-
model,
|
|
239
|
-
children,
|
|
240
|
-
}: {
|
|
241
|
-
model: MsaViewModel
|
|
242
|
-
children: React.ReactNode
|
|
243
|
-
}) {
|
|
244
|
-
const { minimapHeight, treeAreaWidth } = model
|
|
245
|
-
|
|
246
|
-
return (
|
|
247
|
-
<>
|
|
248
|
-
<g transform={`translate(${treeAreaWidth} 0)`}>
|
|
249
|
-
<MinimapSVG model={model} />
|
|
250
|
-
</g>
|
|
251
|
-
|
|
252
|
-
<g transform={`translate(0 ${minimapHeight})`}>{children}</g>
|
|
253
|
-
</>
|
|
254
|
-
)
|
|
255
|
-
}
|
|
256
|
-
|
|
257
272
|
function SvgWrapper({
|
|
258
273
|
width,
|
|
259
274
|
height,
|
|
@@ -271,11 +286,8 @@ function SvgWrapper({
|
|
|
271
286
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
|
272
287
|
viewBox={`0 0 ${width} ${height}`}
|
|
273
288
|
>
|
|
289
|
+
<rect width="100%" height="100%" fill="white" />
|
|
274
290
|
{children}
|
|
275
291
|
</svg>
|
|
276
292
|
)
|
|
277
293
|
}
|
|
278
|
-
|
|
279
|
-
function NullWrapper({ children }: { children: React.ReactNode }) {
|
|
280
|
-
return children
|
|
281
|
-
}
|
|
@@ -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
|
+
}
|
package/src/useWheelScroll.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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) {
|
package/src/util.ts
CHANGED
|
@@ -64,6 +64,41 @@ export function skipBlanks(blanks: number[], str: string) {
|
|
|
64
64
|
return chunks.join('')
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export function computeRowInsertions(blanks: number[], seq: string) {
|
|
68
|
+
const insertions: { pos: number; letters: string }[] = []
|
|
69
|
+
const blanksLen = blanks.length
|
|
70
|
+
let displayPos = 0
|
|
71
|
+
let blankIdx = 0
|
|
72
|
+
let currentInsertPos = -1
|
|
73
|
+
let letterChars: string[] = []
|
|
74
|
+
for (let i = 0; i < seq.length; i++) {
|
|
75
|
+
if (blankIdx < blanksLen && blanks[blankIdx] === i) {
|
|
76
|
+
// bit trick: (code - 45) >>> 0 <= 1 checks for '-' (45) or '.' (46)
|
|
77
|
+
if (!((seq.charCodeAt(i) - 45) >>> 0 <= 1)) {
|
|
78
|
+
if (currentInsertPos === displayPos) {
|
|
79
|
+
letterChars.push(seq[i]!)
|
|
80
|
+
} else {
|
|
81
|
+
if (letterChars.length > 0) {
|
|
82
|
+
insertions.push({
|
|
83
|
+
pos: currentInsertPos,
|
|
84
|
+
letters: letterChars.join(''),
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
currentInsertPos = displayPos
|
|
88
|
+
letterChars = [seq[i]!]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
blankIdx++
|
|
92
|
+
} else {
|
|
93
|
+
displayPos++
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (letterChars.length > 0) {
|
|
97
|
+
insertions.push({ pos: currentInsertPos, letters: letterChars.join('') })
|
|
98
|
+
}
|
|
99
|
+
return insertions
|
|
100
|
+
}
|
|
101
|
+
|
|
67
102
|
export function len(a: { end: number; start: number }) {
|
|
68
103
|
return a.end - a.start
|
|
69
104
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '5.
|
|
1
|
+
export const version = '5.3.0'
|
package/src/webpack.ts
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getZoomNormalizer,
|
|
5
|
+
normalizeWheelDelta,
|
|
6
|
+
zoomFactorFromAccum,
|
|
7
|
+
} from './wheelZoom.ts'
|
|
8
|
+
|
|
9
|
+
describe('normalizeWheelDelta', () => {
|
|
10
|
+
test('pixel mode passes through', () => {
|
|
11
|
+
expect(normalizeWheelDelta(120, 0)).toBe(120)
|
|
12
|
+
})
|
|
13
|
+
test('line mode scales to pixels', () => {
|
|
14
|
+
expect(normalizeWheelDelta(3, 1)).toBe(48)
|
|
15
|
+
})
|
|
16
|
+
test('page mode scales to pixels', () => {
|
|
17
|
+
expect(normalizeWheelDelta(2, 2)).toBe(200)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('getZoomNormalizer', () => {
|
|
22
|
+
test('tiny pinch deltas get the smallest divisor (largest step)', () => {
|
|
23
|
+
expect(getZoomNormalizer(3)).toBe(25)
|
|
24
|
+
})
|
|
25
|
+
test('large mouse notches get the largest divisor (finest step)', () => {
|
|
26
|
+
expect(getZoomNormalizer(200)).toBe(500)
|
|
27
|
+
})
|
|
28
|
+
test('medium deltas land in between', () => {
|
|
29
|
+
expect(getZoomNormalizer(50)).toBe(150)
|
|
30
|
+
expect(getZoomNormalizer(20)).toBe(75)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('zoomFactorFromAccum', () => {
|
|
35
|
+
test('negative accum zooms in (factor > 1)', () => {
|
|
36
|
+
expect(zoomFactorFromAccum(-0.1, 16.67)).toBeCloseTo(1.1)
|
|
37
|
+
})
|
|
38
|
+
test('positive accum zooms out (factor < 1)', () => {
|
|
39
|
+
expect(zoomFactorFromAccum(0.1, 16.67)).toBeCloseTo(1 / 1.1)
|
|
40
|
+
})
|
|
41
|
+
test('rate-limits a big burst to ~0.2 per frame', () => {
|
|
42
|
+
// a huge accumulated delta cannot zoom more than the per-frame cap
|
|
43
|
+
expect(zoomFactorFromAccum(99, 16.67)).toBeCloseTo(1 / 1.2, 2)
|
|
44
|
+
expect(zoomFactorFromAccum(-99, 16.67)).toBeCloseTo(1.2, 2)
|
|
45
|
+
})
|
|
46
|
+
})
|
package/src/wheelZoom.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Wheel / trackpad-pinch delta handling, adapted from @jbrowse/core's
|
|
2
|
+
// util/wheelZoom (not yet in our pinned @jbrowse/core). Turns raw wheel deltas
|
|
3
|
+
// into a per-frame zoom factor that feels consistent across fine trackpad
|
|
4
|
+
// pinches and coarse mouse notches.
|
|
5
|
+
|
|
6
|
+
// Fixed divisor for plain-wheel scroll-zoom mode, where deltas are big notches.
|
|
7
|
+
export const SCROLL_ZOOM_DIVISOR = 500
|
|
8
|
+
|
|
9
|
+
// Cap the zoom change per millisecond so a burst of momentum-scroll events
|
|
10
|
+
// coalesced into one frame can't jump the zoom level (0.2 per ~60fps frame).
|
|
11
|
+
const MAX_ZOOM_RATE_PER_MS = 0.2 / 16.67
|
|
12
|
+
|
|
13
|
+
// Convert a wheel delta to pixels regardless of the browser's deltaMode; some
|
|
14
|
+
// browsers/mice report lines or pages instead of pixels.
|
|
15
|
+
export function normalizeWheelDelta(delta: number, mode: number) {
|
|
16
|
+
if (mode === 1) {
|
|
17
|
+
return delta * 16 // DOM_DELTA_LINE
|
|
18
|
+
}
|
|
19
|
+
if (mode === 2) {
|
|
20
|
+
return delta * 100 // DOM_DELTA_PAGE
|
|
21
|
+
}
|
|
22
|
+
return delta // DOM_DELTA_PIXEL
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Adaptive divisor: tiny pinch deltas zoom in larger steps, big mouse notches
|
|
26
|
+
// in finer steps, so a trackpad pinch and a mouse wheel feel similar.
|
|
27
|
+
export function getZoomNormalizer(deltaY: number) {
|
|
28
|
+
const abs = Math.abs(deltaY)
|
|
29
|
+
if (abs < 6) {
|
|
30
|
+
return 25
|
|
31
|
+
}
|
|
32
|
+
if (abs > 150) {
|
|
33
|
+
return 500
|
|
34
|
+
}
|
|
35
|
+
if (abs > 30) {
|
|
36
|
+
return 150
|
|
37
|
+
}
|
|
38
|
+
return 75
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Turn accumulated, normalized zoom intent into a colWidth/rowHeight multiplier.
|
|
42
|
+
// Positive accum (wheel toward user) zooms out (factor < 1); negative zooms in.
|
|
43
|
+
export function zoomFactorFromAccum(zoomAccum: number, elapsedMs: number) {
|
|
44
|
+
const max = MAX_ZOOM_RATE_PER_MS * elapsedMs
|
|
45
|
+
const d = Math.max(-max, Math.min(max, zoomAccum))
|
|
46
|
+
return d > 0 ? 1 / (1 + d) : 1 - d
|
|
47
|
+
}
|