@jbrowse/render-core 5.0.0-beta.1 → 5.0.0-beta.2

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.
@@ -95,11 +95,11 @@ export declare function RenderLifecycleMixin(): import("@jbrowse/mobx-state-tree
95
95
  * never called and `canvasDrawn` can never flip).
96
96
  *
97
97
  * Lives here, beside `canvasDrawn`, because every consumer of "has this
98
- * display painted" needs the pair — and until 2026-08 each family
99
- * declared its own copy (per-region hard-coded `true`, global carried the
100
- * hook for LD), so a display could express the state only to whichever
101
- * family it happened to compose. See `painted` below for the reader that
102
- * was missed.
98
+ * display painted" needs the pair. Both LGV fetch foundations fill it
99
+ * with `!fetchInert`, so a display there states its placeholder once,
100
+ * through that hook; the raw default reaches only a display composing
101
+ * this mixin outside them. See `painted` below for the reader that was
102
+ * missed while the two were separate overrides.
103
103
  */
104
104
  readonly rendersCanvas: boolean;
105
105
  /**
@@ -104,11 +104,11 @@ export function RenderLifecycleMixin() {
104
104
  * never called and `canvasDrawn` can never flip).
105
105
  *
106
106
  * Lives here, beside `canvasDrawn`, because every consumer of "has this
107
- * display painted" needs the pair — and until 2026-08 each family
108
- * declared its own copy (per-region hard-coded `true`, global carried the
109
- * hook for LD), so a display could express the state only to whichever
110
- * family it happened to compose. See `painted` below for the reader that
111
- * was missed.
107
+ * display painted" needs the pair. Both LGV fetch foundations fill it
108
+ * with `!fetchInert`, so a display there states its placeholder once,
109
+ * through that hook; the raw default reaches only a display composing
110
+ * this mixin outside them. See `painted` below for the reader that was
111
+ * missed while the two were separate overrides.
112
112
  */
