@jbrowse/render-core 4.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/LICENSE +201 -0
- package/esm/OverlayCanvas.d.ts +29 -0
- package/esm/OverlayCanvas.js +43 -0
- package/esm/RenderCanvas.d.ts +76 -0
- package/esm/RenderCanvas.js +38 -0
- package/esm/RenderLifecycleMixin.d.ts +180 -0
- package/esm/RenderLifecycleMixin.js +279 -0
- package/esm/ScrollLockedOverlay.d.ts +28 -0
- package/esm/ScrollLockedOverlay.js +42 -0
- package/esm/blockClipUtils.d.ts +27 -0
- package/esm/blockClipUtils.js +101 -0
- package/esm/canvas2dUtils.d.ts +372 -0
- package/esm/canvas2dUtils.js +564 -0
- package/esm/canvasContext.d.ts +68 -0
- package/esm/canvasContext.js +74 -0
- package/esm/colorRampLut.d.ts +17 -0
- package/esm/colorRampLut.js +25 -0
- package/esm/coverageBand.d.ts +107 -0
- package/esm/coverageBand.js +109 -0
- package/esm/coverageBandBuffers.d.ts +30 -0
- package/esm/coverageBandBuffers.js +13 -0
- package/esm/createRenderingBackend.d.ts +52 -0
- package/esm/createRenderingBackend.js +58 -0
- package/esm/displayPhase.d.ts +155 -0
- package/esm/displayPhase.js +97 -0
- package/esm/globalRenderingBackend.d.ts +90 -0
- package/esm/globalRenderingBackend.js +53 -0
- package/esm/gpuDevice.d.ts +91 -0
- package/esm/gpuDevice.js +277 -0
- package/esm/graphicsCapabilities.d.ts +98 -0
- package/esm/graphicsCapabilities.js +165 -0
- package/esm/hal/createHal.d.ts +22 -0
- package/esm/hal/createHal.js +91 -0
- package/esm/hal/deviceGpuCache.d.ts +36 -0
- package/esm/hal/deviceGpuCache.js +120 -0
- package/esm/hal/gpuHalBase.d.ts +55 -0
- package/esm/hal/gpuHalBase.js +81 -0
- package/esm/hal/index.d.ts +7 -0
- package/esm/hal/index.js +5 -0
- package/esm/hal/mockHal.d.ts +153 -0
- package/esm/hal/mockHal.js +297 -0
- package/esm/hal/oomReporter.d.ts +15 -0
- package/esm/hal/oomReporter.js +22 -0
- package/esm/hal/passIds.d.ts +22 -0
- package/esm/hal/passIds.js +36 -0
- package/esm/hal/regionRegistry.d.ts +11 -0
- package/esm/hal/regionRegistry.js +66 -0
- package/esm/hal/types.d.ts +116 -0
- package/esm/hal/types.js +1 -0
- package/esm/hal/webgl2Hal.d.ts +47 -0
- package/esm/hal/webgl2Hal.js +490 -0
- package/esm/hal/webgpuHal.d.ts +169 -0
- package/esm/hal/webgpuHal.js +722 -0
- package/esm/installUpload.d.ts +91 -0
- package/esm/installUpload.js +94 -0
- package/esm/instanceCache.d.ts +43 -0
- package/esm/instanceCache.js +53 -0
- package/esm/instancePass.d.ts +50 -0
- package/esm/instancePass.js +34 -0
- package/esm/keyedRenderingBackend.d.ts +41 -0
- package/esm/keyedRenderingBackend.js +19 -0
- package/esm/mapUploadSync.d.ts +31 -0
- package/esm/mapUploadSync.js +45 -0
- package/esm/namedReactions.d.ts +29 -0
- package/esm/namedReactions.js +62 -0
- package/esm/perRegionRenderingBackend.d.ts +114 -0
- package/esm/perRegionRenderingBackend.js +93 -0
- package/esm/recoveryBudget.d.ts +40 -0
- package/esm/recoveryBudget.js +56 -0
- package/esm/regionDataMap.d.ts +60 -0
- package/esm/regionDataMap.js +98 -0
- package/esm/renderBlock.d.ts +25 -0
- package/esm/renderBlock.js +10 -0
- package/esm/renderingBackendBase.d.ts +71 -0
- package/esm/renderingBackendBase.js +50 -0
- package/esm/shaders/antialias.js.generated.d.ts +4 -0
- package/esm/shaders/antialias.js.generated.js +25 -0
- package/esm/shaders/capsule.generated.d.ts +1 -0
- package/esm/shaders/capsule.generated.js +3 -0
- package/esm/shaders/colorRampLut.generated.d.ts +1 -0
- package/esm/shaders/colorRampLut.generated.js +3 -0
- package/esm/shaders/coverageBand.generated.d.ts +2 -0
- package/esm/shaders/coverageBand.generated.js +4 -0
- package/esm/shaders/coverageBar.generated.d.ts +4 -0
- package/esm/shaders/coverageBar.generated.js +6 -0
- package/esm/shaders/coverageBar.iface.generated.d.ts +85 -0
- package/esm/shaders/coverageBar.iface.generated.js +114 -0
- package/esm/shaders/coverageIndicator.generated.d.ts +4 -0
- package/esm/shaders/coverageIndicator.generated.js +6 -0
- package/esm/shaders/coverageIndicator.iface.generated.d.ts +85 -0
- package/esm/shaders/coverageIndicator.iface.generated.js +114 -0
- package/esm/shaders/coverageInterbase.generated.d.ts +4 -0
- package/esm/shaders/coverageInterbase.generated.js +6 -0
- package/esm/shaders/coverageInterbase.iface.generated.d.ts +93 -0
- package/esm/shaders/coverageInterbase.iface.generated.js +134 -0
- package/esm/shaders/coverageMod.generated.d.ts +4 -0
- package/esm/shaders/coverageMod.generated.js +6 -0
- package/esm/shaders/coverageMod.iface.generated.d.ts +97 -0
- package/esm/shaders/coverageMod.iface.generated.js +144 -0
- package/esm/shaders/coverageSnp.generated.d.ts +4 -0
- package/esm/shaders/coverageSnp.generated.js +6 -0
- package/esm/shaders/coverageSnp.iface.generated.d.ts +97 -0
- package/esm/shaders/coverageSnp.iface.generated.js +144 -0
- package/esm/shaders/hpmath.js.generated.d.ts +3 -0
- package/esm/shaders/hpmath.js.generated.js +48 -0
- package/esm/shaders/pointGlyph.generated.d.ts +1 -0
- package/esm/shaders/pointGlyph.generated.js +3 -0
- package/esm/shaders/pointGlyph.js.generated.d.ts +1 -0
- package/esm/shaders/pointGlyph.js.generated.js +7 -0
- package/esm/shaders/rowRect.generated.d.ts +3 -0
- package/esm/shaders/rowRect.generated.js +5 -0
- package/esm/shaders/rowRect.js.generated.d.ts +2 -0
- package/esm/shaders/rowRect.js.generated.js +13 -0
- package/esm/shaders/scoreScale.js.generated.d.ts +1 -0
- package/esm/shaders/scoreScale.js.generated.js +46 -0
- package/esm/slangPass.d.ts +49 -0
- package/esm/slangPass.js +22 -0
- package/esm/useEventCallback.d.ts +1 -0
- package/esm/useEventCallback.js +19 -0
- package/esm/useGraphicsCapabilities.d.ts +17 -0
- package/esm/useGraphicsCapabilities.js +38 -0
- package/esm/useRenderingBackend.d.ts +83 -0
- package/esm/useRenderingBackend.js +422 -0
- package/esm/useTabVisibilityRerender.d.ts +13 -0
- package/esm/useTabVisibilityRerender.js +39 -0
- package/esm/webgpuUtils.d.ts +23 -0
- package/esm/webgpuUtils.js +76 -0
- package/package.json +91 -0
- package/src/shaders/antialias.js.generated.ts +33 -0
- package/src/shaders/antialias.slang +112 -0
- package/src/shaders/capsule.generated.ts +4 -0
- package/src/shaders/capsule.slang +62 -0
- package/src/shaders/colorPack.slang +26 -0
- package/src/shaders/colorRampLut.generated.ts +4 -0
- package/src/shaders/colorRampLut.slang +51 -0
- package/src/shaders/coverageBand.generated.ts +6 -0
- package/src/shaders/coverageBand.slang +276 -0
- package/src/shaders/coverageBar.generated.ts +10 -0
- package/src/shaders/coverageBar.iface.generated.ts +172 -0
- package/src/shaders/coverageBar.slang +37 -0
- package/src/shaders/coverageIndicator.generated.ts +10 -0
- package/src/shaders/coverageIndicator.iface.generated.ts +172 -0
- package/src/shaders/coverageIndicator.slang +61 -0
- package/src/shaders/coverageInterbase.generated.ts +10 -0
- package/src/shaders/coverageInterbase.iface.generated.ts +198 -0
- package/src/shaders/coverageInterbase.slang +63 -0
- package/src/shaders/coverageMod.generated.ts +10 -0
- package/src/shaders/coverageMod.iface.generated.ts +211 -0
- package/src/shaders/coverageMod.slang +36 -0
- package/src/shaders/coverageSnp.generated.ts +10 -0
- package/src/shaders/coverageSnp.iface.generated.ts +211 -0
- package/src/shaders/coverageSnp.slang +52 -0
- package/src/shaders/diagonalGrid.slang +25 -0
- package/src/shaders/glyphEdgeAlpha.test.ts +241 -0
- package/src/shaders/hpmath.js.generated.ts +47 -0
- package/src/shaders/hpmath.slang +226 -0
- package/src/shaders/pointGlyph.generated.ts +4 -0
- package/src/shaders/pointGlyph.js.generated.ts +9 -0
- package/src/shaders/pointGlyph.slang +93 -0
- package/src/shaders/rowRect.generated.ts +8 -0
- package/src/shaders/rowRect.js.generated.ts +17 -0
- package/src/shaders/rowRect.slang +160 -0
- package/src/shaders/scoreScale.js.generated.ts +51 -0
- package/src/shaders/scoreScale.slang +68 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// Ceiling on the ratio we render at. Cost scales with the square: a dpr=3 phone
|
|
2
|
+
// would allocate 9x the pixels of dpr=1 and shade 9x the fragments, for a
|
|
3
|
+
// difference essentially nobody resolves past 2x. Retina (2) is the target and
|
|
4
|
+
// is unaffected; only dpr>2 devices are capped.
|
|
5
|
+
//
|
|
6
|
+
// It also buys headroom against MAX_CANVAS_DIM_PX: a canvas has to reach 4096
|
|
7
|
+
// CSS px on an axis to clamp now, instead of 2731 at dpr=3.
|
|
8
|
+
export const MAX_DPR = 2;
|
|
9
|
+
/**
|
|
10
|
+
* The ratio every canvas in the app renders at, capped at {@link MAX_DPR}.
|
|
11
|
+
*
|
|
12
|
+
* Use this, never a bare `devicePixelRatio` read — the value has to agree
|
|
13
|
+
* across the backing-store sizing (`syncCanvasSize` / `prepareCanvas`), the
|
|
14
|
+
* scissor/viewport rects derived from it (`clipBlock`, alignments'
|
|
15
|
+
* `computeBlockGeom`), and any shader uniform that reconstructs backing-store
|
|
16
|
+
* dimensions from CSS ones. A call site that reads the global directly renders
|
|
17
|
+
* geometry at a different scale than the canvas it lands on.
|
|
18
|
+
*
|
|
19
|
+
* Two places legitimately want something other than this and should keep saying
|
|
20
|
+
* so explicitly: `createSvgRasterCanvas` pins 2x because export goes to a file
|
|
21
|
+
* rather than a screen, and the analytics / error-report paths read the raw
|
|
22
|
+
* global because they are reporting the device, not drawing on it.
|
|
23
|
+
*/
|
|
24
|
+
export function getDpr() {
|
|
25
|
+
return typeof devicePixelRatio === 'undefined'
|
|
26
|
+
? 1
|
|
27
|
+
: Math.min(devicePixelRatio, MAX_DPR);
|
|
28
|
+
}
|
|
29
|
+
// Conservative ceiling (physical/backing px) for a single canvas dimension.
|
|
30
|
+
// Browsers throw `InvalidStateError: Canvas exceeds max size` once the backing
|
|
31
|
+
// store crosses their (browser/GPU-specific) limit — Firefox caps the area,
|
|
32
|
+
// WebGL caps a texture at MAX_TEXTURE_SIZE (>= 8192 on essentially all real
|
|
33
|
+
// hardware), Safari/Chrome cap the dimension. 8192 is under all of these, so a
|
|
34
|
+
// canvas whose backing store stays within it on both axes can't throw. Callers
|
|
35
|
+
// that can produce arbitrarily tall/wide content (e.g. the MAF rows canvas with
|
|
36
|
+
// hundreds of species) bound their CSS size against `MAX_CANVAS_DIM_PX / dpr`.
|
|
37
|
+
export const MAX_CANVAS_DIM_PX = 8192;
|
|
38
|
+
/**
|
|
39
|
+
* The largest CSS-px extent a canvas may take on one axis before its backing
|
|
40
|
+
* store (`× dpr`) crosses the ceiling above.
|
|
41
|
+
*
|
|
42
|
+
* For displays that size a canvas to their **content** rather than to a
|
|
43
|
+
* viewport — a row stack that grows with the row count, so nothing downstream
|
|
44
|
+
* bounds it. Past this the backing store stops tracking the CSS size
|
|
45
|
+
* (`syncCanvasSize` clamps it) while scissor/viewport rects are still derived
|
|
46
|
+
* as `cssPx * dpr`, so WebGPU rejects an out-of-bounds rect and blanks the
|
|
47
|
+
* frame, WebGL clamps and paints at the wrong scale, and the Canvas2D fallback
|
|
48
|
+
* stretches its top slice over the whole track.
|
|
49
|
+
*
|
|
50
|
+
* A function, not a constant: `getDpr()` follows the window, so a display
|
|
51
|
+
* reading this into a computed re-derives it when the window moves to another
|
|
52
|
+
* screen. Where the cap lands is the caller's — MAF scrolls its overflow into a
|
|
53
|
+
* viewport, the multi-row painting divides the cap across its rows so they thin
|
|
54
|
+
* out and all stay on screen.
|
|
55
|
+
*/
|
|
56
|
+
export function maxCanvasCssPx() {
|
|
57
|
+
return MAX_CANVAS_DIM_PX / getDpr();
|
|
58
|
+
}
|
|
59
|
+
let warnedCanvasClamp = false;
|
|
60
|
+
// CSS px → backing-store px, clamped to the safe ceiling (warning once if the
|
|
61
|
+
// clamp engages).
|
|
62
|
+
//
|
|
63
|
+
// This is a last-resort guard against `InvalidStateError: Canvas exceeds max
|
|
64
|
+
// size`, NOT a graceful degradation: once clamped the backing store stops
|
|
65
|
+
// tracking the CSS size, so the browser *stretches* the smaller image over the
|
|
66
|
+
// larger element, and every GPU scissor/viewport rect — computed as
|
|
67
|
+
// `cssPx * getDpr()` by `clipBlock` and friends, against the true dpr rather
|
|
68
|
+
// than the effective one — can now exceed the backing store (WebGL clamps
|
|
69
|
+
// silently, WebGPU rejects the rect and blanks the frame). So model-level
|
|
70
|
+
// sizing must keep canvases within the limit on its own: any display sizing a
|
|
71
|
+
// canvas to its *content* rather than its viewport bounds itself with
|
|
72
|
+
// `maxCanvasCssPx()` above. Registered in
|
|
73
|
+
// agent-docs/reference/ARCHITECTURAL_LIMITS.md with the effective-dpr fix that
|
|
74
|
+
// retires it.
|
|
75
|
+
function backingPx(cssSize, dpr, axis) {
|
|
76
|
+
const value = Math.round(cssSize * dpr);
|
|
77
|
+
if (value > MAX_CANVAS_DIM_PX) {
|
|
78
|
+
if (!warnedCanvasClamp) {
|
|
79
|
+
warnedCanvasClamp = true;
|
|
80
|
+
console.warn(`Canvas ${axis} ${value}px exceeds the safe limit ${MAX_CANVAS_DIM_PX}px and was clamped`);
|
|
81
|
+
}
|
|
82
|
+
return MAX_CANVAS_DIM_PX;
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
function effectiveScale(cssPx, backing) {
|
|
87
|
+
// A zero-width or zero-height canvas has no ratio to report; the true dpr is
|
|
88
|
+
// the answer that makes the arithmetic downstream a no-op.
|
|
89
|
+
return cssPx > 0 ? backing / cssPx : getDpr();
|
|
90
|
+
}
|
|
91
|
+
// Apply CSS dimensions + dpr-scaled physical dimensions to `canvas`. Returns
|
|
92
|
+
// whether the backing dimensions actually changed, and the scale each axis
|
|
93
|
+
// ended up with — both HAL impls and any 2D-canvas backend use this so the dpr
|
|
94
|
+
// math and style-vs-attribute logic live in one place.
|
|
95
|
+
export function syncCanvasSize(canvas, width, height) {
|
|
96
|
+
const dpr = getDpr();
|
|
97
|
+
const pw = backingPx(width, dpr, 'width');
|
|
98
|
+
const ph = backingPx(height, dpr, 'height');
|
|
99
|
+
const changed = canvas.width !== pw || canvas.height !== ph;
|
|
100
|
+
if (changed) {
|
|
101
|
+
canvas.width = pw;
|
|
102
|
+
canvas.height = ph;
|
|
103
|
+
}
|
|
104
|
+
// CSS size is set independently of `changed`: once a dimension clamps to
|
|
105
|
+
// MAX_CANVAS_DIM_PX the backing store stops tracking the CSS size, so gating
|
|
106
|
+
// the style write on the backing store froze the element at whatever CSS size
|
|
107
|
+
// it had when it first clamped. Assigning the same string is a no-op in the
|
|
108
|
+
// browser, so this costs nothing on the common path.
|
|
109
|
+
const cssWidth = `${width}px`;
|
|
110
|
+
const cssHeight = `${height}px`;
|
|
111
|
+
if (canvas.style.width !== cssWidth) {
|
|
112
|
+
canvas.style.width = cssWidth;
|
|
113
|
+
}
|
|
114
|
+
if (canvas.style.height !== cssHeight) {
|
|
115
|
+
canvas.style.height = cssHeight;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
changed,
|
|
119
|
+
scale: { x: effectiveScale(width, pw), y: effectiveScale(height, ph) },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function prepareCanvas(canvas, ctx, canvasWidth, canvasHeight) {
|
|
123
|
+
const dpr = getDpr();
|
|
124
|
+
const bufW = backingPx(canvasWidth, dpr, 'width');
|
|
125
|
+
const bufH = backingPx(canvasHeight, dpr, 'height');
|
|
126
|
+
if (canvas.width !== bufW || canvas.height !== bufH) {
|
|
127
|
+
canvas.width = bufW;
|
|
128
|
+
canvas.height = bufH;
|
|
129
|
+
}
|
|
130
|
+
// The scale the backing store actually got, not the one the screen has: past
|
|
131
|
+
// the clamp these differ, and transforming by the true dpr is what made a
|
|
132
|
+
// clamped 2D canvas paint its top slice stretched over the whole track
|
|
133
|
+
// instead of the whole track at reduced resolution.
|
|
134
|
+
ctx.setTransform(effectiveScale(canvasWidth, bufW), 0, 0, effectiveScale(canvasHeight, bufH), 0, 0);
|
|
135
|
+
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
136
|
+
}
|
|
137
|
+
// Acquire a DPR-prepared 2D context from a (possibly null) canvas ref, or null
|
|
138
|
+
// if the canvas/context isn't available. Bundles the
|
|
139
|
+
// `getContext('2d')` + null-guard + `prepareCanvas` ritual that every
|
|
140
|
+
// standalone 2D overlay otherwise hand-rolls in its draw effect — and the
|
|
141
|
+
// `prepareCanvas` DPR step is easy to omit, which renders blurry on Retina
|
|
142
|
+
// (see agent-docs/reference/GPU_RENDERING.md §"Canvas scaling & hi-DPI").
|
|
143
|
+
// Returning the
|
|
144
|
+
// prepared ctx structurally prevents that omission.
|
|
145
|
+
export function getPreparedCanvas2D(canvas, width, height) {
|
|
146
|
+
const ctx = canvas?.getContext('2d');
|
|
147
|
+
if (canvas && ctx) {
|
|
148
|
+
prepareCanvas(canvas, ctx, width, height);
|
|
149
|
+
return ctx;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
// Convert a CSS-px span [cssStart, cssEnd] to an integer device-px `{ start,
|
|
154
|
+
// width }` rect by rounding each edge independently, then subtracting. Rounding
|
|
155
|
+
// the *width* (`round((cssEnd-cssStart)*dpr)`) instead can push the right edge
|
|
156
|
+
// one px past `round(cssEnd*dpr)`: that overflows the backing store for a span
|
|
157
|
+
// touching the canvas edge (WebGPU rejects an out-of-bounds scissor and blanks
|
|
158
|
+
// the frame) and opens 1px seams between adjacent spans. Edge-rounding pins the
|
|
159
|
+
// right edge to `round(cssEnd*dpr)`, so one span's end always equals the next
|
|
160
|
+
// span's start. The single source for every CSS→device-px scissor/viewport span
|
|
161
|
+
// (`clipBlock` X, alignments `computeBlockGeom` X / `devBand` Y).
|
|
162
|
+
export function devicePxSpan(cssStart, cssEnd, dpr) {
|
|
163
|
+
const start = Math.round(cssStart * dpr);
|
|
164
|
+
return { start, width: Math.round(cssEnd * dpr) - start };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* One horizontal band of a stacked canvas as a device-px scissor rect, clamped
|
|
168
|
+
* to the backing store: `top`/`height` in CSS px, `bufH` the backing store's
|
|
169
|
+
* height in device px.
|
|
170
|
+
*
|
|
171
|
+
* The clamp is the part that matters. A band scrolled past either edge — a
|
|
172
|
+
* grouped alignments section, a MAF rows viewport under a resized band — yields
|
|
173
|
+
* a rect the backing store does not contain, and WebGPU REJECTS an
|
|
174
|
+
* out-of-bounds scissor and blanks the whole frame rather than clipping it. A
|
|
175
|
+
* band clamped to zero height is the caller's cue to skip its passes.
|
|
176
|
+
*
|
|
177
|
+
* `dpr` is the scale the canvas ACTUALLY got (`hal.resize`'s return), not
|
|
178
|
+
* `getDpr()`: the two part company once the backing store clamps at
|
|
179
|
+
* `MAX_CANVAS_DIM_PX`, and past that a band built from the true ratio is taller
|
|
180
|
+
* than the target.
|
|
181
|
+
*/
|
|
182
|
+
export function devicePxBand(top, height, dpr, bufH) {
|
|
183
|
+
const span = devicePxSpan(top, top + height, dpr);
|
|
184
|
+
const t = Math.max(0, span.start);
|
|
185
|
+
const b = Math.min(bufH, span.start + span.width);
|
|
186
|
+
return { top: t, height: Math.max(0, b - t) };
|
|
187
|
+
}
|
|
188
|
+
// Integer scissor rect (CSS px) for a block clamped to the canvas. Shared by
|
|
189
|
+
// the GPU (`clipBlock`) and Canvas2D (`clipBlockForCanvas`) clip paths so both
|
|
190
|
+
// backends clip to the exact same pixel columns — the rounding lives in one
|
|
191
|
+
// place rather than being copied. Returns null when the block is fully
|
|
192
|
+
// off-screen.
|
|
193
|
+
export function clampBlockScissor(screenStartPx, screenEndPx, canvasWidth) {
|
|
194
|
+
const scissorX = Math.max(0, Math.floor(screenStartPx));
|
|
195
|
+
const scissorEnd = Math.min(canvasWidth, Math.ceil(screenEndPx));
|
|
196
|
+
const scissorW = scissorEnd - scissorX;
|
|
197
|
+
return scissorW <= 0 ? null : { scissorX, scissorEnd, scissorW };
|
|
198
|
+
}
|
|
199
|
+
export function clipBlockForCanvas(block, canvasWidth) {
|
|
200
|
+
const clamp = clampBlockScissor(block.screenStartPx, block.screenEndPx, canvasWidth);
|
|
201
|
+
const fullBlockWidth = block.screenEndPx - block.screenStartPx;
|
|
202
|
+
const bpLength = block.end - block.start;
|
|
203
|
+
// Degenerate blocks are skipped, matching `clipBlock` on the GPU side —
|
|
204
|
+
// callers divide by both of these, and clampBlockScissor alone lets a
|
|
205
|
+
// zero-span block through (floor/ceil widen it to one column).
|
|
206
|
+
return clamp && fullBlockWidth > 0 && bpLength > 0
|
|
207
|
+
? {
|
|
208
|
+
scissorX: clamp.scissorX,
|
|
209
|
+
scissorW: clamp.scissorW,
|
|
210
|
+
fullBlockWidth,
|
|
211
|
+
bpLength,
|
|
212
|
+
}
|
|
213
|
+
: null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Clip to one rect, paint, unclip — the `save`/`clip`/`restore` triple with the
|
|
217
|
+
* `restore` in a `finally`.
|
|
218
|
+
*
|
|
219
|
+
* The `finally` is the whole point, and the cost of missing it is not local: an
|
|
220
|
+
* on-screen ctx outlives the frame and `prepareCanvas`'s setTransform/clearRect
|
|
221
|
+
* do **not** reset clip state, so one painter throwing once leaves every later
|
|
222
|
+
* frame drawing through a stale clip. A transient error becomes permanent
|
|
223
|
+
* corruption, and the visible symptom (content missing from a band that has no
|
|
224
|
+
* reason to be clipped) points nowhere near the painter that threw.
|
|
225
|
+
*
|
|
226
|
+
* `forEachClippedBlock` is the per-block form and covers the common case on its
|
|
227
|
+
* own. Reach for this one for the clips *nested inside* a block — a display
|
|
228
|
+
* whose bands each own a horizontal strip of the canvas (alignments clips its
|
|
229
|
+
* coverage, pileup and arc bands separately within one block clip), where the
|
|
230
|
+
* nesting is what makes a hand-rolled pairing easy to get wrong: an early
|
|
231
|
+
* `return` inside the band body unbalances the block's `save` too.
|
|
232
|
+
*/
|
|
233
|
+
export function withClip(ctx, x, y, w, h, paint) {
|
|
234
|
+
ctx.save();
|
|
235
|
+
ctx.beginPath();
|
|
236
|
+
ctx.rect(x, y, w, h);
|
|
237
|
+
ctx.clip();
|
|
238
|
+
try {
|
|
239
|
+
paint();
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
ctx.restore();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The Canvas2D twin of `GpuPerRegionRenderingBackend.renderBlocks`' per-block
|
|
247
|
+
* scissor: skip blocks with nothing to draw or no on-screen span, clip to the
|
|
248
|
+
* block's columns, paint, unclip. Owning it here means a painter can't pair
|
|
249
|
+
* `save()` with a missed `restore()` (leaking the clip onto every later block),
|
|
250
|
+
* clip to a span other than `clipBlockForCanvas`', or forget the null check.
|
|
251
|
+
*
|
|
252
|
+
* `select` resolves a block to what it paints, or `undefined` to skip it — the
|
|
253
|
+
* one gate covering all three reasons a painter skips: no region in the map, a
|
|
254
|
+
* region with zero features, and a region whose relevant sub-field is absent
|
|
255
|
+
* (MAF's `?.coverage`). It runs *before* the clip so an empty block costs
|
|
256
|
+
* neither the path-and-clip round trip on a real context nor the queued group
|
|
257
|
+
* on `SvgCanvas` — that class now drops a clip nothing was drawn inside, so a
|
|
258
|
+
* skipped-late block no longer leaves dead `<clipPath>` markup in the export,
|
|
259
|
+
* but relying on it to clean up after the gate is backwards. Resolving here
|
|
260
|
+
* also lets `paint` take a non-optional value instead of re-narrowing inside
|
|
261
|
+
* the callback.
|
|
262
|
+
*
|
|
263
|
+
* `clipHeight` is a parameter rather than read off a render state because
|
|
264
|
+
* painters that own a sub-band of the canvas clip to that band, not the full
|
|
265
|
+
* height (MAF's coverage row).
|
|
266
|
+
*
|
|
267
|
+
* `paint` receives the block, so callers needing bp→px build their own mapper
|
|
268
|
+
* (`makeBpMapper` / `makeCellLeftMapper` — which one is a per-painter choice).
|
|
269
|
+
* Two closures per draw call, not per feature: per-feature loops live inside the
|
|
270
|
+
* `paint` body and stay allocation-free.
|
|
271
|
+
*/
|
|
272
|
+
export function forEachClippedBlock(ctx, blocks, canvasWidth, clipHeight, select, paint) {
|
|
273
|
+
for (const block of blocks) {
|
|
274
|
+
const data = select(block);
|
|
275
|
+
const clip = data === undefined ? null : clipBlockForCanvas(block, canvasWidth);
|
|
276
|
+
if (data !== undefined && clip) {
|
|
277
|
+
withClip(ctx, clip.scissorX, 0, clip.scissorW, clipHeight, () => {
|
|
278
|
+
paint(data, block, clip);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Stroke the outline of `[x, y, w, h]` so the line lands **inside** the rect
|
|
285
|
+
* rather than straddling its edge.
|
|
286
|
+
*
|
|
287
|
+
* Canvas2D centres a stroke on its path, so `strokeRect(x, y, w, h)` puts half
|
|
288
|
+
* the line width outside the rectangle you named. Every GPU pass in this repo
|
|
289
|
+
* draws an outline as an inner band — a fragment test on distance-to-edge, which
|
|
290
|
+
* has no way to paint outside the glyph — so the straddling spelling is a
|
|
291
|
+
* guaranteed half-pixel disagreement with the shader, and it eats whatever gap
|
|
292
|
+
* separates the glyph from its neighbour.
|
|
293
|
+
*
|
|
294
|
+
* **It matters more in the SVG export than on screen**, which is the reason this
|
|
295
|
+
* is a helper and not a house style. On a canvas the difference is half a pixel;
|
|
296
|
+
* in SVG a centred stroke means the emitted element's *declared geometry* and
|
|
297
|
+
* its painted extent differ by half the stroke width, so a 10px-tall read
|
|
298
|
+
* exports as an 11px-tall mark. Anything that measures the file, or opens it in
|
|
299
|
+
* a vector editor and snaps to the shape's bounds, is then reading a rectangle
|
|
300
|
+
* whose coordinates do not describe what is drawn.
|
|
301
|
+
*
|
|
302
|
+
* Degenerate rects are left to the caller: below `lineWidth` on an axis the
|
|
303
|
+
* inset would invert it, and what to do instead (skip the outline, or fill
|
|
304
|
+
* solid) is a per-glyph decision. The alignments read painter and the canvas
|
|
305
|
+
* feature painter both gate on a minimum size first.
|
|
306
|
+
*/
|
|
307
|
+
export function strokeRectInside(ctx, x, y, w, h, lineWidth = 1) {
|
|
308
|
+
const inset = lineWidth / 2;
|
|
309
|
+
ctx.strokeRect(x + inset, y + inset, w - lineWidth, h - lineWidth);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* `fillRect` x for a mark that spans `x1`→`x2` but is painted at `width` — the
|
|
313
|
+
* Canvas2D pivot for every painter that widens a too-narrow span to a floor (a
|
|
314
|
+
* 1bp feature, a zoomed-out coverage bin), and the twin of the pivot baked into
|
|
315
|
+
* the shaders' `extendToMinWidthX`.
|
|
316
|
+
*
|
|
317
|
+
* The mark is anchored at `x1` and grows *away* from it. That's the whole point:
|
|
318
|
+
* on a reversed block `makeBpMapper` flips, so `x2 < x1`, `x1` is the feature's
|
|
319
|
+
* **start** — its right edge — and the mark must grow leftward from it. Spelling
|
|
320
|
+
* the fill as `min(x1, x2)` + `max(floor, |dx|)` instead anchors whichever edge
|
|
321
|
+
* happens to be leftmost, which reversed is the feature's *end*, sliding the
|
|
322
|
+
* mark by up to the floor. Forward blocks are identical either way, so the error
|
|
323
|
+
* only ever shows on flipped regions and survives review. The canvas rect
|
|
324
|
+
* painter (2px floor), the multi-row painter (1px), and wiggle (1.5px) each had
|
|
325
|
+
* it independently.
|
|
326
|
+
*
|
|
327
|
+
* Callers own the *width*, like `makeCellLeftMapper` below — it's a per-plugin
|
|
328
|
+
* rule (canvas/multi-row take `max(floor, |dx|)`, matching `extendToMinWidthX`
|
|
329
|
+
* exactly; wiggle adds a seam-closing fudge the shader deliberately omits).
|
|
330
|
+
* Only the pivot is shared, and it's the part that keeps being got wrong.
|
|
331
|
+
*
|
|
332
|
+
* Scalar in, scalar out: runs per feature per frame, so it must not allocate.
|
|
333
|
+
*/
|
|
334
|
+
export function spanLeft(x1, x2, width) {
|
|
335
|
+
return x2 < x1 ? x1 - width : x1;
|
|
336
|
+
}
|
|
337
|
+
// Quantize a 0..1 ramp position to a 256-entry index. Shared so the direct
|
|
338
|
+
// lookup and the fillStyle LUT below can't quantize differently — a half-step
|
|
339
|
+
// of drift between them shows up as a one-shade mismatch between a cell's fill
|
|
340
|
+
// and the alpha read off the same ramp.
|
|
341
|
+
function rampIndex(t) {
|
|
342
|
+
return Math.max(0, Math.min(255, Math.round(t * 255)));
|
|
343
|
+
}
|
|
344
|
+
// A LUT factory for per-cell `rgba(...)` fillStyle strings. Hot loops in HiC/LD
|
|
345
|
+
// allocate one fillStyle per cell; with a bounded set of unique outputs from a
|
|
346
|
+
// fixed ramp, caching brings 7-15x speedup on big matrices. Returned closure is
|
|
347
|
+
// meant to live for one draw call.
|
|
348
|
+
export function makeRampFillStyleLut(ramp) {
|
|
349
|
+
const lut = new Array(256);
|
|
350
|
+
return (t) => {
|
|
351
|
+
const idx = rampIndex(t);
|
|
352
|
+
let s = lut[idx];
|
|
353
|
+
if (s === undefined) {
|
|
354
|
+
const o = idx * 4;
|
|
355
|
+
const a = ramp[o + 3] / 255;
|
|
356
|
+
s = `rgba(${ramp[o]},${ramp[o + 1]},${ramp[o + 2]},${a.toFixed(3)})`;
|
|
357
|
+
lut[idx] = s;
|
|
358
|
+
}
|
|
359
|
+
return s;
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
export function bpToScreenPx(absBp, regionStart, regionEnd, screenStartPx, screenEndPx, reversed) {
|
|
363
|
+
const frac = (absBp - regionStart) / (regionEnd - regionStart);
|
|
364
|
+
const w = screenEndPx - screenStartPx;
|
|
365
|
+
return reversed ? screenEndPx - frac * w : screenStartPx + frac * w;
|
|
366
|
+
}
|
|
367
|
+
// Closure-style bp→screen-px mapper bound to one region. Consumers that walk
|
|
368
|
+
// many bp positions inside one block (label/peptide overlays, rect/line draws)
|
|
369
|
+
// want a 1-arg call instead of repeating 6 args at every site.
|
|
370
|
+
export function makeBpMapper(bounds) {
|
|
371
|
+
const { start, end, screenStartPx, screenEndPx, reversed } = bounds;
|
|
372
|
+
const span = end - start;
|
|
373
|
+
const w = screenEndPx - screenStartPx;
|
|
374
|
+
return reversed
|
|
375
|
+
? (bp) => screenEndPx - ((bp - start) / span) * w
|
|
376
|
+
: (bp) => screenStartPx + ((bp - start) / span) * w;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Left edge of the **1bp cell** covering `bp` — for painters that fill a rect
|
|
380
|
+
* per base (MAF alignment cells, the alignments pileup's mismatch /
|
|
381
|
+
* modification / per-base quality+letter / soft-clip base layers).
|
|
382
|
+
*
|
|
383
|
+
* Use this, not `makeBpMapper`, whenever the mark is a cell rather than a point
|
|
384
|
+
* or a two-edge span. `makeBpMapper(bp)` is the cell's left edge only on a
|
|
385
|
+
* forward block: a reversed block runs bp leftward, so it lands on the cell's
|
|
386
|
+
* *right* edge, and `fillRect(x, y, +w, h)` from there covers bp-1 instead. The
|
|
387
|
+
* error is one base wide — invisible zoomed out (cells floor to ~1px), a whole
|
|
388
|
+
* base off zoomed in, and it only reproduces on flipped regions, so it survives
|
|
389
|
+
* casual review. Every plugin that hand-rolled the pivot had to rediscover it;
|
|
390
|
+
* the alignments pileup got it wrong across all five of its cell layers.
|
|
391
|
+
*
|
|
392
|
+
* Two-edge spans (`min(toX(start), toX(end))`) and boundary marks (insertions,
|
|
393
|
+
* clip bars, centered on a bp edge) are orientation-safe already and don't need
|
|
394
|
+
* this. The GPU side has no equivalent bug — shaders span
|
|
395
|
+
* `bpToClipX(pos)`→`bpToClipX(pos+1)` unflipped and mirror via `flipX` — and
|
|
396
|
+
* gets its own pivot from `writeBpRangeUniforms`.
|
|
397
|
+
*
|
|
398
|
+
* Callers own the cell *width*: it's a per-plugin rule (MAF uses the raw
|
|
399
|
+
* bp-scale; the pileup floors at 1px and adds a seam fudge). Only the pivot is
|
|
400
|
+
* shared.
|
|
401
|
+
*/
|
|
402
|
+
/**
|
|
403
|
+
* Screen px one base occupies in this region — the scale factor, unsigned, with
|
|
404
|
+
* the block's direction deliberately left out (that lives in `makeBpMapper` and
|
|
405
|
+
* `spanLeft`, and folding it in here would make a *scale* carry an orientation).
|
|
406
|
+
*
|
|
407
|
+
* Shared because it is usually an input to something a second painter has to
|
|
408
|
+
* reproduce exactly: a mark sized against it in a draw pass and hit-tested
|
|
409
|
+
* against it in a component is two spellings of one number, and the hit target
|
|
410
|
+
* silently stops covering the mark if they ever drift. Trivial arithmetic, but
|
|
411
|
+
* the drift is not.
|
|
412
|
+
*/
|
|
413
|
+
export function pxPerBpOf(bounds) {
|
|
414
|
+
return ((bounds.screenEndPx - bounds.screenStartPx) / (bounds.end - bounds.start));
|
|
415
|
+
}
|
|
416
|
+
export function makeCellLeftMapper(bounds) {
|
|
417
|
+
const toX = makeBpMapper(bounds);
|
|
418
|
+
const pxPerBp = pxPerBpOf(bounds);
|
|
419
|
+
// One base's signed span: exact for fractional zooms, and one add per cell —
|
|
420
|
+
// these run per base per row, so a second mapper call per cell would show.
|
|
421
|
+
const shift = bounds.reversed ? -pxPerBp : 0;
|
|
422
|
+
return (bp) => toX(bp) + shift;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Fill the rect covering genomic `[startBp, endBp)`, given a bare
|
|
426
|
+
* `makeBpMapper`. Maps **both** edges and takes the leftmost, which is
|
|
427
|
+
* orientation-safe by construction: on a reversed block bp runs leftward, so
|
|
428
|
+
* the span's left edge is its *end*, and there is no pivot to get backwards.
|
|
429
|
+
*
|
|
430
|
+
* Prefer this to `makeCellLeftMapper` + a caller-computed width for anything
|
|
431
|
+
* wider than one base. The pivot in `makeCellLeftMapper` is exactly one base,
|
|
432
|
+
* which is right for a 1bp cell and silently wrong for an N-bp span — it
|
|
433
|
+
* anchors the rect one base from the correct edge and then grows it the wrong
|
|
434
|
+
* way. `fillBpSpan(bp, bp + 1)` is *identical* to the cell-mapper spelling, so
|
|
435
|
+
* a per-base painter can use this too and never think about orientation again.
|
|
436
|
+
*
|
|
437
|
+
* `seam` is the caller's sub-pixel overlap (MAF's `GAP_STROKE_OFFSET`), added
|
|
438
|
+
* to the width so adjacent cells don't show hairlines at ~1px/bp. Widening a
|
|
439
|
+
* sub-pixel *feature* to a visible floor is a different job — that's
|
|
440
|
+
* `spanLeft`, which must anchor the feature's start edge.
|
|
441
|
+
*/
|
|
442
|
+
export function fillBpSpan(ctx, toX, startBp, endBp, top, height, seam = 0) {
|
|
443
|
+
const x1 = toX(startBp);
|
|
444
|
+
const x2 = toX(endBp);
|
|
445
|
+
ctx.fillRect(Math.min(x1, x2), top, Math.abs(x2 - x1) + seam, height);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The **integer base** whose 1bp cell covers screen pixel `px` — the inverse of
|
|
449
|
+
* `makeCellLeftMapper`, for hit-testing (which base did the cursor land on) as
|
|
450
|
+
* opposed to painting.
|
|
451
|
+
*
|
|
452
|
+
* Rounding is not `Math.floor` on both orientations, which is the trap. The
|
|
453
|
+
* un-rounded inverse of `makeBpMapper` lands in `[b, b+1)` on a forward block
|
|
454
|
+
* but in `(b, b+1]` on a reversed one, because bp runs leftward there — so
|
|
455
|
+
* flooring reversed names `b+1` on base `b`'s leftmost pixel column, and names
|
|
456
|
+
* `end` (outside the block) on the block's first column. It is the same
|
|
457
|
+
* one-base pivot `makeCellLeftMapper` exists to get right, seen from the other
|
|
458
|
+
* direction, and it fails the same way: invisible zoomed out, a whole base off
|
|
459
|
+
* at base zoom, and only on flipped regions.
|
|
460
|
+
*
|
|
461
|
+
* Callers own the *range* check: a `px` outside `[screenStartPx, screenEndPx)`
|
|
462
|
+
* extrapolates rather than clamping, since which block owns a pixel is the
|
|
463
|
+
* caller's decision (adjacent blocks share an edge pixel).
|
|
464
|
+
*
|
|
465
|
+
* **Multiply before dividing, and never form the fraction.** That is the whole
|
|
466
|
+
* of the arithmetic below and it is load-bearing: `(px - screenStartPx) * span`
|
|
467
|
+
* is *exact* — both operands are dyadic and the product tops out around 3e12
|
|
468
|
+
* against a 2^53 budget — so the one division that follows is the only rounding
|
|
469
|
+
* in the expression, and its true quotient is either an exact integer (the
|
|
470
|
+
* division is then exact too) or at least `1 / blockWidth` from one, which is
|
|
471
|
+
* ~1e-3 against a relative error of 2^-53. So the floor cannot land on the
|
|
472
|
+
* wrong base. Spelling it `frac = (px - s) / w` and then `frac * span` rounds
|
|
473
|
+
* twice, and the second product can land arbitrarily close to an integer from
|
|
474
|
+
* either side.
|
|
475
|
+
*
|
|
476
|
+
* Not a micro-optimization — a correctness one, and measured. Against an exact
|
|
477
|
+
* rational oracle over 11.6M realistic samples (integer through eighth-pixel
|
|
478
|
+
* cursor positions, chr1-scale starts, spans from 1bp to 3Mb, both
|
|
479
|
+
* orientations, fractional block offsets) this form is exact on every one,
|
|
480
|
+
* where the `frac` spelling it replaces named the wrong base 4202 times and
|
|
481
|
+
* wiggle's independent `baseAtFraction` — which this now backs — 10992 times.
|
|
482
|
+
* Those are all boundary pixels, which is exactly where a per-base tooltip is
|
|
483
|
+
* read.
|
|
484
|
+
*
|
|
485
|
+
* **The anchor splits, because `bounds.start` is usually not a whole base.** A
|
|
486
|
+
* block's edges are laid out in PIXELS, so its bp edges land wherever that
|
|
487
|
+
* projection puts them — `visibleRegions` hands every hit test a start like
|
|
488
|
+
* `14468.9936` — and rounding the *offset* and then adding such an anchor
|
|
489
|
+
* returns the anchor's fraction untouched. The result is then not a base at
|
|
490
|
+
* all, and every caller that indexes one by equality (a mismatch column, a
|
|
491
|
+
* coverage bin, the sort-at-this-column anchor) misses silently: the pileup
|
|
492
|
+
* offered no SNP submenu over a mismatch it was drawing. Rounding
|
|
493
|
+
* `whole + fraction + offset` in one step instead fixes that and hands back the
|
|
494
|
+
* genome-scale addend the paragraph above exists to keep out, so the whole base
|
|
495
|
+
* comes off first and only the sub-base remainder enters the rounded
|
|
496
|
+
* expression. On a whole-base anchor that remainder is zero and the arithmetic
|
|
497
|
+
* is identical to what an integer start has always done.
|
|
498
|
+
*
|
|
499
|
+
* The same oracle, re-run over 3.7M samples on fractional anchors: this spelling
|
|
500
|
+
* misses 696 and the shipped one missed every single fractional-anchor sample,
|
|
501
|
+
* 3,076,480 of them. `Math.floor(start + offset)` misses 2,524. The residue is
|
|
502
|
+
* the boundary pixel itself, which no double-precision spelling resolves both
|
|
503
|
+
* ways once base boundaries stop landing on whole pixels.
|
|
504
|
+
*/
|
|
505
|
+
export function bpAtPx(px, bounds) {
|
|
506
|
+
const { start, end, screenStartPx, screenEndPx, reversed } = bounds;
|
|
507
|
+
const offset = ((px - screenStartPx) * (end - start)) / (screenEndPx - screenStartPx);
|
|
508
|
+
const anchor = reversed ? end : start;
|
|
509
|
+
const whole = Math.floor(anchor);
|
|
510
|
+
const rem = anchor - whole;
|
|
511
|
+
return reversed
|
|
512
|
+
? whole + Math.ceil(rem - offset) - 1
|
|
513
|
+
: whole + Math.floor(rem + offset);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* The **fractional bp** at screen pixel `px` — the un-rounded inverse of
|
|
517
|
+
* `makeBpMapper`, for hit tests that measure a distance or open a search window
|
|
518
|
+
* rather than index a base (the alignments interbase triangles and modification
|
|
519
|
+
* centers, the variant cell's bp padding, the Manhattan point's ±radius query).
|
|
520
|
+
*
|
|
521
|
+
* Ask `bpAtPx` instead the moment the answer indexes per-base data, and **do
|
|
522
|
+
* not floor this** — flooring an already-divided fraction is the double
|
|
523
|
+
* rounding `bpAtPx`'s JSDoc argues against at length, and reversed it names the
|
|
524
|
+
* neighbouring base as well. `end - offset` reversed, not `start + offset`: bp
|
|
525
|
+
* runs leftward there, and each of the three plugins that spelled this out
|
|
526
|
+
* locally had to rediscover that.
|
|
527
|
+
*
|
|
528
|
+
* **Neither function is the other's implementation.** This multiplies before
|
|
529
|
+
* dividing for the same reason `bpAtPx` does — `(px - screenStartPx) * span` is
|
|
530
|
+
* exact, so the division is the only rounding — but it does not split the
|
|
531
|
+
* anchor into whole and remainder the way `bpAtPx` does. That split keeps the
|
|
532
|
+
* genome-scale addend out of a *rounded* expression, and there is no rounding
|
|
533
|
+
* here to protect; the caller wants the anchor's fraction kept, not floored
|
|
534
|
+
* away.
|
|
535
|
+
*
|
|
536
|
+
* Adopting it moved no consumer's answer, which is the point rather than a
|
|
537
|
+
* caveat. Over 300000 fractional-anchor samples against the exact-rational
|
|
538
|
+
* oracle `bpAtPx` was measured on, it and all three spellings it replaced land
|
|
539
|
+
* within 1 ulp of the exactly-rounded value; it returns a different double from
|
|
540
|
+
* the `frac` spelling on 114 and from gwas' `bpPerPx` spelling on 289, by at
|
|
541
|
+
* most 7.5e-9 bp against hit radii measured in whole pixels.
|
|
542
|
+
*/
|
|
543
|
+
export function bpAtPxExact(px, bounds) {
|
|
544
|
+
const { start, end, screenStartPx, screenEndPx, reversed } = bounds;
|
|
545
|
+
const offset = ((px - screenStartPx) * (end - start)) / (screenEndPx - screenStartPx);
|
|
546
|
+
return reversed ? end - offset : start + offset;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* The region owning screen pixel `px`, or undefined past the last one — the
|
|
550
|
+
* range check `bpAtPx` and `bpAtPxExact` leave to their callers, answered once.
|
|
551
|
+
*
|
|
552
|
+
* **The exclusive upper bound is the whole content of this.** Adjacent regions
|
|
553
|
+
* share a pixel (`regionA.screenEndPx === regionB.screenStartPx`), so an
|
|
554
|
+
* inclusive bound matches both and `find` hands back the earlier one — which
|
|
555
|
+
* then steals every click meant for the later region's first column. Half-open,
|
|
556
|
+
* exactly one region can match, so this answers with a region rather than a
|
|
557
|
+
* list.
|
|
558
|
+
*
|
|
559
|
+
* Generic so a caller gets its own region type back, since what it wants from
|
|
560
|
+
* the match is a `displayedRegionIndex` or the bp/px edges for a mapper.
|
|
561
|
+
*/
|
|
562
|
+
export function regionAtPixel(regions, px) {
|
|
563
|
+
return regions.find(r => px >= r.screenStartPx && px < r.screenEndPx);
|
|
564
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `<canvas>` holds exactly **one** context kind for its whole lifetime. Once
|
|
3
|
+
* `getContext('webgl2')` has succeeded on an element, `getContext('2d')` on that
|
|
4
|
+
* same element returns `null` forever — not because Canvas2D is unavailable, but
|
|
5
|
+
* because the element is spoken for. The browser gives no reason: every failure
|
|
6
|
+
* mode of `getContext` is the same `null`.
|
|
7
|
+
*
|
|
8
|
+
* That collision is not hypothetical here. The HAL ladder (WebGPU → WebGL2 →
|
|
9
|
+
* Canvas2D) picks a kind per backend init, and a **re-init on a reused element**
|
|
10
|
+
* can pick a different one than last time — a WebGPU device loss where the
|
|
11
|
+
* device can't be re-acquired drops to WebGL2, which then finds the canvas
|
|
12
|
+
* already committed to `webgpu`. The remedy is a fresh element (`RenderCanvas`,
|
|
13
|
+
* keyed on `useRenderingBackend`'s `canvasKey`), and the point of this module is
|
|
14
|
+
* that the error *says so* instead of reporting "WebGL2 not supported" on a
|
|
15
|
+
* machine that supports WebGL2 perfectly well.
|
|
16
|
+
*
|
|
17
|
+
* We track it ourselves because the platform offers no way to ask a canvas which
|
|
18
|
+
* kind it holds. A `WeakMap` rather than a DOM attribute: nothing should be able
|
|
19
|
+
* to observe or edit this from the outside, and a detached canvas must stay
|
|
20
|
+
* collectable.
|
|
21
|
+
*/
|
|
22
|
+
export type CanvasContextKind = 'webgpu' | 'webgl2' | '2d';
|
|
23
|
+
/**
|
|
24
|
+
* Record `owner` as the holder of `canvas`'s current WebGPU configuration.
|
|
25
|
+
*
|
|
26
|
+
* `getContext('webgpu')` hands back the **same** `GPUCanvasContext` object every
|
|
27
|
+
* time, so a configuration is per-element state that two HALs can end up
|
|
28
|
+
* sharing: a re-init on a reused element (a display whose `model` prop swaps
|
|
29
|
+
* under it, an init that overlaps a cancelled one) builds a second HAL on the
|
|
30
|
+
* same context, and `unconfigure()` from whichever loses the race takes the
|
|
31
|
+
* winner's swap chain with it. Firefox then throws `InvalidStateError:
|
|
32
|
+
* GPUCanvasContext.getCurrentTexture: Canvas not configured` on every frame the
|
|
33
|
+
* live HAL draws, forever — the browser fires no context-lost event for it, so
|
|
34
|
+
* none of the loss recovery in `useRenderingBackend` ever runs.
|
|
35
|
+
*
|
|
36
|
+
* Last configure wins, which is the live one: a cancelled init is disposed, and
|
|
37
|
+
* disposal is the only thing that unconfigures.
|
|
38
|
+
*/
|
|
39
|
+
export declare function noteCanvasConfigured(canvas: HTMLCanvasElement, owner: object): void;
|
|
40
|
+
/** Whether `owner`'s configuration is still the one on `canvas`. */
|
|
41
|
+
export declare function canvasConfiguredBy(canvas: HTMLCanvasElement, owner: object): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Record that `canvas` is now permanently committed to `kind`. Call on every
|
|
44
|
+
* **successful** `getContext`, in every HAL and every Canvas2D backend — a kind
|
|
45
|
+
* that isn't recorded is one the diagnosis below can't name, and it will fall
|
|
46
|
+
* back to the vaguer message.
|
|
47
|
+
*/
|
|
48
|
+
export declare function noteCanvasContext(canvas: HTMLCanvasElement, kind: CanvasContextKind): void;
|
|
49
|
+
/** The kind this canvas is committed to, if we were the ones who took it. */
|
|
50
|
+
export declare function acquiredCanvasContext(canvas: HTMLCanvasElement): CanvasContextKind | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* `getContext('2d')` + null-check + bookkeeping, as one call. Every Canvas2D
|
|
53
|
+
* backend hand-wrote that ritual with the same bare message, which is four
|
|
54
|
+
* copies of a diagnosis to keep in step — and three of them belong to the two
|
|
55
|
+
* consumers whose canvas *never* unmounts (dotplot, the synteny level), i.e.
|
|
56
|
+
* exactly where a reused element is reachable.
|
|
57
|
+
*/
|
|
58
|
+
export declare function acquireCanvas2D(canvas: HTMLCanvasElement): CanvasRenderingContext2D;
|
|
59
|
+
/**
|
|
60
|
+
* The error to throw when `getContext(requested)` returned null.
|
|
61
|
+
*
|
|
62
|
+
* Keeps each caller's historical message as the first clause (`Canvas 2D context
|
|
63
|
+
* not available`, and tests match on it) and appends the *reason*, which is the
|
|
64
|
+
* part that was missing: either "this element is already committed to X" — the
|
|
65
|
+
* recoverable case, with the remedy named — or an honest statement of the two
|
|
66
|
+
* possibilities when we don't know.
|
|
67
|
+
*/
|
|
68
|
+
export declare function canvasContextError(canvas: HTMLCanvasElement, requested: CanvasContextKind): Error;
|