@jbrowse/render-core 5.0.0-beta.4 → 5.0.0-beta.8

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 (69) hide show
  1. package/esm/RenderLifecycleMixin.d.ts +1 -0
  2. package/esm/RenderLifecycleMixin.js +8 -0
  3. package/esm/gpuContextLostError.d.ts +30 -0
  4. package/esm/gpuContextLostError.js +40 -0
  5. package/esm/gpuDevice.js +4 -1
  6. package/esm/hal/gpuHalBase.d.ts +3 -3
  7. package/esm/hal/index.d.ts +1 -1
  8. package/esm/hal/mockHal.d.ts +3 -3
  9. package/esm/hal/mockHal.js +3 -2
  10. package/esm/hal/oomReporter.d.ts +7 -0
  11. package/esm/hal/oomReporter.js +10 -0
  12. package/esm/hal/types.d.ts +8 -1
  13. package/esm/hal/webgl2Hal.d.ts +2 -2
  14. package/esm/hal/webgl2Hal.js +8 -1
  15. package/esm/hal/webgpuHal.d.ts +12 -2
  16. package/esm/hal/webgpuHal.js +43 -5
  17. package/esm/marks/barMark.d.ts +28 -0
  18. package/esm/marks/barMark.js +63 -0
  19. package/esm/marks/drawAgainstHit.d.ts +85 -14
  20. package/esm/marks/drawAgainstHit.js +260 -38
  21. package/esm/marks/glyphPaint.d.ts +10 -2
  22. package/esm/marks/glyphPaint.js +21 -0
  23. package/esm/marks/index.d.ts +9 -2
  24. package/esm/marks/index.js +4 -1
  25. package/esm/marks/markBackend.d.ts +14 -2
  26. package/esm/marks/markBackend.js +43 -13
  27. package/esm/marks/markHit.d.ts +8 -1
  28. package/esm/marks/markHit.js +15 -0
  29. package/esm/marks/markInk.d.ts +14 -0
  30. package/esm/marks/markInk.js +34 -0
  31. package/esm/marks/markPlan.d.ts +25 -0
  32. package/esm/marks/markPlan.js +20 -0
  33. package/esm/marks/markRamp.d.ts +43 -0
  34. package/esm/marks/markRamp.js +68 -0
  35. package/esm/marks/pointMark.d.ts +8 -4
  36. package/esm/marks/pointMark.js +32 -9
  37. package/esm/marks/spanMark.d.ts +8 -0
  38. package/esm/marks/spanMark.js +19 -14
  39. package/esm/marks/types.d.ts +90 -3
  40. package/esm/marks/types.js +56 -4
  41. package/esm/shaders/barMark.generated.d.ts +4 -0
  42. package/esm/shaders/barMark.generated.js +6 -0
  43. package/esm/shaders/barMark.iface.generated.d.ts +64 -0
  44. package/esm/shaders/barMark.iface.generated.js +110 -0
  45. package/esm/shaders/markColor.generated.d.ts +3 -0
  46. package/esm/shaders/markColor.generated.js +5 -0
  47. package/esm/shaders/pointMark.generated.d.ts +3 -3
  48. package/esm/shaders/pointMark.generated.js +3 -3
  49. package/esm/shaders/pointMark.iface.generated.d.ts +17 -6
  50. package/esm/shaders/pointMark.iface.generated.js +27 -9
  51. package/esm/shaders/pointMark.js.generated.d.ts +1 -0
  52. package/esm/shaders/pointMark.js.generated.js +39 -0
  53. package/esm/shaders/scoreScale.generated.d.ts +2 -0
  54. package/esm/shaders/scoreScale.generated.js +4 -0
  55. package/esm/useRenderingBackend.d.ts +1 -16
  56. package/esm/useRenderingBackend.js +2 -24
  57. package/package.json +4 -2
  58. package/src/shaders/barMark.generated.ts +10 -0
  59. package/src/shaders/barMark.iface.generated.ts +160 -0
  60. package/src/shaders/barMark.slang +109 -0
  61. package/src/shaders/markColor.generated.ts +8 -0
  62. package/src/shaders/markColor.slang +47 -0
  63. package/src/shaders/pointMark.generated.ts +3 -3
  64. package/src/shaders/pointMark.iface.generated.ts +34 -10
  65. package/src/shaders/pointMark.js.generated.ts +41 -0
  66. package/src/shaders/pointMark.slang +15 -26
  67. package/src/shaders/scoreScale.generated.ts +6 -0
  68. package/src/shaders/scoreScale.slang +1 -0
  69. package/src/shaders/valueScale.slang +52 -0
