@fundamental-engine/core 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/themes.d.ts +22 -0
- package/dist/config/themes.d.ts.map +1 -0
- package/dist/config/themes.js +9 -0
- package/dist/config/themes.js.map +1 -0
- package/dist/contracts/guards.d.ts +14 -0
- package/dist/contracts/guards.d.ts.map +1 -1
- package/dist/contracts/guards.js +21 -0
- package/dist/contracts/guards.js.map +1 -1
- package/dist/core/agents.d.ts +1 -1
- package/dist/core/agents.d.ts.map +1 -1
- package/dist/core/agents.js +5 -2
- package/dist/core/agents.js.map +1 -1
- package/dist/core/events.d.ts +15 -0
- package/dist/core/events.d.ts.map +1 -1
- package/dist/core/events.js.map +1 -1
- package/dist/core/field.d.ts.map +1 -1
- package/dist/core/field.js +258 -61
- package/dist/core/field.js.map +1 -1
- package/dist/core/host.d.ts +7 -0
- package/dist/core/host.d.ts.map +1 -1
- package/dist/core/math.d.ts +4 -3
- package/dist/core/math.d.ts.map +1 -1
- package/dist/core/math.js +10 -9
- package/dist/core/math.js.map +1 -1
- package/dist/core/scanner.d.ts +1 -1
- package/dist/core/scanner.d.ts.map +1 -1
- package/dist/core/scanner.js +8 -4
- package/dist/core/scanner.js.map +1 -1
- package/dist/core/types.d.ts +45 -12
- package/dist/core/types.d.ts.map +1 -1
- package/dist/forces/index.d.ts.map +1 -1
- package/dist/forces/index.js +3 -1
- package/dist/forces/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +1 -1
package/dist/core/field.js
CHANGED
|
@@ -24,6 +24,7 @@ import { buildWaves, buildBound, waveYat, } from "./currents.js";
|
|
|
24
24
|
import { healWaves, tearBoundNear, tearBoundByForces, induceCharges } from "./reservoir.js";
|
|
25
25
|
import { FORMATION_BY, PALETTE } from "../config/forces.config.js";
|
|
26
26
|
import { resolvePalette } from "../config/palettes.js";
|
|
27
|
+
import { THEMES, DEFAULT_THEME } from "../config/themes.js";
|
|
27
28
|
import { clamp, hexToRgb, particleRGBInto, rgbToHex, sampleStops } from "./math.js";
|
|
28
29
|
import { feedbackTarget, feedbackWeight } from "./feedback.js";
|
|
29
30
|
import { defaultFeedbackSink } from "./feedback-sink.js";
|
|
@@ -45,9 +46,9 @@ import { forceAt, netField } from "./streamlines.js";
|
|
|
45
46
|
import { traceFieldLines } from "./fieldlines.js";
|
|
46
47
|
import { fieldLineSeeds } from "./fieldline-seeds.js";
|
|
47
48
|
import { flowBiasInto, makeFlowFocus } from "./flow.js";
|
|
49
|
+
import { devWarnNoOp } from "../contracts/guards.js";
|
|
50
|
+
import { FIELD_VERSION } from "../version.js";
|
|
48
51
|
import { energyReport } from "../diagnostics/energy.js";
|
|
49
|
-
// the Currents' cool baseline palette — a subset of the force palette (§24.4).
|
|
50
|
-
const WAVE_RGB = ['#4da3ff', '#2dd4bf', '#a78bfa'].map(hexToRgb);
|
|
51
52
|
// Shared draw/integrate scratch — reused across the per-particle and per-cell hot loops so an
|
|
52
53
|
// active flow focus and the particle draw don't allocate a `{x,y}` / `[r,g,b]` each iteration.
|
|
53
54
|
// Safe to share module-wide: each field's frame runs synchronously, and every read consumes the
|
|
@@ -62,7 +63,7 @@ export function createField(canvas, opts = {}) {
|
|
|
62
63
|
// acquires it lazily (and sizes the store then) — so a field created with 'none' allocates no
|
|
63
64
|
// render surface at all unless asked to draw.
|
|
64
65
|
let ctx = null;
|
|
65
|
-
if ((opts.render ?? '
|
|
66
|
+
if ((opts.render ?? 'none') !== 'none') {
|
|
66
67
|
ctx = canvas.getContext('2d');
|
|
67
68
|
if (!ctx)
|
|
68
69
|
throw new Error('Fundamental: 2D canvas context unavailable');
|
|
@@ -93,6 +94,62 @@ export function createField(canvas, opts = {}) {
|
|
|
93
94
|
cb(payload);
|
|
94
95
|
}
|
|
95
96
|
const sinkPeak = new WeakMap(); // matter held at the rising edge, for the release count
|
|
97
|
+
// #441 body-proximity events. Object-identity membership (survives rescan; removed bodies pruned).
|
|
98
|
+
const insideOf = new Map(); // enter/exit: bodies currently within a body's range
|
|
99
|
+
const metWith = new Map(); // met: bodies currently in box-contact (symmetric, deduped a<b)
|
|
100
|
+
function detectProximityEvents(bs) {
|
|
101
|
+
const wantEE = busHas('enter') || busHas('exit');
|
|
102
|
+
const wantMet = busHas('met');
|
|
103
|
+
if (!wantEE && !wantMet)
|
|
104
|
+
return; // lazy: zero listeners → zero cost
|
|
105
|
+
for (let i = 0; i < bs.length; i++) {
|
|
106
|
+
const a = bs[i];
|
|
107
|
+
if (wantEE) {
|
|
108
|
+
let inside = insideOf.get(a);
|
|
109
|
+
if (!inside)
|
|
110
|
+
insideOf.set(a, (inside = new Set()));
|
|
111
|
+
const r2 = a.range * a.range;
|
|
112
|
+
for (let j = 0; j < bs.length; j++) {
|
|
113
|
+
if (i === j)
|
|
114
|
+
continue;
|
|
115
|
+
const o = bs[j];
|
|
116
|
+
const dx = o.cx - a.cx, dy = o.cy - a.cy;
|
|
117
|
+
const isIn = dx * dx + dy * dy < r2;
|
|
118
|
+
if (isIn && !inside.has(o)) {
|
|
119
|
+
inside.add(o);
|
|
120
|
+
if (busHas('enter'))
|
|
121
|
+
busEmit('enter', { body: a, other: o });
|
|
122
|
+
}
|
|
123
|
+
else if (!isIn && inside.has(o)) {
|
|
124
|
+
inside.delete(o);
|
|
125
|
+
if (busHas('exit'))
|
|
126
|
+
busEmit('exit', { body: a, other: o });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (wantMet) {
|
|
131
|
+
let met = metWith.get(a);
|
|
132
|
+
if (!met)
|
|
133
|
+
metWith.set(a, (met = new Set()));
|
|
134
|
+
for (let j = i + 1; j < bs.length; j++) {
|
|
135
|
+
const b = bs[j];
|
|
136
|
+
const touch = Math.abs(b.cx - a.cx) < a.hw + b.hw && Math.abs(b.cy - a.cy) < a.hh + b.hh;
|
|
137
|
+
if (touch && !met.has(b)) {
|
|
138
|
+
met.add(b);
|
|
139
|
+
busEmit('met', { a, b });
|
|
140
|
+
}
|
|
141
|
+
else if (!touch && met.has(b))
|
|
142
|
+
met.delete(b);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// prune tracking for bodies no longer present (rescan removed them) so they neither leak nor re-fire
|
|
147
|
+
const present = new Set(bs);
|
|
148
|
+
for (const m of [insideOf, metWith])
|
|
149
|
+
for (const k of m.keys())
|
|
150
|
+
if (!present.has(k))
|
|
151
|
+
m.delete(k);
|
|
152
|
+
}
|
|
96
153
|
const programmaticBodies = []; // bodies added via addBody(); merged into `bodies` each scan
|
|
97
154
|
const fieldChannels = new Map(); // addField() input channels
|
|
98
155
|
// All 36 forces are registered on every field — there is no opt-in. Any of them activates per-body
|
|
@@ -110,10 +167,13 @@ export function createField(canvas, opts = {}) {
|
|
|
110
167
|
const host = opts.host;
|
|
111
168
|
const teardowns = []; // host event unsubscribers, called on destroy
|
|
112
169
|
const reduceMotion = host.reducedMotion();
|
|
170
|
+
// ambient theme (#529): a named preset for the heat ramp + wave baseline; `warm` (default) reproduces
|
|
171
|
+
// the shipped palette. Individual lanes (gradientCool/gradientWarm/waveBaseline) override the preset.
|
|
172
|
+
const theme = THEMES[opts.theme ?? DEFAULT_THEME] ?? THEMES[DEFAULT_THEME];
|
|
113
173
|
const cfg = {
|
|
114
174
|
accent: opts.accent ?? resolvePalette(opts.palette)[0] ?? PALETTE[0] ?? '#4da3ff',
|
|
115
175
|
density: opts.density && opts.density > 0 ? opts.density : 1,
|
|
116
|
-
render: opts.render ?? 'dots'
|
|
176
|
+
render: opts.render ?? 'none', // signals-first default (#538): a bare field runs the sim + feedback but draws nothing until asked. Pass render:'dots' for the particle surface.
|
|
117
177
|
waves: opts.waves ?? true, // draw the background Currents (§24); opt-out for the bare field
|
|
118
178
|
background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
|
|
119
179
|
mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
|
|
@@ -121,6 +181,15 @@ export function createField(canvas, opts = {}) {
|
|
|
121
181
|
causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
|
|
122
182
|
heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
|
|
123
183
|
overlay: opts.overlay ?? 'off', // Field Surfaces: overlay-surface visualization mode, opt-in
|
|
184
|
+
// distortion multiplier for the `grid` overlay's lattice deflection (1 = calibrated default; 0 flat).
|
|
185
|
+
gridWarp: opts.gridWarp != null && opts.gridWarp >= 0 ? opts.gridWarp : 1,
|
|
186
|
+
// stroke opacity for the `grid` overlay lines. 0.16 = the calibrated faint diagnostic (default,
|
|
187
|
+
// never overpowers content); raise it (≈0.5) to make the warped lattice a deliberate centerpiece.
|
|
188
|
+
gridIntensity: opts.gridIntensity != null && opts.gridIntensity >= 0 ? Math.min(opts.gridIntensity, 1) : 0.16,
|
|
189
|
+
// theme palette (#529): the heat-ramp ends + wave baseline, resolved from the preset + overrides.
|
|
190
|
+
gradientCool: opts.gradientCool ? hexToRgb(opts.gradientCool) : theme.cool,
|
|
191
|
+
gradientWarm: opts.gradientWarm ? hexToRgb(opts.gradientWarm) : theme.warm,
|
|
192
|
+
waveBaseline: (opts.waveBaseline ?? theme.wave).map(hexToRgb),
|
|
124
193
|
dprCap: opts.dprCap && opts.dprCap > 0 ? opts.dprCap : 2, // backing-store DPR ceiling (#410); the
|
|
125
194
|
// dominant fill-rate lever — the ambient field is soft, so ~1.5 buys ~1.8× headroom on retina.
|
|
126
195
|
// optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
|
|
@@ -135,6 +204,14 @@ export function createField(canvas, opts = {}) {
|
|
|
135
204
|
let bodies = [];
|
|
136
205
|
let W = 0;
|
|
137
206
|
let H = 0;
|
|
207
|
+
// field-space origin from the host viewport (#540) — 0,0 for a window host, the container's
|
|
208
|
+
// left/top for a contained host. measureBodies + the move/thread readouts subtract it.
|
|
209
|
+
let originX = 0;
|
|
210
|
+
let originY = 0;
|
|
211
|
+
// a contained field (host viewport returns an origin): its bodies live in container-local space,
|
|
212
|
+
// which is INVARIANT under page scroll (the container + its contents move together). So it refreshes
|
|
213
|
+
// the origin each frame and skips the window-only per-frame scroll shift below.
|
|
214
|
+
let contained = false;
|
|
138
215
|
// cached page scroll extent — reading scrollHeight forces a synchronous reflow, so
|
|
139
216
|
// we cache it (refreshed on resize + sampled occasionally) rather than per frame.
|
|
140
217
|
let maxScroll = 1;
|
|
@@ -175,6 +252,15 @@ export function createField(canvas, opts = {}) {
|
|
|
175
252
|
// on a cadence and DRAW from this cache every frame (so the arrows never flicker or step).
|
|
176
253
|
let slSamples = null;
|
|
177
254
|
let slQuiescent = [];
|
|
255
|
+
// Same cadence cache for the OVERLAY arrows (drawOverlayArrows) — the in-front Field-Surfaces
|
|
256
|
+
// reading. Its grid is the same body-induced force field, so it had the same per-frame regrid
|
|
257
|
+
// waste the underlay shed in #406; resample on the cadence, draw from this cache every frame.
|
|
258
|
+
let olSamples = null;
|
|
259
|
+
// Tag-tint cache (#515): the parsed RGB + reach² of each coloured body, rebuilt only on the
|
|
260
|
+
// measure cadence (colour/range change there, not per frame). The body ref is kept so the
|
|
261
|
+
// per-particle loop reads cx/cy FRESH each frame — those are scroll-compensated every frame
|
|
262
|
+
// (#508), so caching positions would re-introduce the swarm-pause-on-scroll lag for the tint.
|
|
263
|
+
let tintCache = null;
|
|
178
264
|
// hard pool ceiling for class-[S] sources (§20.1) — generous above the ~130·density
|
|
179
265
|
// base field so emission is never starved, but bounded so the sim can't grow forever.
|
|
180
266
|
const spawnCeiling = Math.round(130 * cfg.density) * 4;
|
|
@@ -362,7 +448,7 @@ export function createField(canvas, opts = {}) {
|
|
|
362
448
|
store.add(newParticle());
|
|
363
449
|
applySeed();
|
|
364
450
|
// the Currents (§24) are opt-out: with waves off, the field is just the free particles.
|
|
365
|
-
waves = cfg.waves ? buildWaves(
|
|
451
|
+
waves = cfg.waves ? buildWaves(cfg.waveBaseline) : [];
|
|
366
452
|
bound = cfg.waves ? buildBound(waves.length, cfg.density, rng) : [];
|
|
367
453
|
boundTarget = bound.length;
|
|
368
454
|
}
|
|
@@ -381,7 +467,7 @@ export function createField(canvas, opts = {}) {
|
|
|
381
467
|
// programmatic bodies (addBody) aren't discoverable by the scan — carry them across the rebuild.
|
|
382
468
|
if (programmaticBodies.length > 0)
|
|
383
469
|
bodies = bodies.concat(programmaticBodies);
|
|
384
|
-
measureBodies(bodies, W, H);
|
|
470
|
+
measureBodies(bodies, W, H, originX, originY);
|
|
385
471
|
bindEngagement();
|
|
386
472
|
// Reconcile movers: carry forward offset + dock state for elements that persist across
|
|
387
473
|
// rescans (shadow-DOM re-register, Astro nav re-mounts, explicit rescan()). An element that
|
|
@@ -588,7 +674,7 @@ export function createField(canvas, opts = {}) {
|
|
|
588
674
|
// self-laying-out repulsion (Concept 3) sees where everything actually sits this frame.
|
|
589
675
|
const centers = movers.map((mv) => {
|
|
590
676
|
const r = mv.el.getBoundingClientRect();
|
|
591
|
-
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
677
|
+
return { x: r.left - originX + r.width / 2, y: r.top - originY + r.height / 2 }; // container-local (#540)
|
|
592
678
|
});
|
|
593
679
|
for (let i = 0; i < movers.length; i++) {
|
|
594
680
|
const mv = movers[i];
|
|
@@ -644,8 +730,9 @@ export function createField(canvas, opts = {}) {
|
|
|
644
730
|
// Concept 3 — self-laying-out: this element pushes off the others and drifts off
|
|
645
731
|
// dense field regions, so a cluster spreads and re-settles (e.g. on resize).
|
|
646
732
|
if (mv.layout) {
|
|
647
|
-
|
|
648
|
-
|
|
733
|
+
// #530: pass the shared centers + this index instead of allocating `centers.filter(j !== i)`
|
|
734
|
+
// every frame per mover (was O(movers²) arrays/frame on a self-laying-out cluster).
|
|
735
|
+
const rep = repelForce({ x: cx, y: cy }, centers, i);
|
|
649
736
|
const press = densityPush((sx, sy) => store.near(sx, sy, 40).length, cx, cy, 16, 6);
|
|
650
737
|
fx += rep.x + press.x;
|
|
651
738
|
fy += rep.y + press.y;
|
|
@@ -746,10 +833,10 @@ export function createField(canvas, opts = {}) {
|
|
|
746
833
|
for (const th of threadLinks) {
|
|
747
834
|
const ra = th.a.getBoundingClientRect();
|
|
748
835
|
const rb = th.b.getBoundingClientRect();
|
|
749
|
-
const ax = ra.left + ra.width / 2;
|
|
750
|
-
const ay = ra.top + ra.height / 2;
|
|
751
|
-
const bx = rb.left + rb.width / 2;
|
|
752
|
-
const by = rb.top + rb.height / 2;
|
|
836
|
+
const ax = ra.left - originX + ra.width / 2; // container-local (#540)
|
|
837
|
+
const ay = ra.top - originY + ra.height / 2;
|
|
838
|
+
const bx = rb.left - originX + rb.width / 2;
|
|
839
|
+
const by = rb.top - originY + rb.height / 2;
|
|
753
840
|
const [cr, cg, cb] = th.c;
|
|
754
841
|
ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.22)`;
|
|
755
842
|
ctx.lineWidth = 1;
|
|
@@ -772,11 +859,17 @@ export function createField(canvas, opts = {}) {
|
|
|
772
859
|
// Size the drawing surfaces' backing stores to the current W×H (dpr-scaled). Split out of
|
|
773
860
|
// resize() so the lazy `setRender('none' → drawing)` path can run exactly this once. With no
|
|
774
861
|
// context (a field created with `render: 'none'`, §13.7 / #297) it is a no-op: the canvas
|
|
862
|
+
// adaptive quality tier (#413): the QualityGovernor's 0–3 signal, applied to the engine's own
|
|
863
|
+
// levers. A tier caps the backing-store DPR (the dominant fill lever) and, at 2+, skips the heaviest
|
|
864
|
+
// ambient layer (the heatmap glow). Reversible — tier 0 restores the configured quality. Driven by
|
|
865
|
+
// `setQualityTier`; the platform runtime forwards the governor's tier.
|
|
866
|
+
let qualityTier = 0;
|
|
867
|
+
const TIER_DPR = [Infinity, 1.5, 1.25, 1]; // effective DPR ceiling per tier, capping cfg.dprCap further
|
|
775
868
|
// backing store stays 0×0 while W/H — the simulation space — keep tracking the viewport.
|
|
776
869
|
function sizeSurfaces(dprRaw) {
|
|
777
870
|
if (!ctx)
|
|
778
871
|
return;
|
|
779
|
-
const dpr = Math.min(dprRaw || 1, cfg.dprCap);
|
|
872
|
+
const dpr = Math.min(dprRaw || 1, cfg.dprCap, TIER_DPR[qualityTier] ?? Infinity);
|
|
780
873
|
canvas.width = Math.floor(W * dpr);
|
|
781
874
|
canvas.height = Math.floor(H * dpr);
|
|
782
875
|
canvas.style.width = W + 'px';
|
|
@@ -789,6 +882,9 @@ export function createField(canvas, opts = {}) {
|
|
|
789
882
|
const vp = host.viewport();
|
|
790
883
|
W = vp.width;
|
|
791
884
|
H = vp.height;
|
|
885
|
+
originX = vp.originX ?? 0;
|
|
886
|
+
originY = vp.originY ?? 0;
|
|
887
|
+
contained = vp.originX != null || vp.originY != null;
|
|
792
888
|
sizeSurfaces(vp.dpr);
|
|
793
889
|
env.W = W;
|
|
794
890
|
env.H = H;
|
|
@@ -1003,6 +1099,14 @@ export function createField(canvas, opts = {}) {
|
|
|
1003
1099
|
function drawHeatmap() {
|
|
1004
1100
|
if (!heatmap)
|
|
1005
1101
|
return;
|
|
1102
|
+
// Fade the ambient glow out as the page scrolls past the hero (≈ the first viewport). Unlike the
|
|
1103
|
+
// earlier velocity-based suppression (which popped in/out and read as choppy), this is a smooth,
|
|
1104
|
+
// MONOTONIC function of scroll POSITION — full through the top of the page, gone by ~1.15 viewports
|
|
1105
|
+
// — so it never flickers; and below the hero the whole layer is skipped (the #409 at-rest upscale
|
|
1106
|
+
// cost the heatmap is otherwise paying every frame for a glow you can't focus on mid-page).
|
|
1107
|
+
const hmFade = H > 0 ? clamp((1.15 - lastScrollY / H) / 0.85, 0, 1) : 1;
|
|
1108
|
+
if (hmFade <= 0.01)
|
|
1109
|
+
return;
|
|
1006
1110
|
const cell = heatmap.cell;
|
|
1007
1111
|
const cols = Math.max(1, Math.ceil(W / cell));
|
|
1008
1112
|
const rows = Math.max(1, Math.ceil(H / cell));
|
|
@@ -1023,7 +1127,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1023
1127
|
if (hmImg === null || frameN % 3 === 0) {
|
|
1024
1128
|
if (hmImg === null)
|
|
1025
1129
|
hmImg = hmCtx.createImageData(cols, rows);
|
|
1026
|
-
const acc = hexToRgb(cfg.accent)
|
|
1130
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1027
1131
|
const data = hmImg.data;
|
|
1028
1132
|
for (let r = 0; r < rows; r++) {
|
|
1029
1133
|
for (let c = 0; c < cols; c++) {
|
|
@@ -1039,7 +1143,9 @@ export function createField(canvas, opts = {}) {
|
|
|
1039
1143
|
}
|
|
1040
1144
|
ctx.globalCompositeOperation = 'lighter';
|
|
1041
1145
|
ctx.imageSmoothingEnabled = true;
|
|
1146
|
+
ctx.globalAlpha = hmFade; // fade with scroll position (computed above)
|
|
1042
1147
|
ctx.drawImage(hmCanvas, 0, 0, W, H); // bilinear upscale → smooth glow
|
|
1148
|
+
ctx.globalAlpha = 1;
|
|
1043
1149
|
ctx.globalCompositeOperation = 'source-over';
|
|
1044
1150
|
}
|
|
1045
1151
|
function render() {
|
|
@@ -1072,8 +1178,8 @@ export function createField(canvas, opts = {}) {
|
|
|
1072
1178
|
// whenever enabled. (An earlier scroll-suppression made it pop/fade out while scrolling, which
|
|
1073
1179
|
// read as choppy; the perf intent is served instead by the compute throttle — the texel grid is
|
|
1074
1180
|
// recomputed only every 3rd frame — so the per-frame cost is just the cached bilinear upscale.)
|
|
1075
|
-
if (heatmap)
|
|
1076
|
-
drawHeatmap();
|
|
1181
|
+
if (heatmap && qualityTier < 2)
|
|
1182
|
+
drawHeatmap(); // #413: drop the heaviest ambient layer at tier 2+
|
|
1077
1183
|
drawBound();
|
|
1078
1184
|
// free particles — cool centre → warm edge, blended toward accent (§20.8).
|
|
1079
1185
|
// metaballs (a molten iso-surface skin) and streamlines (the bare force field) REPLACE
|
|
@@ -1081,10 +1187,27 @@ export function createField(canvas, opts = {}) {
|
|
|
1081
1187
|
// keep it (their overlays read against the particles).
|
|
1082
1188
|
const showMatter = cfg.render !== 'metaballs' && cfg.render !== 'streamlines';
|
|
1083
1189
|
ctx.globalCompositeOperation = 'lighter';
|
|
1084
|
-
const acc = hexToRgb(cfg.accent)
|
|
1190
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1085
1191
|
const cx = W / 2;
|
|
1086
1192
|
const cy = H * 0.4;
|
|
1087
1193
|
const maxD = Math.hypot(Math.max(cx, W - cx), Math.max(cy, H - cy)) || 1;
|
|
1194
|
+
// Tag-tint: every body carrying a colour stains the swarm toward its tint by proximity — a
|
|
1195
|
+
// pervasive, render-time companion to the overlap-only `pigment` force, so a particle near a
|
|
1196
|
+
// tagged body wears its tag's hue even on a sparse field. Few coloured bodies, precomputed once
|
|
1197
|
+
// here; nearest-strongest wins per particle so the hues don't muddy. Reach is a touch past the
|
|
1198
|
+
// force range so the colour reads before matter actually arrives.
|
|
1199
|
+
// Rebuild the RGB cache only on the measure cadence (every 6th frame) or first frame — hexToRgb is
|
|
1200
|
+
// the churn (#515). Positions are NOT cached: the loop below reads each body's live cx/cy.
|
|
1201
|
+
if (tintCache === null || frameN % 6 === 0) {
|
|
1202
|
+
tintCache = [];
|
|
1203
|
+
for (const tb of bodies) {
|
|
1204
|
+
if (!tb.tint)
|
|
1205
|
+
continue;
|
|
1206
|
+
const reach = (tb.range || 200) * 1.4;
|
|
1207
|
+
tintCache.push({ b: tb, r2: reach * reach, rgb: hexToRgb(tb.tint) });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const tintBodies = tintCache;
|
|
1088
1211
|
if (showMatter)
|
|
1089
1212
|
for (const p of store.particles) {
|
|
1090
1213
|
// captured matter is held in orbit by the sink — drawn dim and small (the accretion's orbital
|
|
@@ -1100,10 +1223,32 @@ export function createField(canvas, opts = {}) {
|
|
|
1100
1223
|
const d = Math.min(1, Math.hypot(p.x - cx, p.y - cy) / maxD);
|
|
1101
1224
|
const rs = d * d;
|
|
1102
1225
|
const h = p.heat;
|
|
1103
|
-
particleRGBInto(_rgb, rs, h, acc);
|
|
1226
|
+
particleRGBInto(_rgb, rs, h, acc, cfg.gradientCool, cfg.gradientWarm);
|
|
1104
1227
|
let r = _rgb[0];
|
|
1105
1228
|
let g = _rgb[1];
|
|
1106
1229
|
let b = _rgb[2];
|
|
1230
|
+
if (tintBodies.length) {
|
|
1231
|
+
let bw = 0;
|
|
1232
|
+
let brgb = null;
|
|
1233
|
+
for (const tb of tintBodies) {
|
|
1234
|
+
const dx = p.x - tb.b.cx; // live position — scroll-compensated every frame (#508)
|
|
1235
|
+
const dy = p.y - tb.b.cy;
|
|
1236
|
+
const dd2 = dx * dx + dy * dy;
|
|
1237
|
+
if (dd2 >= tb.r2)
|
|
1238
|
+
continue;
|
|
1239
|
+
const w = 1 - Math.sqrt(dd2 / tb.r2); // linear falloff, 1 at centre → 0 at reach
|
|
1240
|
+
if (w > bw) {
|
|
1241
|
+
bw = w;
|
|
1242
|
+
brgb = tb.rgb;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
if (brgb) {
|
|
1246
|
+
const k = bw * 0.7; // tint strength at the body centre
|
|
1247
|
+
r += (brgb[0] - r) * k;
|
|
1248
|
+
g += (brgb[1] - g) * k;
|
|
1249
|
+
b += (brgb[2] - b) * k;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1107
1252
|
if (p.color) {
|
|
1108
1253
|
// carried pigment (§20.8): a stained particle reads mostly as its own tint.
|
|
1109
1254
|
const [pr, pg, pb] = hexToRgb(p.color);
|
|
@@ -1139,7 +1284,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1139
1284
|
ctx.globalCompositeOperation = 'source-over';
|
|
1140
1285
|
if (cfg.render === 'links') {
|
|
1141
1286
|
ctx.globalCompositeOperation = 'lighter';
|
|
1142
|
-
const acc = hexToRgb(cfg.accent)
|
|
1287
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1143
1288
|
const R = 90;
|
|
1144
1289
|
ctx.lineWidth = 0.6;
|
|
1145
1290
|
for (const p of store.particles) {
|
|
@@ -1179,7 +1324,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1179
1324
|
continue;
|
|
1180
1325
|
splatDensity(mball, cols, rows, STEP, p.x, p.y, RAD, 1);
|
|
1181
1326
|
}
|
|
1182
|
-
const acc = hexToRgb(cfg.accent)
|
|
1327
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1183
1328
|
ctx.globalCompositeOperation = 'lighter';
|
|
1184
1329
|
ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.5 * boot})`;
|
|
1185
1330
|
ctx.lineWidth = 1.4;
|
|
@@ -1233,7 +1378,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1233
1378
|
vor[gy * cols + gx] = owner;
|
|
1234
1379
|
}
|
|
1235
1380
|
}
|
|
1236
|
-
const acc = hexToRgb(cfg.accent)
|
|
1381
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1237
1382
|
ctx.globalCompositeOperation = 'lighter';
|
|
1238
1383
|
ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.32 * boot})`;
|
|
1239
1384
|
ctx.lineWidth = 1;
|
|
@@ -1251,7 +1396,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1251
1396
|
// particles plus the flow they ride, in this one underlay canvas (no second surface, no blend).
|
|
1252
1397
|
if (cfg.render === 'streamlines' || cfg.render === 'flow') {
|
|
1253
1398
|
const GRID = 46;
|
|
1254
|
-
const acc = hexToRgb(cfg.accent)
|
|
1399
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1255
1400
|
ctx.lineWidth = 1;
|
|
1256
1401
|
ctx.lineCap = 'round';
|
|
1257
1402
|
// RESAMPLE the field on a cadence, not every frame. The arrows trace the body-induced force
|
|
@@ -1343,41 +1488,51 @@ export function createField(canvas, opts = {}) {
|
|
|
1343
1488
|
// unused by the current dispatch (both arrow modes read the felt field).
|
|
1344
1489
|
function drawOverlayArrows(out, structure, absolute) {
|
|
1345
1490
|
const GRID = 44;
|
|
1346
|
-
const acc = hexToRgb(cfg.accent)
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1491
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1492
|
+
// RESAMPLE the field on a cadence, not every frame (mirrors the underlay slSamples cache, #406).
|
|
1493
|
+
// The overlay grid is the same body-induced force field — it only shifts on the measure cadence
|
|
1494
|
+
// (every 6th frame) or while a flow focus animates — so a per-frame regrid (≈grid×bodies force
|
|
1495
|
+
// evals) was the same wasted work the underlay shed, surfacing as scroll jank when a Field-
|
|
1496
|
+
// Surfaces reading is live. Resample every 3rd frame (or empty cache / live flow); the EMA scale
|
|
1497
|
+
// updates with it. DRAW from the cache every frame below, so the arrows never flicker or step.
|
|
1498
|
+
if (olSamples === null || flow || frameN % 3 === 0) {
|
|
1499
|
+
const samples = [];
|
|
1500
|
+
let frameMax = 0;
|
|
1501
|
+
for (let gx = GRID / 2; gx < W; gx += GRID) {
|
|
1502
|
+
for (let gy = GRID / 2; gy < H; gy += GRID) {
|
|
1503
|
+
let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
|
|
1504
|
+
if (flow) {
|
|
1505
|
+
const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
|
|
1506
|
+
fx += b.x;
|
|
1507
|
+
fy += b.y;
|
|
1508
|
+
}
|
|
1509
|
+
const mag = Math.hypot(fx, fy);
|
|
1510
|
+
if (!(mag > 1e-9))
|
|
1511
|
+
continue; // skip dead zones / NaN
|
|
1512
|
+
samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
|
|
1513
|
+
if (mag > frameMax)
|
|
1514
|
+
frameMax = mag;
|
|
1356
1515
|
}
|
|
1357
|
-
const mag = Math.hypot(fx, fy);
|
|
1358
|
-
if (!(mag > 1e-9))
|
|
1359
|
-
continue; // skip dead zones / NaN
|
|
1360
|
-
samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
|
|
1361
|
-
if (mag > frameMax)
|
|
1362
|
-
frameMax = mag;
|
|
1363
1516
|
}
|
|
1517
|
+
// Same EMA approach as the underlay streamlines (see slMaxSmoothed) — independent state so
|
|
1518
|
+
// the overlay scale never couples to the underlay's field strength. Updated with the resample.
|
|
1519
|
+
if (olMaxSmoothed === 0)
|
|
1520
|
+
olMaxSmoothed = frameMax;
|
|
1521
|
+
else
|
|
1522
|
+
olMaxSmoothed = frameMax > olMaxSmoothed
|
|
1523
|
+
? olMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
|
|
1524
|
+
: olMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
|
|
1525
|
+
olSamples = samples;
|
|
1364
1526
|
}
|
|
1365
|
-
// Same EMA approach as the underlay streamlines (see slMaxSmoothed) — independent state so
|
|
1366
|
-
// the overlay scale never couples to the underlay's field strength.
|
|
1367
|
-
if (olMaxSmoothed === 0)
|
|
1368
|
-
olMaxSmoothed = frameMax;
|
|
1369
|
-
else
|
|
1370
|
-
olMaxSmoothed = frameMax > olMaxSmoothed
|
|
1371
|
-
? olMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
|
|
1372
|
-
: olMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
|
|
1373
1527
|
if (olMaxSmoothed <= 0)
|
|
1374
1528
|
return;
|
|
1375
1529
|
// one backend call per arrow: shaft + two head strokes packed as three segments. Alpha varies
|
|
1376
1530
|
// per arrow (it encodes magnitude), so arrows can't share one batch without quantizing — the
|
|
1377
|
-
// call count matches the previous per-arrow beginPath/stroke exactly.
|
|
1531
|
+
// call count matches the previous per-arrow beginPath/stroke exactly. `acc` is read every frame
|
|
1532
|
+
// (outside the resample) so a live setAccent recolors the cached arrows immediately.
|
|
1378
1533
|
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.2 };
|
|
1379
1534
|
const seg = new Float64Array(12);
|
|
1380
|
-
for (const s of
|
|
1535
|
+
for (const s of olSamples) {
|
|
1381
1536
|
const rel = absolute ? clamp(s.mag / olMaxSmoothed, 0, 1) : Math.sqrt(s.mag / olMaxSmoothed);
|
|
1382
1537
|
const len = GRID * 0.5 * (0.25 + 0.75 * rel);
|
|
1383
1538
|
const ex = s.gx + s.ux * len;
|
|
@@ -1415,7 +1570,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1415
1570
|
bounds: { w: W, h: H },
|
|
1416
1571
|
loopDist: 8,
|
|
1417
1572
|
});
|
|
1418
|
-
const acc = hexToRgb(cfg.accent)
|
|
1573
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1419
1574
|
// one polyline per traced curve through the backend seam (#373) — same shared stroke style.
|
|
1420
1575
|
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0.42, width: 1.1 };
|
|
1421
1576
|
for (const line of lines) {
|
|
@@ -1433,7 +1588,8 @@ export function createField(canvas, opts = {}) {
|
|
|
1433
1588
|
// space itself made visible, bending where the field is strong. Reads deformation.
|
|
1434
1589
|
function drawOverlayGrid(out) {
|
|
1435
1590
|
const STEP = 56;
|
|
1436
|
-
const MAXD = 11; // px displacement at the strongest sample — legible, never
|
|
1591
|
+
const MAXD = 11 * cfg.gridWarp; // px displacement at the strongest sample — legible, never
|
|
1592
|
+
// chaotic at the default gridWarp=1; the multiplier exaggerates the deformation for demos.
|
|
1437
1593
|
const cols = Math.floor(W / STEP) + 2;
|
|
1438
1594
|
const rows = Math.floor(H / STEP) + 2;
|
|
1439
1595
|
const dx = new Float32Array(cols * rows);
|
|
@@ -1454,8 +1610,10 @@ export function createField(canvas, opts = {}) {
|
|
|
1454
1610
|
}
|
|
1455
1611
|
}
|
|
1456
1612
|
}
|
|
1457
|
-
const acc = hexToRgb(cfg.accent)
|
|
1458
|
-
|
|
1613
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1614
|
+
// a bolder lattice also wants a slightly heavier line so it reads as structure, not threads;
|
|
1615
|
+
// at the faint diagnostic default (0.16) this stays width 1 — byte-identical to before.
|
|
1616
|
+
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: cfg.gridIntensity, width: cfg.gridIntensity > 0.3 ? 1.2 : 1 };
|
|
1459
1617
|
const px = (gx, gy) => {
|
|
1460
1618
|
const i = gy * cols + gx;
|
|
1461
1619
|
const rel = maxMag > 0 ? Math.sqrt(mags[i] / maxMag) : 0;
|
|
@@ -1506,7 +1664,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1506
1664
|
max = oscalar[i];
|
|
1507
1665
|
if (max <= 0)
|
|
1508
1666
|
return;
|
|
1509
|
-
const acc = hexToRgb(cfg.accent)
|
|
1667
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1510
1668
|
const LEVELS = [0.25, 0.5, 0.78]; // nested iso-rings: faint outer shell → bright core
|
|
1511
1669
|
const packed = [];
|
|
1512
1670
|
for (let li = 0; li < LEVELS.length; li++) {
|
|
@@ -1544,7 +1702,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1544
1702
|
const SEED = 104; // seed lattice spacing (px)
|
|
1545
1703
|
const STEPPX = 9; // integration step (px)
|
|
1546
1704
|
const STEPS = 24; // max steps per path
|
|
1547
|
-
const acc = hexToRgb(cfg.accent)
|
|
1705
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1548
1706
|
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.1 };
|
|
1549
1707
|
const seg = new Float64Array(4);
|
|
1550
1708
|
for (let sx = SEED / 2; sx < W; sx += SEED) {
|
|
@@ -1582,7 +1740,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1582
1740
|
// (§8, the same number the platform mirrors to `--d`) printed beside the body. Feedback bodies
|
|
1583
1741
|
// lead (they asked to be measured); non-feedback bodies are skipped — no reading, no chip.
|
|
1584
1742
|
function drawOverlayData(out) {
|
|
1585
|
-
const acc = hexToRgb(cfg.accent)
|
|
1743
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1586
1744
|
for (const b of bodies) {
|
|
1587
1745
|
if (!b.vis || !b.feedback)
|
|
1588
1746
|
continue;
|
|
@@ -1634,16 +1792,36 @@ export function createField(canvas, opts = {}) {
|
|
|
1634
1792
|
if (boot < 1)
|
|
1635
1793
|
boot = Math.min(1, boot + 0.012);
|
|
1636
1794
|
easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
|
|
1795
|
+
// a contained field re-reads its origin each frame (the container's viewport position shifts as the
|
|
1796
|
+
// page scrolls); cheap — one getBoundingClientRect for the container, only when contained.
|
|
1797
|
+
if (contained) {
|
|
1798
|
+
const vp = host.viewport();
|
|
1799
|
+
originX = vp.originX ?? 0;
|
|
1800
|
+
originY = vp.originY ?? 0;
|
|
1801
|
+
}
|
|
1637
1802
|
const scrollY = host.scrollY();
|
|
1803
|
+
const dScroll = scrollY - lastScrollY;
|
|
1638
1804
|
// eased page-scroll speed for the `scrolling` data-when gate (§5).
|
|
1639
|
-
env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(
|
|
1805
|
+
env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(dScroll) * 0.3;
|
|
1640
1806
|
lastScrollY = scrollY;
|
|
1807
|
+
// Scroll-compensate the cached body centres between the every-6th-frame re-measures. The page
|
|
1808
|
+
// scrolls continuously under the fixed field, so each body's viewport position shifts every frame
|
|
1809
|
+
// even though getBoundingClientRect only runs on the measure cadence — without this the attractors
|
|
1810
|
+
// snap in 6-frame steps during scroll and the swarm reads as "pausing". Cheap and drift-free: the
|
|
1811
|
+
// elements don't move in the document between measures, so the only delta is scroll, and
|
|
1812
|
+
// measureBodies refreshes from the real rects on its own cadence. cy carries the shaped box too
|
|
1813
|
+
// (it is centred on cy ± hh). Window fields only: a contained field's local positions are
|
|
1814
|
+
// scroll-invariant (container + contents move together), so this shift would corrupt them (#540).
|
|
1815
|
+
if (dScroll !== 0 && !contained)
|
|
1816
|
+
for (const b of bodies)
|
|
1817
|
+
b.cy -= dScroll;
|
|
1641
1818
|
for (const w of waves) {
|
|
1642
1819
|
const target = scrollY * (0.025 + w.depth * 0.08); // wave parallax (§24)
|
|
1643
1820
|
w.offsetY += (target - w.offsetY) * 0.04;
|
|
1644
1821
|
}
|
|
1645
1822
|
if (bodies.length && frameN % 6 === 0) {
|
|
1646
|
-
measureBodies(bodies, W, H);
|
|
1823
|
+
measureBodies(bodies, W, H, originX, originY);
|
|
1824
|
+
detectProximityEvents(bodies); // #441 enter/exit/met — on the measure cadence, lazy
|
|
1647
1825
|
// attention-gated discharge (#365): an engagement-gated sink releases on the falling
|
|
1648
1826
|
// edge of engagement — the same conserved supernova ritual as saturation.
|
|
1649
1827
|
dischargeDisengaged(bodies, env.supernova);
|
|
@@ -1880,6 +2058,14 @@ export function createField(canvas, opts = {}) {
|
|
|
1880
2058
|
if (ctx)
|
|
1881
2059
|
sizeSurfaces(host.viewport().dpr); // re-size the backing store to the new ceiling now
|
|
1882
2060
|
},
|
|
2061
|
+
setQualityTier: (tier) => {
|
|
2062
|
+
const next = Math.max(0, Math.min(3, Math.floor(tier || 0)));
|
|
2063
|
+
if (next === qualityTier)
|
|
2064
|
+
return;
|
|
2065
|
+
qualityTier = next;
|
|
2066
|
+
if (ctx)
|
|
2067
|
+
sizeSurfaces(host.viewport().dpr); // re-apply the tier's effective DPR ceiling now
|
|
2068
|
+
},
|
|
1883
2069
|
threads: setThreads,
|
|
1884
2070
|
burst: (x, y, hex) => {
|
|
1885
2071
|
// discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
|
|
@@ -2044,7 +2230,7 @@ export function createField(canvas, opts = {}) {
|
|
|
2044
2230
|
body.onFeedback = (ch) => { Object.assign(channels, ch); spec.onFeedback?.(ch); };
|
|
2045
2231
|
programmaticBodies.push(body);
|
|
2046
2232
|
bodies = bodies.concat(body); // live this frame, before the next scan re-merges it
|
|
2047
|
-
measureBodies([body], W, H); // init cx/cy so the first sample/force is correct
|
|
2233
|
+
measureBodies([body], W, H, originX, originY); // init cx/cy so the first sample/force is correct
|
|
2048
2234
|
return {
|
|
2049
2235
|
data: spec.data,
|
|
2050
2236
|
get channels() { return channels; },
|
|
@@ -2091,8 +2277,18 @@ export function createField(canvas, opts = {}) {
|
|
|
2091
2277
|
const { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
|
|
2092
2278
|
return { x: fx, y: fy };
|
|
2093
2279
|
},
|
|
2094
|
-
sampleScalar: (x, y) =>
|
|
2095
|
-
|
|
2280
|
+
sampleScalar: (x, y) => {
|
|
2281
|
+
if (heatmap)
|
|
2282
|
+
return heatmap.norm(x, y);
|
|
2283
|
+
devWarnNoOp('NOOP_NO_HEATMAP', 'sampleScalar() returned 0 because the heatmap layer is off — construct with { heatmap: true } or call setHeatmap(true).');
|
|
2284
|
+
return 0;
|
|
2285
|
+
},
|
|
2286
|
+
sampleGradient: (x, y) => {
|
|
2287
|
+
if (heatmap)
|
|
2288
|
+
return heatmap.gradient(x, y);
|
|
2289
|
+
devWarnNoOp('NOOP_NO_HEATMAP', 'sampleGradient() returned { x: 0, y: 0 } because the heatmap layer is off — construct with { heatmap: true } or call setHeatmap(true).');
|
|
2290
|
+
return { x: 0, y: 0 };
|
|
2291
|
+
},
|
|
2096
2292
|
grid: (name) => env.grid(name),
|
|
2097
2293
|
on: (type, cb) => {
|
|
2098
2294
|
let set = busListeners.get(type);
|
|
@@ -2103,6 +2299,7 @@ export function createField(canvas, opts = {}) {
|
|
|
2103
2299
|
set.add(cb);
|
|
2104
2300
|
return () => void set.delete(cb);
|
|
2105
2301
|
},
|
|
2302
|
+
version: FIELD_VERSION,
|
|
2106
2303
|
scrollV: () => env.scrollV ?? 0,
|
|
2107
2304
|
setVisible: (on) => {
|
|
2108
2305
|
canvasVisible = on;
|