113
113
  get rendersCanvas() {
114
114
  return true;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A contract family — the `<family>` in the `[jbrowse <family> contract]`
3
+ * prefix every contract check in the tree reports under.
4
+ * `config/jest/console.js` matches `\w+`, so a new one reaches the test gate
5
+ * without touching it.
6
+ */
7
+ export type ContractFamily = 'display' | 'session' | 'figure';
8
+ export interface ContractReport {
9
+ family: ContractFamily;
10
+ message: string;
11
+ /** what armed the channel, so a host can say why it is showing this */
12
+ armedBy: string;
13
+ }
14
+ /**
15
+ * Whether the checks should run at all.
16
+ *
17
+ * Read it where a check costs something to run — the `MutationObserver` a live
18
+ * figure installs, the per-frame payload walk in `installUpload`. A check whose
19
+ * cost is a `WeakSet` lookup should run unconditionally and report through
20
+ * {@link reportContractViolation} instead, so that arming the channel late
21
+ * still surfaces what it found.
22
+ */
23
+ export declare function contractReportsOn(): boolean;
24
+ /**
25
+ * Turn the channel on in a production build, saying what turned it on.
26
+ *
27
+ * The reason is not decoration: a notification that appears in an app the
28
+ * reader did not build has to answer "why am I seeing this" in its own text, or
29
+ * it reads as the app being broken rather than as a plugin being under
30
+ * development. First caller wins, so the earliest evidence is the one quoted.
31
+ */
32
+ export declare function enableContractReports(reason: string): void;
33
+ /**
34
+ * Where a host puts contract violations that reach a user — a session
35
+ * notification in the apps. Installing one drains whatever was found before the
36
+ * host existed, and the disposer only clears the sink it installed, so a
37
+ * session torn down after its replacement was built does not take the live
38
+ * one's sink with it.
39
+ */
40
+ export declare function setContractReportSink(fn: (report: ContractReport) => void): () => void;
41
+ /**
42
+ * Report a violation of an ordering contract no type can state.
43
+ *
44
+ * In tree this is the `console.error` it has always been, and the jest gate is
45
+ * what listens. Out of tree it is the only thing that reaches a plugin author
46
+ * at all: their display runs in a production build of somebody's app, which
47
+ * strips nothing here, and a violation surfaces the moment anything arms the
48
+ * channel — a `localStorage` flag, a plugin loaded from localhost, or a site
49
+ * whose config asks for it.
50
+ *
51
+ * `agent-docs/reference/ARCHITECTURAL_LIMITS.md` §"Ordering is the contract" is
52
+ * the register of what reports here.
53
+ */
54
+ export declare function reportContractViolation(family: ContractFamily, message: string): void;
@@ -0,0 +1,112 @@
1
+ const DEVELOPER_MODE_KEY = 'jbrowseDeveloperMode';
2
+ // The flag a plugin author can set on any deployed JBrowse without owning it or
3
+ // asking anyone, which is the population this whole channel is for.
4
+ function storageFlag() {
5
+ try {
6
+ return typeof localStorage === 'undefined' ||
7
+ localStorage.getItem(DEVELOPER_MODE_KEY) === null
8
+ ? undefined
9
+ : `localStorage.${DEVELOPER_MODE_KEY} is set`;
10
+ }
11
+ catch {
12
+ // storage can be denied outright, and a diagnostic is not the thing to take
13
+ // the app down with
14
+ return undefined;
15
+ }
16
+ }
17
+ // Module scope for the same reason `numberGrouping` is: a check is called from
18
+ // a mixin factory, an MST action and a React effect that cannot share a
19
+ // closure, and each realm resolves this once at startup rather than through an
20
+ // observable a worker could not see.
21
+ let armedBy = process.env.NODE_ENV === 'production'
22
+ ? storageFlag()
23
+ : 'this is a development build';
24
+ let sink;
25
+ const unlogged = [];
26
+ const undelivered = [];
27
+ const PENDING_LIMIT = 20;
28
+ function remember(queue, found) {
29
+ if (queue.length < PENDING_LIMIT) {
30
+ queue.push(found);
31
+ }
32
+ }
33
+ /**
34
+ * Whether the checks should run at all.
35
+ *
36
+ * Read it where a check costs something to run — the `MutationObserver` a live
37
+ * figure installs, the per-frame payload walk in `installUpload`. A check whose
38
+ * cost is a `WeakSet` lookup should run unconditionally and report through
39
+ * {@link reportContractViolation} instead, so that arming the channel late
40
+ * still surfaces what it found.
41
+ */
42
+ export function contractReportsOn() {
43
+ return armedBy !== undefined;
44
+ }
45
+ /**
46
+ * Turn the channel on in a production build, saying what turned it on.
47
+ *
48
+ * The reason is not decoration: a notification that appears in an app the
49
+ * reader did not build has to answer "why am I seeing this" in its own text, or
50
+ * it reads as the app being broken rather than as a plugin being under
51
+ * development. First caller wins, so the earliest evidence is the one quoted.
52
+ */
53
+ export function enableContractReports(reason) {
54
+ armedBy ??= reason;
55
+ for (const found of unlogged.splice(0)) {
56
+ emit(found, armedBy);
57
+ }
58
+ }
59
+ /**
60
+ * Where a host puts contract violations that reach a user — a session
61
+ * notification in the apps. Installing one drains whatever was found before the
62
+ * host existed, and the disposer only clears the sink it installed, so a
63
+ * session torn down after its replacement was built does not take the live
64
+ * one's sink with it.
65
+ */
66
+ export function setContractReportSink(fn) {
67
+ sink = fn;
68
+ if (armedBy !== undefined) {
69
+ for (const found of undelivered.splice(0)) {
70
+ fn({ ...found, armedBy });
71
+ }
72
+ }
73
+ return () => {
74
+ if (sink === fn) {
75
+ sink = undefined;
76
+ }
77
+ };
78
+ }
79
+ function emit(found, armed) {
80
+ // `console.error`, never a throw: an error escaping `afterAttach` is read by
81
+ // the session loader as an invalid track and the display is silently dropped,
82
+ // which would hide the very violation being reported. The prefix is what
83
+ // `config/jest/console.js` buffers and `contractGate.js` fails a test on.
84
+ console.error(`[jbrowse ${found.family} contract] ${found.message}`);
85
+ if (sink === undefined) {
86
+ remember(undelivered, found);
87
+ }
88
+ else {
89
+ sink({ ...found, armedBy: armed });
90
+ }
91
+ }
92
+ /**
93
+ * Report a violation of an ordering contract no type can state.
94
+ *
95
+ * In tree this is the `console.error` it has always been, and the jest gate is
96
+ * what listens. Out of tree it is the only thing that reaches a plugin author
97
+ * at all: their display runs in a production build of somebody's app, which
98
+ * strips nothing here, and a violation surfaces the moment anything arms the
99
+ * channel — a `localStorage` flag, a plugin loaded from localhost, or a site
100
+ * whose config asks for it.
101
+ *
102
+ * `agent-docs/reference/ARCHITECTURAL_LIMITS.md` §"Ordering is the contract" is
103
+ * the register of what reports here.
104
+ */
105
+ export function reportContractViolation(family, message) {
106
+ if (armedBy === undefined) {
107
+ remember(unlogged, { family, message });
108
+ }
109
+ else {
110
+ emit({ family, message }, armedBy);
111
+ }
112
+ }
@@ -83,6 +83,15 @@ export interface DisplayLoadingInputs {
83
83
  * click.
84
84
  */
85
85
  isLoadingOrCanceled: boolean;
86
+ /**
87
+ * A load this display depends on beyond its primary fetch has not landed for
88
+ * the first time, so the frame the primary fetch calls current is still
89
+ * missing something (multi-way synteny's lane genes and links). A loading
90
+ * term rather than a `dataSuperseded` fold: that flag holds the export
91
+ * through every later refetch too, and a scrim over lanes that are already
92
+ * drawn is the thing a display saying this wants to avoid.
93
+ */
94
+ awaitingDependentData: boolean;
86
95
  /**
87
96
  * Whether this display paints a canvas in its current configuration. Gates
88
97
  * the pre-first-paint term alone, because a display that is showing a
@@ -142,7 +151,7 @@ export interface DisplayLoadingInputs {
142
151
  * IntersectionObserver's first callback, which is exactly when a display must
143
152
  * not flash `ready`. Only the paint it will never do is excused.
144
153
  */
145
- export declare function computeLoadingTerm({ fetchInert, viewportEmpty, isLoadingOrCanceled, rendersCanvas, canvasDrawn, }: DisplayLoadingInputs, viewportCurrent: () => boolean, hostMounted?: () => boolean): boolean;
154
+ export declare function computeLoadingTerm({ fetchInert, viewportEmpty, isLoadingOrCanceled, awaitingDependentData, rendersCanvas, canvasDrawn, }: DisplayLoadingInputs, viewportCurrent: () => boolean, hostMounted?: () => boolean): boolean;
146
155
  /**
147
156
  * The same ranking minus `renderError`, for a display with no rendering backend
148
157
  * to fail (arc's main-thread SVG). `computeDisplayPhase` delegates here rather
@@ -70,10 +70,11 @@ export function computeDisplayPhase({ renderError, regionTooLarge, error }, load
70
70
  * IntersectionObserver's first callback, which is exactly when a display must
71
71
  * not flash `ready`. Only the paint it will never do is excused.
72
72
  */
73
- export function computeLoadingTerm({ fetchInert, viewportEmpty, isLoadingOrCanceled, rendersCanvas, canvasDrawn, }, viewportCurrent, hostMounted = () => true) {
73
+ export function computeLoadingTerm({ fetchInert, viewportEmpty, isLoadingOrCanceled, awaitingDependentData, rendersCanvas, canvasDrawn, }, viewportCurrent, hostMounted = () => true) {
74
74
  return (!fetchInert &&
75
75
  !viewportEmpty &&
76
76
  (isLoadingOrCanceled ||
77
+ awaitingDependentData ||
77
78
  (rendersCanvas && hostMounted() && !canvasDrawn) ||
78
79
  !viewportCurrent()));
79
80
  }
@@ -1,5 +1,6 @@
1
1
  import { getType } from '@jbrowse/mobx-state-tree';
2
2
  import { computed } from 'mobx';
3
+ import { contractReportsOn, reportContractViolation, } from './contractReports.js';
3
4
  import { createMapUploadSync } from './mapUploadSync.js';
4
5
  import { isCheckedAtTheStore } from './regionDataMap.js';
5
6
  /**
@@ -29,7 +30,7 @@ function checkPayloads(self, cells) {
29
30
  }
30
31
  if (!named.has(key)) {
31
32
  named.add(key);
32
- console.error(`[jbrowse display contract] ${getType(self).name}: cell ${String(key)} ` +
33
+ reportContractViolation('display', `${getType(self).name}: cell ${String(key)} ` +
33
34
  `handed to installUpload is \`${String(value)}\`. A fetch answers ` +
34
35
  'a payload or nothing; leave the key out until it has one.');
35
36
  }
@@ -51,7 +52,7 @@ export function installUpload(self, backend, { cells, inputs, encode, render })
51
52
  return {
52
53
  upload: b => {
53
54
  const current = own();
54
- if (process.env.NODE_ENV !== 'production') {
55
+ if (contractReportsOn()) {
55
56
  checkPayloads(self, current);
56
57
  }
57
58
  return sync(b, current);
@@ -66,7 +67,7 @@ export function installUpload(self, backend, { cells, inputs, encode, render })
66
67
  return {
67
68
  upload: b => {
68
69
  const current = cells();
69
- if (process.env.NODE_ENV !== 'production') {
70
+ if (contractReportsOn()) {
70
71
  checkPayloads(self, current);
71
72
  }
72
73
  const p = props ? props.get() : undefined;
@@ -1,4 +1,5 @@
1
1
  import { observable } from 'mobx';
2
+ import { reportContractViolation } from './contractReports.js';
2
3
  // Maps built here check themselves at the `set`, so the upload seam skips them
3
4
  // and a bad entry is reported once, with the field named.
4
5
  //
@@ -73,26 +74,27 @@ export function regionDataMap(
73
74
  // reached by forgetting rather than by deciding.
74
75
  name) {
75
76
  const map = observable.map(undefined, { deep: false, name });
76
- if (process.env.NODE_ENV !== 'production') {
77
- checkedAtTheStore.add(map);
78
- const set = map.set.bind(map);
79
- map.set = (key, value) => {
80
- // as `unknown`, because the claim that this is a `T` is the thing in
81
- // doubt — checking it against its own declared type narrows to `never`
82
- // and checks nothing
83
- const stored = value;
84
- if (typeof stored !== 'object' || stored === null) {
85
- // `console.error`, never `throw`: the store runs inside the fetch's own
86
- // result handler, where a throw is caught and reported as a failed
87
- // region — which would hide the violation being reported.
88
- console.error(`[jbrowse display contract] ${name}: region ${key} stored ` +
89
- `\`${stored === null ? 'null' : typeof stored}\`, not a ` +
90
- 'payload. A fetch RPC answers a payload or a regionTooLarge ' +
91
- 'refusal; storing anything else leaves every reader of this map ' +
92
- 'reading through it.');
93
- }
94
- return set(key, value);
95
- };
96
- }
77
+ checkedAtTheStore.add(map);
78
+ const set = map.set.bind(map);
79
+ // one `typeof` per stored region, which is why it is not gated on the channel
80
+ // being armed: a violation found in a production build is buffered until
81
+ // something arms it, where a gate here would have thrown the evidence away
82
+ map.set = (key, value) => {
83
+ // as `unknown`, because the claim that this is a `T` is the thing in
84
+ // doubt — checking it against its own declared type narrows to `never`
85
+ // and checks nothing
86
+ const stored = value;
87
+ if (typeof stored !== 'object' || stored === null) {
88
+ // reported and never thrown: the store runs inside the fetch's own result
89
+ // handler, where a throw is caught and reported as a failed region —
90
+ // which would hide the violation being reported.
91
+ reportContractViolation('display', `${name}: region ${key} stored ` +
92
+ `\`${stored === null ? 'null' : typeof stored}\`, not a ` +
93
+ 'payload. A fetch RPC answers a payload or a regionTooLarge ' +
94
+ 'refusal; storing anything else leaves every reader of this map ' +
95
+ 'reading through it.');
96
+ }
97
+ return set(key, value);
98
+ };
97
99
  return map;
98
100
  }
@@ -1,3 +1,4 @@
1
1
  export declare function snapBoxHeightPx(heightPx: number): number;
2
+ export declare function snapBoxTopPx(topY: number, heightPx: number, scrollY: number): number;
2
3
  export declare function snapBoxCenterYPx(centerY: number, heightPx: number, scrollY: number): number;
3
4
  export declare function extendToMinWidthPx(x1: number, x2: number, minWidth: number): number;
@@ -26,8 +26,11 @@ export function snapBoxHeightPx(heightPx) {
26
26
  }
27
27
  return _t1;
28
28
  }
29
+ export function snapBoxTopPx(topY, heightPx, scrollY) {
30
+ return Math.floor(((((topY + (heightPx * 0.5)) - (snapBoxHeightPx(heightPx) * 0.5)) - scrollY) + 0.5));
31
+ }
29
32
  export function snapBoxCenterYPx(centerY, heightPx, scrollY) {
30
- return ((Math.floor((((centerY - (heightPx * 0.5)) - scrollY) + 0.5)) + Math.floor((snapBoxHeightPx(heightPx) * 0.5))) + 0.5);
33
+ return ((snapBoxTopPx((centerY - (heightPx * 0.5)), heightPx, scrollY) + Math.floor((snapBoxHeightPx(heightPx) * 0.5))) + 0.5);
31
34
  }
32
35
  export function extendToMinWidthPx(x1, x2, minWidth) {
33
36
  let dx = (x2 - x1);
@@ -1,3 +1,2 @@
1
1
  export declare const MIN_DRAWN_ROW_PX = 1;
2
- export declare const MIN_DRAWN_CELL_PX = 1;
3
2
  export declare const MULTI_ROW_MIN_CELL_PX = 2;
@@ -1,5 +1,4 @@
1
1
  // AUTO-GENERATED by packages/shader-tools/src/shader-codegen from rowRect.slang.
2
2
  // Do not edit. Run `pnpm gen:shaders` to regenerate.
3
3
  export const MIN_DRAWN_ROW_PX = 1;
4
- export const MIN_DRAWN_CELL_PX = 1;
5
4
  export const MULTI_ROW_MIN_CELL_PX = 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/render-core",
3
- "version": "5.0.0-beta.1",
3
+ "version": "5.0.0-beta.2",
4
4
  "type": "module",
5
5
  "description": "GPU/Canvas2D rendering primitives for JBrowse displays: the HAL, the draw-lifecycle mixin, per-region/global backend bases, and the React backend hooks",
6
6
  "keywords": [
@@ -31,6 +31,7 @@
31
31
  "./canvas2dUtils": "./esm/canvas2dUtils.js",
32
32
  "./canvasContext": "./esm/canvasContext.js",
33
33
  "./colorRampLut": "./esm/colorRampLut.js",
34
+ "./contractReports": "./esm/contractReports.js",
34
35
  "./coverageBand": "./esm/coverageBand.js",
35
36
  "./coverageBandBuffers": "./esm/coverageBandBuffers.js",
36
37
  "./createRenderingBackend": "./esm/createRenderingBackend.js",
@@ -26,8 +26,12 @@ export function snapBoxHeightPx(heightPx: number): number {
26
26
  return _t1
27
27
  }
28
28
 
29
+ export function snapBoxTopPx(topY: number, heightPx: number, scrollY: number): number {
30
+ return Math.floor(((((topY + (heightPx * 0.5)) - (snapBoxHeightPx(heightPx) * 0.5)) - scrollY) + 0.5))
31
+ }
32
+
29
33
  export function snapBoxCenterYPx(centerY: number, heightPx: number, scrollY: number): number {
30
- return ((Math.floor((((centerY - (heightPx * 0.5)) - scrollY) + 0.5)) + Math.floor((snapBoxHeightPx(heightPx) * 0.5))) + 0.5)
34
+ return ((snapBoxTopPx((centerY - (heightPx * 0.5)), heightPx, scrollY) + Math.floor((snapBoxHeightPx(heightPx) * 0.5))) + 0.5)
31
35
  }
32
36
 
33
37
  export function extendToMinWidthPx(x1: number, x2: number, minWidth: number): number {
@@ -8,7 +8,7 @@
8
8
  // must make identically to the GPU — which pixel row a glyph centers on, how a
9
9
  // sub-pixel span is widened. `pnpm gen:shaders` transliterates them into
10
10
  // hpmath.js.generated.ts; nothing downstream re-derives them by hand.
11
- //! js-export: snapBoxHeightPx, snapBoxCenterYPx, extendToMinWidthPx
11
+ //! js-export: snapBoxHeightPx, snapBoxTopPx, snapBoxCenterYPx, extendToMinWidthPx
12
12
  //! js-skip: hpSplitUint — the hi/lo float32 precision split exists because a GPU has no float64; the Canvas2D path just uses a number
13
13
  //! js-skip: quadLocal — maps a vertex id to a quad corner — Canvas2D has no vertices, it calls fillRect
14
14
  //! js-skip: yPxToClipY — clip space is the GPU coordinate system; the px-space decision it wraps is what gets exported (snapBoxCenterYPx and friends)
@@ -91,14 +91,27 @@ public float snapBoxHeightPx(float heightPx) {
91
91
  return even && hPx >= 2.0 && hPx <= THIN_BOX_PX ? hPx + 1.0 : hPx;
92
92
  }
93
93
 
94
+ // The pixel row a box's top edge lands on. Snapped about the box's CENTER, not
95
+ // its top: `snapBoxHeightPx` rounds the height and sometimes nudges it a whole
96
+ // pixel, and anchoring the top spends all of that off the BOTTOM edge. A box the
97
+ // layout deliberately centered inside its row then isn't — a UTR is
98
+ // center-shrunk to 0.65 of the transcript body, so at superCompact's 3px row its
99
+ // 1.95px box rounded up to 3px and drew a full pixel below the CDS beside it,
100
+ // while the intron line and strand arrow (which snap to the row's center row)
101
+ // sat on its top edge. Top-anchored and center-anchored agree wherever the
102
+ // height survives the snap unchanged, which is every full-height feature box.
103
+ public float snapBoxTopPx(float topY, float heightPx, float scrollY) {
104
+ float centerY = topY + heightPx * 0.5;
105
+ return floor(centerY - snapBoxHeightPx(heightPx) * 0.5 - scrollY + 0.5);
106
+ }
107
+
94
108
  // Center a thin glyph on the *drawn* middle row of the box it annotates, given
95
- // the box's real center `centerY` and height `heightPx`. The box is rasterized
96
- // with the rect shader's edge snapping (top = floor(top+0.5), height =
97
- // snapBoxHeightPx(h)); reproducing that here lands the line/chevron exactly on
98
- // the box's center row instead of the ~1px bias a plain pixel-center snap
99
- // (`floor(centerY - scrollY + 0.5) + 0.5`) gives. Thin boxes are drawn at an odd
100
- // height precisely so that center row exists; a taller even box still has none,
101
- // and stays consistent (just below the seam).
109
+ // the box's real center `centerY` and height `heightPx`. Reproducing the rect
110
+ // shader's own edge snapping (`snapBoxTopPx` + `snapBoxHeightPx`) lands the
111
+ // line/chevron exactly on the box's center row instead of the ~1px bias a plain
112
+ // pixel-center snap (`floor(centerY - scrollY + 0.5) + 0.5`) gives. Thin boxes
113
+ // are drawn at an odd height precisely so that center row exists; a taller even
114
+ // box still has none, and stays consistent (just below the seam).
102
115
  //
103
116
  // Split in two on purpose. The decision — which pixel row is the center — is
104
117
  // resolution math the Canvas2D and SVG paths need in *screen px*; only the
@@ -107,7 +120,7 @@ public float snapBoxHeightPx(float heightPx) {
107
120
  // re-deriving it (see adr-051). Keep new shared decisions in this shape: pure
108
121
  // px-in/px-out below, the `yPxToClipY` wrapper above it.
109
122
  public float snapBoxCenterYPx(float centerY, float heightPx, float scrollY) {
110
- float topPx = floor(centerY - heightPx * 0.5 - scrollY + 0.5);
123
+ float topPx = snapBoxTopPx(centerY - heightPx * 0.5, heightPx, scrollY);
111
124
  return topPx + floor(snapBoxHeightPx(heightPx) * 0.5) + 0.5;
112
125
  }
113
126
 
@@ -3,6 +3,4 @@
3
3
 
4
4
  export const MIN_DRAWN_ROW_PX = 1
5
5
 
6
- export const MIN_DRAWN_CELL_PX = 1
7
-
8
6
  export const MULTI_ROW_MIN_CELL_PX = 2
@@ -11,13 +11,14 @@
11
11
  // Row placement comes from uniforms rather than the instance data, so a
12
12
  // rowHeight / rowProportion change re-renders without a refetch.
13
13
  //
14
- // What the callers legitimately differ on is the min-width floor: both the
15
- // width they write into `minCellPx` and the *unit* they measure it in, via
16
- // `minCellDenomPx` (CSS px vs device px) — see GpuMafRenderer's note. Those are
17
- // uniform-write decisions, not a reason for two shaders. The unit is also
18
- // unsettled, and device px additionally makes MAF's floor depend on the
19
- // reader's monitor: `agent-docs/ideas/maf-subpixel-cells.md`.
20
- //! export-consts: MIN_DRAWN_ROW_PX, MIN_DRAWN_CELL_PX, MULTI_ROW_MIN_CELL_PX
14
+ // What the callers legitimately differ on is the min-width floor they write
15
+ // into `minCellPx` — a uniform-write decision, not a reason for two shaders.
16
+ // Whether to floor at all turns on whether the caller's marks TILE: MAF's cells
17
+ // do, so a sub-pixel one is read as part of the run around it and widening it
18
+ // paints ink the alignment does not contain (measured, and MAF now passes 0 —
19
+ // `agent-docs/reference/MAF_SUBPIXEL_CELLS.md`). The multi-row painter's features
20
+ // are sparse intervals with nothing beside them, so it floors.
21
+ //! export-consts: MIN_DRAWN_ROW_PX, MULTI_ROW_MIN_CELL_PX
21
22
  //! js-export: drawnRowHeightPx, rowBandOffsetPx
22
23
  module rowRect;
23
24
 
@@ -36,16 +37,13 @@ public struct RowRectUniforms {
36
37
  public float canvasHeight; // CSS px
37
38
  // The width `minCellPx` is measured against, and so the thing that decides
38
39
  // what unit `minCellPx` IS. Named for that rather than for the viewport
39
- // because the three callers do not agree: multi-row and wiggle density pass
40
- // a CSS width (a 1 CSS px floor), MAF passes a device one (1 device px, so
41
- // half a CSS px on a retina screen and a floor that moves with the monitor).
42
- // That disagreement is live and parked, not settled —
43
- // agent-docs/ideas/maf-subpixel-cells.md — so the name carries it to every
44
- // call site instead of a comment forty lines away.
40
+ // because the callers that floor pass a CSS width. Pass a DEVICE width and
41
+ // the floor moves with the reader's monitor — MAF did, and rendered
42
+ // measurably differently on a retina screen until it stopped flooring.
45
43
  public float minCellDenomPx;
46
- // Narrowest a cell is painted, in whatever unit `minCellDenomPx` is in.
47
- // MIN_DRAWN_CELL_PX is the floor a caller takes when it has no reason to
48
- // widen further; the multi-row painter does (see MULTI_ROW_MIN_CELL_PX).
44
+ // Narrowest a cell is painted, in whatever unit `minCellDenomPx` is in. Zero
45
+ // is a no-op and is what a tiling caller writes (MAF); the multi-row painter
46
+ // names its own width, MULTI_ROW_MIN_CELL_PX.
49
47
  public float minCellPx;
50
48
  public float zero; // always 0.0 — prevents hp-split collapse
51
49
  public float rowHeight; // CSS px per row
@@ -62,24 +60,16 @@ public struct RowRectVsOut {
62
60
  public float4 color : COLOR0;
63
61
  };
64
62
 
65
- // Thinnest a row is ever painted. Vertical twin of MIN_DRAWN_CELL_PX below: a
66
- // row thinner than a pixel can miss every pixel center and disappear, so
67
- // without this rows drop out as they get denser instead of just getting
68
- // thinner. Floored, neighbours overlap instead.
69
- static const float MIN_DRAWN_ROW_PX = 1.0;
70
-
71
- // Thinnest a cell can be painted and still be drawable at all, for the same
72
- // reason as the row floor above on the other axis. A caller writes this into
73
- // `minCellPx` when a cell being *visible* is not its problem, only a cell
74
- // existing; one whose whole encoding is carried by sub-pixel cells asks for
75
- // more (MULTI_ROW_MIN_CELL_PX).
63
+ // Thinnest a row is ever painted: a row thinner than a pixel can miss every
64
+ // pixel center and disappear, so without this rows drop out as they get denser
65
+ // instead of just getting thinner. Floored, neighbours overlap instead.
76
66
  //
77
- // One caller mirrors it on the Canvas2D side and one deliberately does not: the
78
- // multi-row painter floors, MAF draws cells at their natural sub-pixel width.
79
- // That asymmetry is real and parked in `agent-docs/ideas/maf-subpixel-cells.md`;
80
- // sharing the constant does not decide it, it just stops the value being
81
- // anonymous in the places that DO agree.
82
- static const float MIN_DRAWN_CELL_PX = 1.0;
67
+ // There is no X-axis twin. A row is a container the reader is counting, so
68
+ // losing one loses a species; a cell is a sample of a tiling, so losing one to
69
+ // the blend is the honest answer and flooring it invents ink
70
+ // (`agent-docs/reference/MAF_SUBPIXEL_CELLS.md`). The caller that does want an X
71
+ // floor names its own width, below.
72
+ static const float MIN_DRAWN_ROW_PX = 1.0;
83
73
 
84
74
  // What the multi-row painter ("chromosome painting") writes into `minCellPx`.
85
75
  //
@@ -91,9 +81,9 @@ static const float MIN_DRAWN_CELL_PX = 1.0;
91
81
  // across two, so neither reaches full opacity — the tick reads as a smudge, and
92
82
  // the eye scanning for structure across the rows finds none.
93
83
  //
94
- // Both floors distort width; that is what a floor is. This one distorts more,
95
- // and only where the alternative was showing nothing legible: a feature wide
96
- // enough to matter is already past it.
84
+ // A floor distorts width; that is what a floor is. This is the only caller that
85
+ // takes the trade, and only where the alternative was showing nothing legible:
86
+ // a feature wide enough to matter is already past it.
97
87
  static const float MULTI_ROW_MIN_CELL_PX = 2.0;
98
88
 
99
89
  public struct RowBand {