@@ -0,0 +1,25 @@
1
+ import type { Mark, MarkFrame, PlannedPass } from './types.ts';
2
+ /**
3
+ * A mark list resolved against one frame's state: the marks whose `enabled`
4
+ * said yes, in list order, and the passes they draw.
5
+ *
6
+ * `drawMarks` asks every mark's gate, lens and staged compare per block, which
7
+ * is the right shape for a display that stages its uniforms through the marks
8
+ * — and at 120 section blocks a frame it is the whole cost the pileup measured
9
+ * and declined (`benches/pileupUniformWrite.bench.ts` has the table). A
10
+ * display that writes the uniforms itself, once per section, has nothing left
11
+ * for the per-block walk to decide: `planMarks` answers the frame question
12
+ * once, `drawPlannedPasses` issues a `drawPass` per planned mark against
13
+ * whatever the caller staged, and `marks` is the painter's list.
14
+ */
15
+ export interface MarkPlan<TRegion, TState extends MarkFrame> {
16
+ readonly marks: readonly Mark<TRegion, TState>[];
17
+ readonly passes: readonly PlannedPass[];
18
+ }
19
+ /**
20
+ * Resolve which marks draw this frame. Throws for an enabled mark the plan
21
+ * form cannot carry — one with a `band` or a `paintsBlock` — since a plan
22
+ * neither scissors nor asks the block anything, and dropping the gate silently
23
+ * would draw on the GPU what Canvas2D declines.
24
+ */
25
+ export declare function planMarks<TRegion, TState extends MarkFrame>(marks: readonly Mark<TRegion, TState>[], state: TState): MarkPlan<TRegion, TState>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Resolve which marks draw this frame. Throws for an enabled mark the plan
3
+ * form cannot carry — one with a `band` or a `paintsBlock` — since a plan
4
+ * neither scissors nor asks the block anything, and dropping the gate silently
5
+ * would draw on the GPU what Canvas2D declines.
6
+ */
7
+ export function planMarks(marks, state) {
8
+ const enabled = [];
9
+ const passes = [];
10
+ for (const mark of marks) {
11
+ if (!mark.enabled || mark.enabled(state)) {
12
+ if (!mark.planned) {
13
+ throw new Error(`mark ${mark.pass.id} declares a band or a block gate, which a frame plan does not carry; draw it through drawMarks`);
14
+ }
15
+ enabled.push(mark);
16
+ passes.push(mark.planned);
17
+ }
18
+ }
19
+ return { marks: enabled, passes };
20
+ }
@@ -0,0 +1,43 @@
1
+ import type { MarkRamp, MarkValueScaleType } from './types.ts';
2
+ /**
3
+ * A colour channel as a shape reads it: `color`, the packed ABGR the worker
4
+ * resolved, or `colorValue`, the raw values a frame's {@link MarkRamp} turns
5
+ * into colours here. A shape reading a ramp declares both optional and asks
6
+ * {@link colorBits} for the GPU and {@link paintColors} for Canvas2D.
7
+ */
8
+ export interface ColorChannel {
9
+ color?: Uint32Array;
10
+ colorValue?: Float32Array;
11
+ /** The Canvas2D bake, memoized on the payload; nothing else writes it. */
12
+ rampBake?: RampBake;
13
+ }
14
+ interface RampBake {
15
+ min: number;
16
+ max: number;
17
+ log: boolean;
18
+ lut: Uint8Array;
19
+ colors: Uint32Array;
20
+ }
21
+ /** `scoreScale`'s scale-type code for a declared value scale. */
22
+ export declare function valueScaleTypeCode(scale: MarkValueScaleType | undefined): 0 | 1;
23
+ /** `markColor.slang`'s three uniforms for a frame's ramp, or for none. */
24
+ export declare function rampUniforms(ramp: MarkRamp | undefined): {
25
+ rampMode: number;
26
+ rampMin: number;
27
+ rampMax: number;
28
+ };
29
+ /**
30
+ * The 4-byte instance lane, for the packer. Under a ramp it is the value's
31
+ * float32 bits viewed as the `uint` the attribute declares — the reinterpret
32
+ * `markColor.slang`'s `asfloat` undoes, and the reason a ramp adds no lane.
33
+ */
34
+ export declare function colorBits(c: ColorChannel): ArrayLike<number>;
35
+ /**
36
+ * The packed colours a painter fills with. Under a ramp the values are baked
37
+ * against the domain once and kept on the payload, so a repaint at an
38
+ * unchanged domain — every pan and every hover — walks no values and the
39
+ * painters' fill batching still sees runs of one colour. The bake reruns when
40
+ * the domain, the scale type or the ramp's bytes move.
41
+ */
42
+ export declare function paintColors(c: ColorChannel, count: number, ramp: MarkRamp | undefined): ArrayLike<number>;
43
+ export {};
@@ -0,0 +1,68 @@
1
+ import { RAMP_LINEAR, RAMP_LOG, RAMP_NONE, } from '../shaders/markColor.generated.js';
2
+ import { SCALE_TYPE_LOG } from '../shaders/scoreScale.generated.js';
3
+ import { normalizeScore } from '../shaders/scoreScale.js.generated.js';
4
+ const NO_COLORS = new Uint32Array(0);
5
+ /** `scoreScale`'s scale-type code for a declared value scale. */
6
+ export function valueScaleTypeCode(scale) {
7
+ return scale === 'log' ? SCALE_TYPE_LOG : 0;
8
+ }
9
+ /** `markColor.slang`'s three uniforms for a frame's ramp, or for none. */
10
+ export function rampUniforms(ramp) {
11
+ return ramp
12
+ ? {
13
+ rampMode: ramp.scale === 'log' ? RAMP_LOG : RAMP_LINEAR,
14
+ rampMin: ramp.domain[0],
15
+ rampMax: ramp.domain[1],
16
+ }
17
+ : { rampMode: RAMP_NONE, rampMin: 0, rampMax: 1 };
18
+ }
19
+ /**
20
+ * The 4-byte instance lane, for the packer. Under a ramp it is the value's
21
+ * float32 bits viewed as the `uint` the attribute declares — the reinterpret
22
+ * `markColor.slang`'s `asfloat` undoes, and the reason a ramp adds no lane.
23
+ */
24
+ export function colorBits(c) {
25
+ const { colorValue } = c;
26
+ return colorValue
27
+ ? new Uint32Array(colorValue.buffer, colorValue.byteOffset, colorValue.length)
28
+ : (c.color ?? NO_COLORS);
29
+ }
30
+ /**
31
+ * The packed colours a painter fills with. Under a ramp the values are baked
32
+ * against the domain once and kept on the payload, so a repaint at an
33
+ * unchanged domain — every pan and every hover — walks no values and the
34
+ * painters' fill batching still sees runs of one colour. The bake reruns when
35
+ * the domain, the scale type or the ramp's bytes move.
36
+ */
37
+ export function paintColors(c, count, ramp) {
38
+ const { colorValue } = c;
39
+ if (!ramp || !colorValue) {
40
+ return c.color ?? NO_COLORS;
41
+ }
42
+ const [min, max] = ramp.domain;
43
+ const log = ramp.scale === 'log';
44
+ const { lut } = ramp;
45
+ const bake = c.rampBake;
46
+ if (bake &&
47
+ bake.min === min &&
48
+ bake.max === max &&
49
+ bake.log === log &&
50
+ bake.lut === lut &&
51
+ bake.colors.length >= count) {
52
+ return bake.colors;
53
+ }
54
+ const entries = lut.length / 4;
55
+ const colors = new Uint32Array(count);
56
+ for (let i = 0; i < count; i++) {
57
+ const t = normalizeScore(colorValue[i], min, max, log ? SCALE_TYPE_LOG : 0, 1);
58
+ const o = Math.round(t * (entries - 1)) * 4;
59
+ colors[i] =
60
+ ((lut[o + 3] << 24) |
61
+ (lut[o + 2] << 16) |
62
+ (lut[o + 1] << 8) |
63
+ lut[o]) >>>
64
+ 0;
65
+ }
66
+ c.rampBake = { min, max, log, lut, colors };
67
+ return colors;
68
+ }
@@ -1,4 +1,5 @@
1
- import type { MarkShape } from './types.ts';
1
+ import type { ColorChannel } from './markRamp.ts';
2
+ import type { MarkRamp, MarkShape, MarkValueScaleType } from './types.ts';
2
3
  /**
3
4
  * The `point` shape's channels: a glyph per instance at `x`, on the `y` scale,
4
5
  * widening to a bar where `x2 - x` is wider than the glyph.
@@ -7,17 +8,20 @@ import type { MarkShape } from './types.ts';
7
8
  * one array holds SNPs (`x2 === x + 1`) and structural variants together, and
8
9
  * the shader, the painter and the hit test all take the same branch off it.
9
10
  */
