@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.
Files changed (164) hide show
  1. package/LICENSE +201 -0
  2. package/esm/OverlayCanvas.d.ts +29 -0
  3. package/esm/OverlayCanvas.js +43 -0
  4. package/esm/RenderCanvas.d.ts +76 -0
  5. package/esm/RenderCanvas.js +38 -0
  6. package/esm/RenderLifecycleMixin.d.ts +180 -0
  7. package/esm/RenderLifecycleMixin.js +279 -0
  8. package/esm/ScrollLockedOverlay.d.ts +28 -0
  9. package/esm/ScrollLockedOverlay.js +42 -0
  10. package/esm/blockClipUtils.d.ts +27 -0
  11. package/esm/blockClipUtils.js +101 -0
  12. package/esm/canvas2dUtils.d.ts +372 -0
  13. package/esm/canvas2dUtils.js +564 -0
  14. package/esm/canvasContext.d.ts +68 -0
  15. package/esm/canvasContext.js +74 -0
  16. package/esm/colorRampLut.d.ts +17 -0
  17. package/esm/colorRampLut.js +25 -0
  18. package/esm/coverageBand.d.ts +107 -0
  19. package/esm/coverageBand.js +109 -0
  20. package/esm/coverageBandBuffers.d.ts +30 -0
  21. package/esm/coverageBandBuffers.js +13 -0
  22. package/esm/createRenderingBackend.d.ts +52 -0
  23. package/esm/createRenderingBackend.js +58 -0
  24. package/esm/displayPhase.d.ts +155 -0
  25. package/esm/displayPhase.js +97 -0
  26. package/esm/globalRenderingBackend.d.ts +90 -0
  27. package/esm/globalRenderingBackend.js +53 -0
  28. package/esm/gpuDevice.d.ts +91 -0
  29. package/esm/gpuDevice.js +277 -0
  30. package/esm/graphicsCapabilities.d.ts +98 -0
  31. package/esm/graphicsCapabilities.js +165 -0
  32. package/esm/hal/createHal.d.ts +22 -0
  33. package/esm/hal/createHal.js +91 -0
  34. package/esm/hal/deviceGpuCache.d.ts +36 -0
  35. package/esm/hal/deviceGpuCache.js +120 -0
  36. package/esm/hal/gpuHalBase.d.ts +55 -0
  37. package/esm/hal/gpuHalBase.js +81 -0
  38. package/esm/hal/index.d.ts +7 -0
  39. package/esm/hal/index.js +5 -0
  40. package/esm/hal/mockHal.d.ts +153 -0
  41. package/esm/hal/mockHal.js +297 -0
  42. package/esm/hal/oomReporter.d.ts +15 -0
  43. package/esm/hal/oomReporter.js +22 -0
  44. package/esm/hal/passIds.d.ts +22 -0
  45. package/esm/hal/passIds.js +36 -0
  46. package/esm/hal/regionRegistry.d.ts +11 -0
  47. package/esm/hal/regionRegistry.js +66 -0
  48. package/esm/hal/types.d.ts +116 -0
  49. package/esm/hal/types.js +1 -0
  50. package/esm/hal/webgl2Hal.d.ts +47 -0
  51. package/esm/hal/webgl2Hal.js +490 -0
  52. package/esm/hal/webgpuHal.d.ts +169 -0
  53. package/esm/hal/webgpuHal.js +722 -0
  54. package/esm/installUpload.d.ts +91 -0
  55. package/esm/installUpload.js +94 -0
  56. package/esm/instanceCache.d.ts +43 -0
  57. package/esm/instanceCache.js +53 -0
  58. package/esm/instancePass.d.ts +50 -0
  59. package/esm/instancePass.js +34 -0
  60. package/esm/keyedRenderingBackend.d.ts +41 -0
  61. package/esm/keyedRenderingBackend.js +19 -0
  62. package/esm/mapUploadSync.d.ts +31 -0
  63. package/esm/mapUploadSync.js +45 -0
  64. package/esm/namedReactions.d.ts +29 -0
  65. package/esm/namedReactions.js +62 -0
  66. package/esm/perRegionRenderingBackend.d.ts +114 -0
  67. package/esm/perRegionRenderingBackend.js +93 -0
  68. package/esm/recoveryBudget.d.ts +40 -0
  69. package/esm/recoveryBudget.js +56 -0
  70. package/esm/regionDataMap.d.ts +60 -0
  71. package/esm/regionDataMap.js +98 -0
  72. package/esm/renderBlock.d.ts +25 -0
  73. package/esm/renderBlock.js +10 -0
  74. package/esm/renderingBackendBase.d.ts +71 -0
  75. package/esm/renderingBackendBase.js +50 -0
  76. package/esm/shaders/antialias.js.generated.d.ts +4 -0
  77. package/esm/shaders/antialias.js.generated.js +25 -0
  78. package/esm/shaders/capsule.generated.d.ts +1 -0
  79. package/esm/shaders/capsule.generated.js +3 -0
  80. package/esm/shaders/colorRampLut.generated.d.ts +1 -0
  81. package/esm/shaders/colorRampLut.generated.js +3 -0
  82. package/esm/shaders/coverageBand.generated.d.ts +2 -0
  83. package/esm/shaders/coverageBand.generated.js +4 -0
  84. package/esm/shaders/coverageBar.generated.d.ts +4 -0
  85. package/esm/shaders/coverageBar.generated.js +6 -0
  86. package/esm/shaders/coverageBar.iface.generated.d.ts +85 -0
  87. package/esm/shaders/coverageBar.iface.generated.js +114 -0
  88. package/esm/shaders/coverageIndicator.generated.d.ts +4 -0
  89. package/esm/shaders/coverageIndicator.generated.js +6 -0
  90. package/esm/shaders/coverageIndicator.iface.generated.d.ts +85 -0
  91. package/esm/shaders/coverageIndicator.iface.generated.js +114 -0
  92. package/esm/shaders/coverageInterbase.generated.d.ts +4 -0
  93. package/esm/shaders/coverageInterbase.generated.js +6 -0
  94. package/esm/shaders/coverageInterbase.iface.generated.d.ts +93 -0
  95. package/esm/shaders/coverageInterbase.iface.generated.js +134 -0
  96. package/esm/shaders/coverageMod.generated.d.ts +4 -0
  97. package/esm/shaders/coverageMod.generated.js +6 -0
  98. package/esm/shaders/coverageMod.iface.generated.d.ts +97 -0
  99. package/esm/shaders/coverageMod.iface.generated.js +144 -0
  100. package/esm/shaders/coverageSnp.generated.d.ts +4 -0
  101. package/esm/shaders/coverageSnp.generated.js +6 -0
  102. package/esm/shaders/coverageSnp.iface.generated.d.ts +97 -0
  103. package/esm/shaders/coverageSnp.iface.generated.js +144 -0
  104. package/esm/shaders/hpmath.js.generated.d.ts +3 -0
  105. package/esm/shaders/hpmath.js.generated.js +48 -0
  106. package/esm/shaders/pointGlyph.generated.d.ts +1 -0
  107. package/esm/shaders/pointGlyph.generated.js +3 -0
  108. package/esm/shaders/pointGlyph.js.generated.d.ts +1 -0
  109. package/esm/shaders/pointGlyph.js.generated.js +7 -0
  110. package/esm/shaders/rowRect.generated.d.ts +3 -0
  111. package/esm/shaders/rowRect.generated.js +5 -0
  112. package/esm/shaders/rowRect.js.generated.d.ts +2 -0
  113. package/esm/shaders/rowRect.js.generated.js +13 -0
  114. package/esm/shaders/scoreScale.js.generated.d.ts +1 -0
  115. package/esm/shaders/scoreScale.js.generated.js +46 -0
  116. package/esm/slangPass.d.ts +49 -0
  117. package/esm/slangPass.js +22 -0
  118. package/esm/useEventCallback.d.ts +1 -0
  119. package/esm/useEventCallback.js +19 -0
  120. package/esm/useGraphicsCapabilities.d.ts +17 -0
  121. package/esm/useGraphicsCapabilities.js +38 -0
  122. package/esm/useRenderingBackend.d.ts +83 -0
  123. package/esm/useRenderingBackend.js +422 -0
  124. package/esm/useTabVisibilityRerender.d.ts +13 -0
  125. package/esm/useTabVisibilityRerender.js +39 -0
  126. package/esm/webgpuUtils.d.ts +23 -0
  127. package/esm/webgpuUtils.js +76 -0
  128. package/package.json +91 -0
  129. package/src/shaders/antialias.js.generated.ts +33 -0
  130. package/src/shaders/antialias.slang +112 -0
  131. package/src/shaders/capsule.generated.ts +4 -0
  132. package/src/shaders/capsule.slang +62 -0
  133. package/src/shaders/colorPack.slang +26 -0
  134. package/src/shaders/colorRampLut.generated.ts +4 -0
  135. package/src/shaders/colorRampLut.slang +51 -0
  136. package/src/shaders/coverageBand.generated.ts +6 -0
  137. package/src/shaders/coverageBand.slang +276 -0
  138. package/src/shaders/coverageBar.generated.ts +10 -0
  139. package/src/shaders/coverageBar.iface.generated.ts +172 -0
  140. package/src/shaders/coverageBar.slang +37 -0
  141. package/src/shaders/coverageIndicator.generated.ts +10 -0
  142. package/src/shaders/coverageIndicator.iface.generated.ts +172 -0
  143. package/src/shaders/coverageIndicator.slang +61 -0
  144. package/src/shaders/coverageInterbase.generated.ts +10 -0
  145. package/src/shaders/coverageInterbase.iface.generated.ts +198 -0
  146. package/src/shaders/coverageInterbase.slang +63 -0
  147. package/src/shaders/coverageMod.generated.ts +10 -0
  148. package/src/shaders/coverageMod.iface.generated.ts +211 -0
  149. package/src/shaders/coverageMod.slang +36 -0
  150. package/src/shaders/coverageSnp.generated.ts +10 -0
  151. package/src/shaders/coverageSnp.iface.generated.ts +211 -0
  152. package/src/shaders/coverageSnp.slang +52 -0
  153. package/src/shaders/diagonalGrid.slang +25 -0
  154. package/src/shaders/glyphEdgeAlpha.test.ts +241 -0
  155. package/src/shaders/hpmath.js.generated.ts +47 -0
  156. package/src/shaders/hpmath.slang +226 -0
  157. package/src/shaders/pointGlyph.generated.ts +4 -0
  158. package/src/shaders/pointGlyph.js.generated.ts +9 -0
  159. package/src/shaders/pointGlyph.slang +93 -0
  160. package/src/shaders/rowRect.generated.ts +8 -0
  161. package/src/shaders/rowRect.js.generated.ts +17 -0
  162. package/src/shaders/rowRect.slang +160 -0
  163. package/src/shaders/scoreScale.js.generated.ts +51 -0
  164. 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;