10
- export interface PointChannels {
11
+ export interface PointChannels extends ColorChannel {
11
12
  x: Uint32Array;
12
13
  x2: Uint32Array;
13
14
  y: Float32Array;
14
- color: Uint32Array;
15
15
  glyph: Uint8Array;
16
16
  count: number;
17
17
  }
18
18
  export interface PointParams {
19
- /** `[min, max]` of the linear scale `y` is read through. */
19
+ /** `[min, max]` `y` is read through. */
20
20
  domain: [number, number];
21
+ /** How that domain is read; linear when absent. */
22
+ scaleType?: MarkValueScaleType;
23
+ /** The quantitative colour scale, for a point whose colour is a ramp. */
24
+ ramp?: MarkRamp;
21
25
  /** Glyph diameter in CSS px. */
22
26
  diameterPx: number;
23
27
  }
@@ -1,16 +1,18 @@
1
1
  import { bpRangeXTuple } from '../blockClipUtils.js';
2
2
  import { getDpr, makeBpMapper } from '../canvas2dUtils.js';
3
3
  import * as shader from '../shaders/pointMark.generated.js';
4
- import { pointDrawsBar, valueToYPx } from '../shaders/pointMark.js.generated.js';
4
+ import { pointDrawsBar, valueToYPxScaled, } from '../shaders/pointMark.js.generated.js';
5
5
  import { slangPass } from '../slangPass.js';
6
6
  import { abgrToCssRgba } from './colorFill.js';
7
- import { appendGlyph } from './glyphPaint.js';
7
+ import { appendGlyph, glyphBox } from './glyphPaint.js';
8
8
  import { inkAtPoint, inkOnRect, nearestInk } from './markHit.js';
9
+ import { colorBits, paintColors, rampUniforms, valueScaleTypeCode, } from './markRamp.js';
10
+ import { blockPx } from './spanMark.js';
9
11
  export const pointMark = {
10
12
  id: 'point',
11
13
  pass: {
12
14
  ...slangPass({ id: 'point', mod: shader }),
13
- pack: c => shader.packInstances(c, c.count),
15
+ pack: c => shader.packInstances({ ...c, color: colorBits(c) }, c.count),
14
16
  },
15
17
  writeUniforms(scratch, clip, block, frame, params) {
16
18
  shader.writeUniforms(scratch, {
@@ -18,6 +20,8 @@ export const pointMark = {
18
20
  canvasHeight: frame.canvasHeight,
19
21
  domainMin: params.domain[0],
20
22
  domainMax: params.domain[1],
23
+ valueScaleType: valueScaleTypeCode(params.scaleType),
24
+ ...rampUniforms(params.ramp),
21
25
  zero: 0,
22
26
  // viewportWidth and radiusPx stay in CSS units to match canvasHeight:
23
27
  // mixing a DPR-scaled radius with a CSS-scaled height draws vertically
@@ -28,15 +32,17 @@ export const pointMark = {
28
32
  });
29
33
  },
30
34
  paintBlock(ctx, channels, block, frame, params) {
31
- const { x, x2, y, color, glyph, count } = channels;
35
+ const { x, x2, y, glyph, count } = channels;
32
36
  if (count === 0) {
33
37
  return;
34
38
  }
39
+ const color = paintColors(channels, count, params.ramp);
35
40
  const { diameterPx, domain } = params;
36
41
  const r = diameterPx / 2;
37
42
  const canvasHeight = frame.canvasHeight;
38
43
  const domainMin = domain[0];
39
44
  const domainMax = domain[1];
45
+ const st = valueScaleTypeCode(params.scaleType);
40
46
  // The per-block closure, not the six-argument `bpToScreenPx`: that spelling
41
47
  // re-derives the region span and the block width at every call, and this
42
48
  // loop makes two calls per instance. Measured at 1.67x on 100K points.
@@ -56,7 +62,7 @@ export const pointMark = {
56
62
  }
57
63
  const xStart = bpToPx(x[i]);
58
64
  const xEnd = bpToPx(x2[i]);
59
- const yPx = valueToYPx(y[i], domainMin, domainMax, canvasHeight);
65
+ const yPx = valueToYPxScaled(y[i], domainMin, domainMax, canvasHeight, st);
60
66
  const widthPx = Math.abs(xEnd - xStart);
61
67
  if (pointDrawsBar(widthPx, r)) {
62
68
  ctx.rect(Math.min(xStart, xEnd), yPx - r, widthPx, diameterPx);
@@ -67,6 +73,25 @@ export const pointMark = {
67
73
  }
68
74
  ctx.fill();
69
75
  },
76
+ // A bar is the rect the painter fills, unpadded on every side; a glyph is
77
+ // the box `appendGlyph` paints inside.
78
+ ink(channels, block, frame, params, i) {
79
+ const { x, x2, y, glyph } = channels;
80
+ const { diameterPx, domain } = params;
81
+ const xStart = blockPx(block, x[i]);
82
+ const xEnd = blockPx(block, x2[i]);
83
+ const cy = valueToYPxScaled(y[i], domain[0], domain[1], frame.canvasHeight, valueScaleTypeCode(params.scaleType));
84
+ const lo = Math.min(xStart, xEnd);
85
+ const hi = Math.max(xStart, xEnd);
86
+ const r = diameterPx / 2;
87
+ return pointDrawsBar(hi - lo, r)
88
+ ? { left: lo, top: cy - r, width: hi - lo, height: diameterPx }
89
+ : glyphBox(glyph[i], xStart, cy, diameterPx);
90
+ },
91
+ // Its own rather than the one `ink` implies, for the glyph: a dense plot
92
+ // stacks glyphs whose boxes all contain the cursor at distance 0, and the
93
+ // one under the cursor is the nearest CENTRE, which no box can say. A bar
94
+ // is grabbed anywhere inside the rect it fills, as the derived test would.
70
95
  hitNearest(channels, block, frame, params, xPx, yPx, candidates, maxDistSq) {
71
96
  const { x, x2, y } = channels;
72
97
  const bpToPx = makeBpMapper(block);
@@ -74,13 +99,11 @@ export const pointMark = {
74
99
  const domainMin = domain[0];
75
100
  const domainMax = domain[1];
76
101
  const canvasHeight = frame.canvasHeight;
102
+ const st = valueScaleTypeCode(params.scaleType);
77
103
  return nearestInk(candidates, maxDistSq, i => {
78
104
  const xStart = bpToPx(x[i]);
79
105
  const xEnd = bpToPx(x2[i]);
80
- const cy = valueToYPx(y[i], domainMin, domainMax, canvasHeight);
81
- // A bar is the rect the painter fills, unpadded on every side, so the
82
- // drawn bar and this box are one rectangle. A glyph has no rect to clamp
83
- // into and offers its centre.
106
+ const cy = valueToYPxScaled(y[i], domainMin, domainMax, canvasHeight, st);
84
107
  const lo = Math.min(xStart, xEnd);
85
108
  const hi = Math.max(xStart, xEnd);
86
109
  return pointDrawsBar(hi - lo, diameterPx / 2)
@@ -1,3 +1,4 @@
1
+ import type { RenderBlock } from '../renderBlock.ts';
1
2
  import type { MarkShape } from './types.ts';
2
3
  /**
3
4
  * The `span` shape's channels: a coloured rectangle from `x` to `x2` on the
@@ -7,6 +8,12 @@ export interface SpanChannels {
7
8
  x: Uint32Array;
8
9
  x2: Uint32Array;
9
10
  row: Uint32Array;
11
+ /**
12
+ * Packed ABGR, resolved in the worker — a span's colour has no ramp arm.
13
+ * `rowRect`'s uniform struct is shared with four other consumers, and a
14
+ * sampler on it would move all of them for a channel no config asks for
15
+ * on a span (ADR-113).
16
+ */
10
17
  color: Uint32Array;
11
18
  count: number;
12
19
  }
@@ -42,4 +49,5 @@ export interface SpanParams {
42
49
  /** Rows-area scroll offset in CSS px; 0 for a canvas sized to its content. */
43
50
  scrollTop: number;
44
51
  }
52
+ export declare function blockPx(block: RenderBlock, bp: number): number;
45
53
  export declare const spanMark: MarkShape<SpanChannels, SpanParams>;
@@ -1,10 +1,15 @@
1
1
  import { bpRangeXTuple } from '../blockClipUtils.js';
2
- import { makeBpMapper, spanLeft } from '../canvas2dUtils.js';
2
+ import { bpToScreenPx, makeBpMapper, spanLeft } from '../canvas2dUtils.js';
3
3
  import { drawnRowHeightPx, rowBandOffsetPx, } from '../shaders/rowRect.js.generated.js';
4
4
  import * as shader from '../shaders/spanMark.generated.js';
5
5
  import { slangPass } from '../slangPass.js';
6
6
  import { makeAbgrFill } from './colorFill.js';
7
- import { inkOnRect, nearestInk } from './markHit.js';
7
+ // Per instance rather than through `makeBpMapper`: `ink` is asked one
8
+ // instance at a time, and a closure per ask is the allocation the mapper
9
+ // exists to hoist out of a loop.
10
+ export function blockPx(block, bp) {
11
+ return bpToScreenPx(bp, block.start, block.end, block.screenStartPx, block.screenEndPx, block.reversed);
12
+ }
8
13
  export const spanMark = {
9
14
  id: 'span',
10
15
  pass: {
@@ -41,19 +46,19 @@ export const spanMark = {
41
46
  ctx.fillRect(spanLeft(xa, xb, width), offset + rowHeight * row[i] - scrollTop, width + seamPx, h);
42
47
  }
43
48
  },
44
- hitNearest(channels, block, _frame, params, xPx, yPx, candidates, maxDistSq) {
49
+ ink(channels, block, _frame, params, i) {
45
50
  const { x, x2, row } = channels;
46
51
  const { rowHeight, rowProportion, minWidthPx, scrollTop } = params;
47
- const h = drawnRowHeightPx(rowHeight, rowProportion);
48
- const offset = rowBandOffsetPx(rowHeight, rowProportion);
49
- const bpToPx = makeBpMapper(block);
50
- return nearestInk(candidates, maxDistSq, i => {
51
- const xa = bpToPx(x[i]);
52
- const xb = bpToPx(x2[i]);
53
- const width = Math.max(minWidthPx, Math.abs(xb - xa));
54
- // the rect `paintBlock` fills, less `seamPx`, which is painter-only
55
- // overdraw
56
- return inkOnRect(xPx, yPx, spanLeft(xa, xb, width), offset + rowHeight * row[i] - scrollTop, width, h);
57
- });
52
+ const xa = blockPx(block, x[i]);
53
+ const xb = blockPx(block, x2[i]);
54
+ const width = Math.max(minWidthPx, Math.abs(xb - xa));
55
+ return {
56
+ left: spanLeft(xa, xb, width),
57
+ top: rowBandOffsetPx(rowHeight, rowProportion) +
58
+ rowHeight * row[i] -
59
+ scrollTop,
60
+ width,
61
+ height: drawnRowHeightPx(rowHeight, rowProportion),
62
+ };
58
63
  },
59
64
  };
@@ -31,8 +31,35 @@ export interface MarkContext2D extends ClipContext2D {
31
31
  closePath(): void;
32
32
  fill(): void;
33
33
  stroke(): void;
34
+ drawImage?(image: CanvasImageSource, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void;
34
35
  }
36
+ /**
37
+ * A canvas a mark's pass samples in place of a colour ramp, with the size the
38
+ * texture is allocated at. A fresh object per repaint of the canvas: the
39
+ * backend uploads on identity, and a canvas element's identity never moves.
40
+ */
41
+ export interface MarkImage {
42
+ image: HTMLCanvasElement | OffscreenCanvas;
43
+ width: number;
44
+ height: number;
45
+ }
46
+ /** What a mark's pass binds: a 256-entry RGBA ramp, or a canvas. */
47
+ export type MarkTexture = Uint8Array | MarkImage;
35
48
  export type MarkFrame = FrameDimensions;
49
+ /** How a declared value scale reads its domain. */
50
+ export type MarkValueScaleType = 'linear' | 'log';
51
+ /**
52
+ * The quantitative colour scale a frame paints a ramp channel through: the
53
+ * domain, unioned over the loaded regions by the display; the declared scale
54
+ * type; and the 256-entry RGBA LUT the pass's sampler binds. A shape that
55
+ * reads it takes the raw values in `colorValue` and resolves them per frame,
56
+ * so a pan that widens the domain uploads no instance bytes.
57
+ */
58
+ export interface MarkRamp {
59
+ domain: [number, number];
60
+ scale: MarkValueScaleType;
61
+ lut: Uint8Array;
62
+ }
36
63
  /** A horizontal strip of the canvas, in CSS px down from its top edge. */
37
64
  export interface MarkBand {
38
65
  top: number;
@@ -45,6 +72,13 @@ export interface MarkHit {
45
72
  y: number;
46
73
  distSq: number;
47
74
  }
75
+ /** An axis-aligned box of the canvas, in CSS px. */
76
+ export interface InkRect {
77
+ left: number;
78
+ top: number;
79
+ width: number;
80
+ height: number;
81
+ }
48
82
  /**
49
83
  * A shape: one hand-written `.slang`, one packer, one Canvas2D painter and one
50
84
  * hit test, all reading the same channel arrays.
@@ -82,6 +116,19 @@ export interface MarkShape<TChannels, TParams> {
82
116
  * and its triangles turn off together.
83
117
  */
84
118
  paintsBlock?(block: RenderBlock, frame: MarkFrame, params: TParams): boolean;
119
+ /**
120
+ * The rect `paintBlock` fills for instance `i`, or undefined for an instance
121
+ * it skips — a row the viewport has scrolled past, a bar with no height. A
122
+ * shape whose every instance is one box declares this and nothing about
123
+ * hovering: `defineMark` derives `hitNearest` from it where none is
124
+ * declared, and a display's highlight guide reads it for the instances it
125
+ * names. A shape whose ink is not a box (a ribbon, an arc, a capsule) leaves
126
+ * it off and keeps its own hit test.
127
+ *
128
+ * Painter-only overdraw — `span`'s seam — is not ink; `drawAgainstHit.ts`
129
+ * holds the two to each other within a pixel.
130
+ */
131
+ ink?(channels: TChannels, block: RenderBlock, frame: MarkFrame, params: TParams, i: number): InkRect | undefined;
85
132
  /**
86
133
  * The nearest drawn ink to `(xPx, yPx)` among `candidates`, or undefined if
87
134
  * nothing beats `maxDistSq`.
@@ -100,7 +147,8 @@ export interface MarkShape<TChannels, TParams> {
100
147
  *
101
148
  * Optional, because a shape earns it from a consumer like any other member.
102
149
  * MAF answers a hit from row/column arithmetic over the alignment rather than
103
- * from `span`'s channels, so it reads none.
150
+ * from `span`'s channels, so it reads none. A shape with `ink` and no hit test
151
+ * gets `inkHitNearest` — the nearest of its boxes — through `shapeHitNearest`.
104
152
  */
105
153
  hitNearest?(channels: TChannels, block: RenderBlock, frame: MarkFrame, params: TParams, xPx: number, yPx: number, candidates: Iterable<number>, maxDistSq: number): MarkHit | undefined;
106
154
  }
@@ -126,6 +174,15 @@ export interface StagedUniforms {
126
174
  writer: unknown;
127
175
  params: unknown;
128
176
  }
177
+ /**
178
+ * A mark's pass as a frame plan draws it: what `hal.drawPass(id, regionKey,
179
+ * bufferOf)` takes and nothing else, resolved once per frame so the per-block
180
+ * loop reads two fields per mark.
181
+ */
182
+ export interface PlannedPass {
183
+ readonly id: string;
184
+ readonly bufferOf: string | undefined;
185
+ }
129
186
  /**
130
187
  * A shape bound to one display's region payload and render state — what
131
188
  * `defineMark` returns and what a display actually holds.
@@ -150,10 +207,33 @@ export interface Mark<TRegion, TState extends MarkFrame> {
150
207
  * undefined case — a shader owns its sampler unconditionally, and a textured
151
208
  * pass with no texture never draws on the WebGPU HAL.
152
209
  */
153
- readonly texture?: (state: TState, region: TRegion) => Uint8Array | undefined;
210
+ readonly texture?: (state: TState, region: TRegion) => MarkTexture | undefined;
211
+ /**
212
+ * The frame-level gate: whether the mark draws at all under `state`, read
213
+ * off the display-wide state and nothing else. `planMarks` asks it once per
214
+ * frame; `drawRegion`, `paintBlock` and `hitNearest` ask it per block, so a
215
+ * list drawn through `drawMarks` gates like one drawn through a plan — and
216
+ * so does the hit test, which is what stops a switched-off layer answering a
217
+ * hover over blank pixels. A question about the block is `paintsBlock`, on
218
+ * the shape.
219
+ */
220
+ readonly enabled?: (state: TState) => boolean;
221
+ /**
222
+ * The pass a frame plan draws for this mark, or undefined for a mark the plan
223
+ * form cannot carry: one with a `band` (a plan's caller owns the scissor) or a
224
+ * `paintsBlock` (a plan asks the block nothing).
225
+ */
226
+ readonly planned?: PlannedPass;
154
227
  drawRegion(hal: GpuHal, scratch: ArrayBuffer, block: RenderBlock, clip: BlockClipResult, region: TRegion, state: TState, regionKey: number, staged?: StagedUniforms): void;
155
228
  paintBlock(ctx: MarkContext2D, region: TRegion, block: RenderBlock, state: TState): void;
156
229
  hitNearest?(region: TRegion, block: RenderBlock, state: TState, xPx: number, yPx: number, candidates: Iterable<number>, maxDistSq: number): MarkHit | undefined;
230
+ /**
231
+ * The box instance `i` painted under the mark's gates, clipped to its band
232
+ * where it has one: what a highlight guide draws for the instance a display
233
+ * names. Undefined off a shape with no `ink`, and for an instance nothing
234
+ * drew.
235
+ */
236
+ ink?(region: TRegion, block: RenderBlock, state: TState, i: number): InkRect | undefined;
157
237
  }
158
238
  /**
159
239
  * `{ shape, channels }` over typed arrays — the declaration a display writes in
@@ -205,6 +285,12 @@ export interface Mark<TRegion, TState extends MarkFrame> {
205
285
  * nothing, and the shape still places Y against the whole canvas — the band is
206
286
  * a clip, never an offset, which is what lets one `scrollTop` serve both
207
287
  * backends. A mark without one paints wherever its shape puts ink.
288
+ *
289
+ * `enabled` is the setting that turns the mark off for a whole frame — the
290
+ * pileup's "show mismatches" — and it is one gate for the three consumers: a
291
+ * mark that does not draw does not answer a hover either. It reads the state
292
+ * alone so `planMarks` can resolve it once per frame; a gate that needs the
293
+ * block is the shape's `paintsBlock`.
208
294
  */
209
295
  export declare function defineMark<TRegion, TState extends MarkFrame, TChannels, TParams>(spec: {
210
296
  shape: MarkShape<TChannels, TParams>;
@@ -212,5 +298,6 @@ export declare function defineMark<TRegion, TState extends MarkFrame, TChannels,
212
298
  params: (state: TState, region: TRegion, block: RenderBlock) => TParams;
213
299
  bufferOf?: Mark<TRegion, TState>;
214
300
  band?: (state: TState) => MarkBand;
215
- texture?: (state: TState, region: TRegion) => Uint8Array | undefined;
301
+ texture?: (state: TState, region: TRegion) => MarkTexture | undefined;
302
+ enabled?: (state: TState) => boolean;
216
303
  }): Mark<TRegion, TState>;
@@ -1,4 +1,5 @@
1
1
  import { devicePxBand, withClip } from '../canvas2dUtils.js';
2
+ import { shapeHitNearest } from './markHit.js';
2
3
  /**
3
4
  * `{ shape, channels }` over typed arrays — the declaration a display writes in
4
5
  * place of a `GpuXxxRenderer` class, a `Canvas2DXxxRenderer` class, a factory
@@ -49,9 +50,17 @@ import { devicePxBand, withClip } from '../canvas2dUtils.js';
49
50
  * nothing, and the shape still places Y against the whole canvas — the band is
50
51
  * a clip, never an offset, which is what lets one `scrollTop` serve both
51
52
  * backends. A mark without one paints wherever its shape puts ink.
53
+ *
54
+ * `enabled` is the setting that turns the mark off for a whole frame — the
55
+ * pileup's "show mismatches" — and it is one gate for the three consumers: a
56
+ * mark that does not draw does not answer a hover either. It reads the state
57
+ * alone so `planMarks` can resolve it once per frame; a gate that needs the
58
+ * block is the shape's `paintsBlock`.
52
59
  */
53
60
  export function defineMark(spec) {
54
- const { shape, channels, params, band, texture } = spec;
61
+ const { shape, channels, params, band, texture, enabled } = spec;
62
+ const hitNearest = shapeHitNearest(shape);
63
+ const shapeInk = shape.ink?.bind(shape);
55
64
  const lender = spec.bufferOf?.pass;
56
65
  if (lender &&
57
66
  (lender.instanceStride !== shape.pass.instanceStride ||
@@ -60,6 +69,7 @@ export function defineMark(spec) {
60
69
  throw new Error(`mark ${shape.id} draws off ${lender.id}'s buffer but declares a different instance struct`);
61
70
  }
62
71
  const bufferOf = lender?.id;
72
+ const planned = band || shape.paintsBlock ? undefined : { id: shape.pass.id, bufferOf };
63
73
  return {
64
74
  pass: {
65
75
  ...shape.pass,
@@ -70,7 +80,12 @@ export function defineMark(spec) {
70
80
  },
71
81
  bufferOf,
72
82
  texture,
83
+ enabled,
84
+ planned,
73
85
  drawRegion(hal, scratch, block, clip, region, state, regionKey, staged) {
86
+ if (enabled && !enabled(state)) {
87
+ return;
88
+ }
74
89
  const strip = band?.(state);
75
90
  const scissor = strip
76
91
  ? devicePxBand(strip.top, strip.height, clip.scaleY, clip.pxH)
@@ -98,6 +113,9 @@ export function defineMark(spec) {
98
113
  }
99
114
  },
100
115
  paintBlock(ctx, region, block, state) {
116
+ if (enabled && !enabled(state)) {
117
+ return;
118
+ }
101
119
  const strip = band?.(state);
102
120
  const c = channels(region);
103
121
  if ((!strip || strip.height > 0) && c !== undefined) {
@@ -115,7 +133,7 @@ export function defineMark(spec) {
115
133
  }
116
134
  }
117
135
  },
118
- hitNearest: shape.hitNearest
136
+ hitNearest: hitNearest
119
137
  ? (region, block, state, xPx, yPx, candidates, maxDistSq) => {
120
138
  const strip = band?.(state);
121
139
  const c = channels(region);
@@ -125,19 +143,53 @@ export function defineMark(spec) {
125
143
  // either. The cursor being inside the strip is a fourth, and the ink
126
144
  // being inside it the fifth: a band is a CLIP, so ink outside one was
127
145
  // never drawn and cannot be the nearest thing to anything.
128
- if ((!strip || strip.height > 0) &&
146
+ //
147
+ // The fifth gate REJECTS rather than re-runs, and that is the one
148
+ // conservative edge here: the shape picks its winner without knowing
149
+ // about the band, so a clipped-away nearest masks a farther candidate
150
+ // whose ink IS inside the strip, and the mark answers nothing instead
151
+ // of that one. Safe in the direction that matters — it never claims a
152
+ // hover over pixels neither backend drew — and closing it would mean
153
+ // pushing the band down into every shape's own ink test, where the
154
+ // band is deliberately not.
155
+ if ((!enabled || enabled(state)) &&
156
+ (!strip || strip.height > 0) &&
129
157
  c !== undefined &&
130
158
  !bandExcludes(strip, yPx)) {
131
159
  const p = params(state, region, block);
132
160
  if (!shape.paintsBlock || shape.paintsBlock(block, state, p)) {
133
- hit = shape.hitNearest(c, block, state, p, xPx, yPx, candidates, maxDistSq);
161
+ hit = hitNearest(c, block, state, p, xPx, yPx, candidates, maxDistSq);
134
162
  }
135
163
  }
136
164
  return hit && !bandExcludes(strip, hit.y) ? hit : undefined;
137
165
  }
138
166
  : undefined,
167
+ ink: shapeInk
168
+ ? (region, block, state, i) => {
169
+ const strip = band?.(state);
170
+ const c = channels(region);
171
+ if ((enabled && !enabled(state)) ||
172
+ (strip && strip.height <= 0) ||
173
+ c === undefined) {
174
+ return undefined;
175
+ }
176
+ const p = params(state, region, block);
177
+ if (shape.paintsBlock && !shape.paintsBlock(block, state, p)) {
178
+ return undefined;
179
+ }
180
+ const r = shapeInk(c, block, state, p, i);
181
+ return r && strip ? clipToBand(r, strip) : r;
182
+ }
183
+ : undefined,
139
184
  };
140
185
  }
186
+ function clipToBand(r, strip) {
187
+ const top = Math.max(r.top, strip.top);
188
+ const bottom = Math.min(r.top + r.height, strip.top + strip.height);
189
+ return bottom > top
190
+ ? { left: r.left, top, width: r.width, height: bottom - top }
191
+ : undefined;
192
+ }
141
193
  // What a mark with nothing in this region packs. An empty pack IS the release
142
194
  // — every HAL deletes the pass's prior buffer before it looks at the count.
143
195
  const NOTHING = new ArrayBuffer(0);