@fundamental-engine/core 0.7.0 → 0.9.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/agents/event-agent.d.ts +4 -2
- package/dist/agents/event-agent.d.ts.map +1 -1
- package/dist/agents/event-agent.js +15 -6
- package/dist/agents/event-agent.js.map +1 -1
- 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/currents.d.ts +12 -0
- package/dist/core/currents.d.ts.map +1 -1
- package/dist/core/currents.js +23 -0
- package/dist/core/currents.js.map +1 -1
- package/dist/core/events.d.ts +37 -0
- package/dist/core/events.d.ts.map +1 -1
- package/dist/core/events.js +52 -0
- package/dist/core/events.js.map +1 -1
- package/dist/core/field.d.ts.map +1 -1
- package/dist/core/field.js +789 -133
- package/dist/core/field.js.map +1 -1
- package/dist/core/frame-harness.d.ts +109 -0
- package/dist/core/frame-harness.d.ts.map +1 -0
- package/dist/core/frame-harness.js +300 -0
- package/dist/core/frame-harness.js.map +1 -0
- package/dist/core/host-headless.d.ts +35 -0
- package/dist/core/host-headless.d.ts.map +1 -0
- package/dist/core/host-headless.js +47 -0
- package/dist/core/host-headless.js.map +1 -0
- package/dist/core/host.d.ts +7 -0
- package/dist/core/host.d.ts.map +1 -1
- package/dist/core/integrator.d.ts +6 -0
- package/dist/core/integrator.d.ts.map +1 -1
- package/dist/core/integrator.js +62 -12
- package/dist/core/integrator.js.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/streamlines.d.ts.map +1 -1
- package/dist/core/streamlines.js +18 -2
- package/dist/core/streamlines.js.map +1 -1
- package/dist/core/types.d.ts +133 -12
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +10 -1
- package/dist/core/types.js.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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/record/index.d.ts +13 -0
- package/dist/record/index.d.ts.map +1 -0
- package/dist/record/index.js +13 -0
- package/dist/record/index.js.map +1 -0
- package/dist/record/record.d.ts +87 -0
- package/dist/record/record.d.ts.map +1 -0
- package/dist/record/record.js +172 -0
- package/dist/record/record.js.map +1 -0
- package/dist/record/rng.d.ts +15 -0
- package/dist/record/rng.d.ts.map +1 -0
- package/dist/record/rng.js +25 -0
- package/dist/record/rng.js.map +1 -0
- 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
|
@@ -20,20 +20,23 @@ import { scanBodies, measureBodies, bodyFromElement } from "./scanner.js";
|
|
|
20
20
|
import { ShadowRegistry, REGISTER_BODY, UNREGISTER_BODY, UPDATE_BODY, } from "./shadow.js";
|
|
21
21
|
import { easeFormation } from "./formations.js";
|
|
22
22
|
import { Heatmap } from "./heatmap.js";
|
|
23
|
-
import { buildWaves, buildBound, waveYat, } from "./currents.js";
|
|
23
|
+
import { buildWaves, buildBound, waveYat, waveRAt, } 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";
|
|
30
31
|
import { thermoMetrics } from "./thermo.js";
|
|
31
32
|
import { attentionMuls } from "./attention.js";
|
|
33
|
+
import { updateRelationship } from "../agents/relationship.js";
|
|
34
|
+
import { Thresholder } from "../agents/event-agent.js";
|
|
32
35
|
import { spillover } from "./causality.js";
|
|
33
36
|
import { integrateOffset, anchorForce, elementMass, repelForce, densityPush } from "./agents.js";
|
|
34
37
|
import { releaseCaptured, sinkLoad, captureEdge, dischargeDisengaged } from "./accretion.js";
|
|
35
38
|
import { withinCapture, stepDock, dockTransform } from "./dock.js";
|
|
36
|
-
import { parseEventBindings, triggerActive } from "./events.js";
|
|
39
|
+
import { FieldEventCoalescer, parseEventBindings, triggerActive } from "./events.js";
|
|
37
40
|
import { registerCoreForces } from "../forces/index.js";
|
|
38
41
|
import { registerNaturalForces } from "../forces/natural.js";
|
|
39
42
|
import { registerExtendedForces } from "../forces/extended.js";
|
|
@@ -45,9 +48,9 @@ import { forceAt, netField } from "./streamlines.js";
|
|
|
45
48
|
import { traceFieldLines } from "./fieldlines.js";
|
|
46
49
|
import { fieldLineSeeds } from "./fieldline-seeds.js";
|
|
47
50
|
import { flowBiasInto, makeFlowFocus } from "./flow.js";
|
|
51
|
+
import { devWarnNoOp } from "../contracts/guards.js";
|
|
52
|
+
import { FIELD_VERSION } from "../version.js";
|
|
48
53
|
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
54
|
// Shared draw/integrate scratch — reused across the per-particle and per-cell hot loops so an
|
|
52
55
|
// active flow focus and the particle draw don't allocate a `{x,y}` / `[r,g,b]` each iteration.
|
|
53
56
|
// Safe to share module-wide: each field's frame runs synchronously, and every read consumes the
|
|
@@ -62,7 +65,7 @@ export function createField(canvas, opts = {}) {
|
|
|
62
65
|
// acquires it lazily (and sizes the store then) — so a field created with 'none' allocates no
|
|
63
66
|
// render surface at all unless asked to draw.
|
|
64
67
|
let ctx = null;
|
|
65
|
-
if ((opts.render ?? '
|
|
68
|
+
if ((opts.render ?? 'none') !== 'none') {
|
|
66
69
|
ctx = canvas.getContext('2d');
|
|
67
70
|
if (!ctx)
|
|
68
71
|
throw new Error('Fundamental: 2D canvas context unavailable');
|
|
@@ -86,15 +89,103 @@ export function createField(canvas, opts = {}) {
|
|
|
86
89
|
// when unused: detection passes guard on `busHas(type)` so zero listeners cost nothing.
|
|
87
90
|
const busListeners = new Map();
|
|
88
91
|
const busHas = (type) => (busListeners.get(type)?.size ?? 0) > 0;
|
|
89
|
-
function
|
|
92
|
+
function busDeliver(type, payload) {
|
|
90
93
|
const set = busListeners.get(type);
|
|
91
94
|
if (set)
|
|
92
95
|
for (const cb of set)
|
|
93
96
|
cb(payload);
|
|
94
97
|
}
|
|
98
|
+
// Per-frame coalescing (#684, shadow-dom §31): a force/condition can cross the same edge more than
|
|
99
|
+
// once within a single frame (multiple detection passes, a same-frame fill+release), which would
|
|
100
|
+
// otherwise fire the bus repeatedly per tick. busEmit buffers occurrences during the frame; the
|
|
101
|
+
// single flushBusEvents() at frame end delivers at most ONE event per (source, type) — last write
|
|
102
|
+
// wins. Behaviour-preserving for listeners that just react to "it happened"; it removes intra-frame
|
|
103
|
+
// duplicates only. detectProximityEvents/updateCaptureEvents already dedupe per *state transition*;
|
|
104
|
+
// this layer additionally guarantees one delivery per frame regardless of how many passes ran.
|
|
105
|
+
const busCoalescer = new FieldEventCoalescer();
|
|
106
|
+
function busEmit(type, payload) {
|
|
107
|
+
if (!busHas(type))
|
|
108
|
+
return; // no listeners → don't even buffer
|
|
109
|
+
busCoalescer.record(type, payload);
|
|
110
|
+
}
|
|
111
|
+
// Flush the frame's coalesced bus events to listeners — called once at the end of each frame.
|
|
112
|
+
function flushBusEvents() {
|
|
113
|
+
busCoalescer.flush(busDeliver);
|
|
114
|
+
}
|
|
95
115
|
const sinkPeak = new WeakMap(); // matter held at the rising edge, for the release count
|
|
116
|
+
// #441 body-proximity events. Object-identity membership (survives rescan; removed bodies pruned).
|
|
117
|
+
const insideOf = new Map(); // enter/exit: bodies currently within a body's range
|
|
118
|
+
const metWith = new Map(); // met: bodies currently in box-contact (symmetric, deduped a<b)
|
|
119
|
+
function detectProximityEvents(bs) {
|
|
120
|
+
const wantEE = busHas('enter') || busHas('exit');
|
|
121
|
+
const wantMet = busHas('met');
|
|
122
|
+
if (!wantEE && !wantMet)
|
|
123
|
+
return; // lazy: zero listeners → zero cost
|
|
124
|
+
for (let i = 0; i < bs.length; i++) {
|
|
125
|
+
const a = bs[i];
|
|
126
|
+
if (wantEE) {
|
|
127
|
+
let inside = insideOf.get(a);
|
|
128
|
+
if (!inside)
|
|
129
|
+
insideOf.set(a, (inside = new Set()));
|
|
130
|
+
const r2 = a.range * a.range;
|
|
131
|
+
for (let j = 0; j < bs.length; j++) {
|
|
132
|
+
if (i === j)
|
|
133
|
+
continue;
|
|
134
|
+
const o = bs[j];
|
|
135
|
+
const dx = o.cx - a.cx, dy = o.cy - a.cy;
|
|
136
|
+
const isIn = dx * dx + dy * dy < r2;
|
|
137
|
+
if (isIn && !inside.has(o)) {
|
|
138
|
+
inside.add(o);
|
|
139
|
+
if (busHas('enter'))
|
|
140
|
+
busEmit('enter', { body: a, other: o });
|
|
141
|
+
}
|
|
142
|
+
else if (!isIn && inside.has(o)) {
|
|
143
|
+
inside.delete(o);
|
|
144
|
+
if (busHas('exit'))
|
|
145
|
+
busEmit('exit', { body: a, other: o });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (wantMet) {
|
|
150
|
+
let met = metWith.get(a);
|
|
151
|
+
if (!met)
|
|
152
|
+
metWith.set(a, (met = new Set()));
|
|
153
|
+
for (let j = i + 1; j < bs.length; j++) {
|
|
154
|
+
const b = bs[j];
|
|
155
|
+
const touch = Math.abs(b.cx - a.cx) < a.hw + b.hw && Math.abs(b.cy - a.cy) < a.hh + b.hh;
|
|
156
|
+
if (touch && !met.has(b)) {
|
|
157
|
+
met.add(b);
|
|
158
|
+
busEmit('met', { a, b });
|
|
159
|
+
}
|
|
160
|
+
else if (!touch && met.has(b))
|
|
161
|
+
met.delete(b);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// prune tracking for bodies no longer present (rescan removed them) so they neither leak nor re-fire
|
|
166
|
+
const present = new Set(bs);
|
|
167
|
+
for (const m of [insideOf, metWith])
|
|
168
|
+
for (const k of m.keys())
|
|
169
|
+
if (!present.has(k))
|
|
170
|
+
m.delete(k);
|
|
171
|
+
}
|
|
96
172
|
const programmaticBodies = []; // bodies added via addBody(); merged into `bodies` each scan
|
|
173
|
+
// programmatic edges (addEdge): relationships between two addBody bodies, with no DOM. Each carries a
|
|
174
|
+
// pure RelationshipAgent stepped every frame (strengthen-with-use / decay / memory). handleToBody lets
|
|
175
|
+
// addEdge resolve the two bodies from the handles addBody returned.
|
|
176
|
+
const handleToBody = new WeakMap();
|
|
177
|
+
const programmaticEdges = [];
|
|
178
|
+
let edgeSeq = 0;
|
|
179
|
+
// Reserved agent-threshold events (§22.5, FIELD_EVENTS): per-body hysteretic edge detectors that
|
|
180
|
+
// turn a continuous metric (sink load, density, attention, entropy) into one debounced `field:*`
|
|
181
|
+
// CustomEvent on its rising edge — never per-frame. Lazy: a body gets a Thresholder for a metric
|
|
182
|
+
// only the first frame it has a value to test, and the maps are pruned on rescan with the body.
|
|
183
|
+
// Keyed body → metric-name → Thresholder; edges track relationship `memory` the same way.
|
|
184
|
+
const bodyThresholds = new WeakMap();
|
|
185
|
+
const edgeThresholds = new WeakMap();
|
|
97
186
|
const fieldChannels = new Map(); // addField() input channels
|
|
187
|
+
// custom overlay functions registered via registerOverlay(); keyed by name, dispatched from renderOverlay()
|
|
188
|
+
const customOverlays = new Map();
|
|
98
189
|
// All 36 forces are registered on every field — there is no opt-in. Any of them activates per-body
|
|
99
190
|
// through its `data-body` token (e.g. `data-body="lens crystallize"`); an unused force costs nothing.
|
|
100
191
|
registerCoreForces(reg); // the canonical nine (§6)
|
|
@@ -110,17 +201,32 @@ export function createField(canvas, opts = {}) {
|
|
|
110
201
|
const host = opts.host;
|
|
111
202
|
const teardowns = []; // host event unsubscribers, called on destroy
|
|
112
203
|
const reduceMotion = host.reducedMotion();
|
|
204
|
+
// ambient theme (#529): a named preset for the heat ramp + wave baseline; `warm` (default) reproduces
|
|
205
|
+
// the shipped palette. Individual lanes (gradientCool/gradientWarm/waveBaseline) override the preset.
|
|
206
|
+
const theme = THEMES[opts.theme ?? DEFAULT_THEME] ?? THEMES[DEFAULT_THEME];
|
|
113
207
|
const cfg = {
|
|
114
208
|
accent: opts.accent ?? resolvePalette(opts.palette)[0] ?? PALETTE[0] ?? '#4da3ff',
|
|
115
209
|
density: opts.density && opts.density > 0 ? opts.density : 1,
|
|
116
|
-
render: opts.render ?? 'dots'
|
|
210
|
+
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
211
|
waves: opts.waves ?? true, // draw the background Currents (§24); opt-out for the bare field
|
|
212
|
+
waveStyle: opts.waveStyle ?? 'linear',
|
|
213
|
+
waveCenter: opts.waveCenter ?? null,
|
|
118
214
|
background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
|
|
119
215
|
mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
|
|
216
|
+
separation: opts.separation != null && opts.separation >= 0 ? opts.separation : 0,
|
|
120
217
|
attention: opts.attention ?? false, // conserved attention (§2.4), opt-in
|
|
121
218
|
causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
|
|
122
219
|
heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
|
|
123
220
|
overlay: opts.overlay ?? 'off', // Field Surfaces: overlay-surface visualization mode, opt-in
|
|
221
|
+
// distortion multiplier for the `grid` overlay's lattice deflection (1 = calibrated default; 0 flat).
|
|
222
|
+
gridWarp: opts.gridWarp != null && opts.gridWarp >= 0 ? opts.gridWarp : 1,
|
|
223
|
+
// stroke opacity for the `grid` overlay lines. 0.16 = the calibrated faint diagnostic (default,
|
|
224
|
+
// never overpowers content); raise it (≈0.5) to make the warped lattice a deliberate centerpiece.
|
|
225
|
+
gridIntensity: opts.gridIntensity != null && opts.gridIntensity >= 0 ? Math.min(opts.gridIntensity, 1) : 0.16,
|
|
226
|
+
// theme palette (#529): the heat-ramp ends + wave baseline, resolved from the preset + overrides.
|
|
227
|
+
gradientCool: opts.gradientCool ? hexToRgb(opts.gradientCool) : theme.cool,
|
|
228
|
+
gradientWarm: opts.gradientWarm ? hexToRgb(opts.gradientWarm) : theme.warm,
|
|
229
|
+
waveBaseline: (opts.waveBaseline ?? theme.wave).map(hexToRgb),
|
|
124
230
|
dprCap: opts.dprCap && opts.dprCap > 0 ? opts.dprCap : 2, // backing-store DPR ceiling (#410); the
|
|
125
231
|
// dominant fill-rate lever — the ambient field is soft, so ~1.5 buys ~1.8× headroom on retina.
|
|
126
232
|
// optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
|
|
@@ -135,6 +241,14 @@ export function createField(canvas, opts = {}) {
|
|
|
135
241
|
let bodies = [];
|
|
136
242
|
let W = 0;
|
|
137
243
|
let H = 0;
|
|
244
|
+
// field-space origin from the host viewport (#540) — 0,0 for a window host, the container's
|
|
245
|
+
// left/top for a contained host. measureBodies + the move/thread readouts subtract it.
|
|
246
|
+
let originX = 0;
|
|
247
|
+
let originY = 0;
|
|
248
|
+
// a contained field (host viewport returns an origin): its bodies live in container-local space,
|
|
249
|
+
// which is INVARIANT under page scroll (the container + its contents move together). So it refreshes
|
|
250
|
+
// the origin each frame and skips the window-only per-frame scroll shift below.
|
|
251
|
+
let contained = false;
|
|
138
252
|
// cached page scroll extent — reading scrollHeight forces a synchronous reflow, so
|
|
139
253
|
// we cache it (refreshed on resize + sampled occasionally) rather than per frame.
|
|
140
254
|
let maxScroll = 1;
|
|
@@ -152,6 +266,7 @@ export function createField(canvas, opts = {}) {
|
|
|
152
266
|
let waves = [];
|
|
153
267
|
let bound = [];
|
|
154
268
|
let boundTarget = 0;
|
|
269
|
+
let resolvedWaveCenter = null;
|
|
155
270
|
// the injected sources (#371): every random draw and wall-clock read in the engine flows
|
|
156
271
|
// through these two, so a seeded rng + fixed clock make a run reproducible end to end.
|
|
157
272
|
const rng = opts.rng ?? Math.random;
|
|
@@ -175,6 +290,15 @@ export function createField(canvas, opts = {}) {
|
|
|
175
290
|
// on a cadence and DRAW from this cache every frame (so the arrows never flicker or step).
|
|
176
291
|
let slSamples = null;
|
|
177
292
|
let slQuiescent = [];
|
|
293
|
+
// Same cadence cache for the OVERLAY arrows (drawOverlayArrows) — the in-front Field-Surfaces
|
|
294
|
+
// reading. Its grid is the same body-induced force field, so it had the same per-frame regrid
|
|
295
|
+
// waste the underlay shed in #406; resample on the cadence, draw from this cache every frame.
|
|
296
|
+
let olSamples = null;
|
|
297
|
+
// Tag-tint cache (#515): the parsed RGB + reach² of each coloured body, rebuilt only on the
|
|
298
|
+
// measure cadence (colour/range change there, not per frame). The body ref is kept so the
|
|
299
|
+
// per-particle loop reads cx/cy FRESH each frame — those are scroll-compensated every frame
|
|
300
|
+
// (#508), so caching positions would re-introduce the swarm-pause-on-scroll lag for the tint.
|
|
301
|
+
let tintCache = null;
|
|
178
302
|
// hard pool ceiling for class-[S] sources (§20.1) — generous above the ~130·density
|
|
179
303
|
// base field so emission is never starved, but bounded so the sim can't grow forever.
|
|
180
304
|
const spawnCeiling = Math.round(130 * cfg.density) * 4;
|
|
@@ -192,6 +316,10 @@ export function createField(canvas, opts = {}) {
|
|
|
192
316
|
let emitters = [];
|
|
193
317
|
let sparks = [];
|
|
194
318
|
let eventEls = [];
|
|
319
|
+
// element trigger class-toggle (§22.3, FACM #687): `data-class="dense:lit, captured:full"` adds a
|
|
320
|
+
// class while a trigger holds and removes it when the trigger releases — the declarative, no-JS
|
|
321
|
+
// counterpart of the `data-on` CustomEvent binding (same trigger vocabulary, parsed identically).
|
|
322
|
+
let classEls = [];
|
|
195
323
|
let engaged = []; // [data-hot] listeners, for teardown
|
|
196
324
|
// shadow-DOM participation (docs/engine-reference/shadow-dom.md): encapsulated components dispatch composed
|
|
197
325
|
// register/unregister/update events; the field registers the HOST and never inspects the
|
|
@@ -273,6 +401,12 @@ export function createField(canvas, opts = {}) {
|
|
|
273
401
|
tearBoundNear(bound, waves, b.cx, b.cy, 320, W, H, env.t, (p) => void store.add(newParticle(p)));
|
|
274
402
|
// release docks any DOM elements this sink had captured (§22.3, element capture).
|
|
275
403
|
undockFrom(b);
|
|
404
|
+
// saturation is the threshold transition for field:saturated (§22.5, FACM #686): the sink
|
|
405
|
+
// reached its capacity this frame (that is what triggered the supernova). The force pass resets
|
|
406
|
+
// accreted to 0 immediately, so the load metric is never observable at ≈1 after the fact — the
|
|
407
|
+
// honest place to announce saturation is here, the moment it happens. field:released (below) is
|
|
408
|
+
// the paired down-edge, so a consumer gets both ends of the cycle.
|
|
409
|
+
fireThreshold(b.el, 'field:saturated', { peak: released.length });
|
|
276
410
|
// and fires field:released on the falling edge of accreting (capture/release events, §22.5).
|
|
277
411
|
if (b.el.dataset.fxCap === '1') {
|
|
278
412
|
b.el.dataset.fxCap = '0';
|
|
@@ -362,7 +496,7 @@ export function createField(canvas, opts = {}) {
|
|
|
362
496
|
store.add(newParticle());
|
|
363
497
|
applySeed();
|
|
364
498
|
// the Currents (§24) are opt-out: with waves off, the field is just the free particles.
|
|
365
|
-
waves = cfg.waves ? buildWaves(
|
|
499
|
+
waves = cfg.waves ? buildWaves(cfg.waveBaseline) : [];
|
|
366
500
|
bound = cfg.waves ? buildBound(waves.length, cfg.density, rng) : [];
|
|
367
501
|
boundTarget = bound.length;
|
|
368
502
|
}
|
|
@@ -381,7 +515,7 @@ export function createField(canvas, opts = {}) {
|
|
|
381
515
|
// programmatic bodies (addBody) aren't discoverable by the scan — carry them across the rebuild.
|
|
382
516
|
if (programmaticBodies.length > 0)
|
|
383
517
|
bodies = bodies.concat(programmaticBodies);
|
|
384
|
-
measureBodies(bodies, W, H);
|
|
518
|
+
measureBodies(bodies, W, H, originX, originY);
|
|
385
519
|
bindEngagement();
|
|
386
520
|
// Reconcile movers: carry forward offset + dock state for elements that persist across
|
|
387
521
|
// rescans (shadow-DOM re-register, Astro nav re-mounts, explicit rescan()). An element that
|
|
@@ -418,6 +552,16 @@ export function createField(canvas, opts = {}) {
|
|
|
418
552
|
bindings: parseEventBindings(el.dataset.on ?? ''),
|
|
419
553
|
};
|
|
420
554
|
});
|
|
555
|
+
// element trigger class-toggle (§22.3, FACM #687): same trigger:value grammar as data-on, but the
|
|
556
|
+
// value is a class name toggled on the element instead of an event name dispatched.
|
|
557
|
+
classEls = [...host.root.querySelectorAll('[data-class]')].map((node) => {
|
|
558
|
+
const el = node;
|
|
559
|
+
return {
|
|
560
|
+
el,
|
|
561
|
+
body: bodies.find((b) => b.el === el) ?? null,
|
|
562
|
+
bindings: parseEventBindings(el.dataset.class ?? ''),
|
|
563
|
+
};
|
|
564
|
+
});
|
|
421
565
|
// resolve `warp` pairings (§22.3 relocate): a body's data-pair selector → the paired body, whose
|
|
422
566
|
// live centre becomes the relocate target each frame (updateWarpTargets).
|
|
423
567
|
for (const b of bodies) {
|
|
@@ -536,6 +680,31 @@ export function createField(canvas, opts = {}) {
|
|
|
536
680
|
}
|
|
537
681
|
}
|
|
538
682
|
}
|
|
683
|
+
// element trigger class-toggle (§22.3, FACM #687): the declarative element-trigger consumer. While a
|
|
684
|
+
// trigger holds (the same dense/sparse/engaged/captured vocabulary data-on uses), add its class to
|
|
685
|
+
// the element; remove it when the trigger releases. Idempotent via classList + the per-binding armed
|
|
686
|
+
// flag, so a held trigger toggles exactly once. Opt-in by `data-class`, so it never touches existing
|
|
687
|
+
// content — the no-JS counterpart of doing `el.classList.toggle()` inside a data-on handler.
|
|
688
|
+
function updateClassToggles() {
|
|
689
|
+
if (classEls.length === 0)
|
|
690
|
+
return;
|
|
691
|
+
for (const ce of classEls) {
|
|
692
|
+
const s = ce.body
|
|
693
|
+
? { d: ce.body.d, on: ce.body.on, accreted: ce.body.accreted }
|
|
694
|
+
: { d: 0, on: ce.el.dataset.active === '1', accreted: 0 };
|
|
695
|
+
for (const bind of ce.bindings) {
|
|
696
|
+
const active = triggerActive(bind.trigger, s);
|
|
697
|
+
if (active && !bind.armed) {
|
|
698
|
+
bind.armed = true;
|
|
699
|
+
ce.el.classList.add(bind.event); // `event` carries the class name for a data-class binding
|
|
700
|
+
}
|
|
701
|
+
else if (!active && bind.armed) {
|
|
702
|
+
bind.armed = false;
|
|
703
|
+
ce.el.classList.remove(bind.event);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
539
708
|
// dispatch a discrete field event on an element, with the forces:* alias (migration window).
|
|
540
709
|
function fireCaptureEvent(el, name, detail) {
|
|
541
710
|
el.dispatchEvent(new CustomEvent('field:' + name, { bubbles: true, composed: true, detail }));
|
|
@@ -566,6 +735,72 @@ export function createField(canvas, opts = {}) {
|
|
|
566
735
|
}
|
|
567
736
|
}
|
|
568
737
|
}
|
|
738
|
+
// Reserved agent-threshold events (§22.5): turn each body's continuous metrics into debounced,
|
|
739
|
+
// hysteretic `field:*` CustomEvents on the element as it crosses a level. Mirrors the lit/dim
|
|
740
|
+
// contract (rising edge fires once, falls re-arm), but routed through the shared Thresholder so the
|
|
741
|
+
// enter/exit levels + debounce are uniform. Every event is paired (a "warning"/"shifted" rising
|
|
742
|
+
// edge and an `*-cleared` falling edge) so a consumer can react both ways. Lazy & cheap: a body
|
|
743
|
+
// only allocates a Thresholder for a metric the first frame that metric has a value to test, and
|
|
744
|
+
// the per-frame loop is a few comparisons. `field:entered`/`field:exited` here are the body's own
|
|
745
|
+
// density crossing (distinct from `field:lit`/`field:dim`, which carry the neighbour-spillover lit
|
|
746
|
+
// channel). See FIELD_EVENTS in agents/event-agent.ts and docs/canonical/agent-consumption-model.md.
|
|
747
|
+
// density / attention / entropy are continuous channels sampled per frame → hysteretic Thresholder.
|
|
748
|
+
// saturation is a discrete transition (the sink hitting capacity), so field:saturated is fired from
|
|
749
|
+
// the supernova callback above, not here (the load metric is reset to 0 before this loop runs).
|
|
750
|
+
const THRESHOLD_EVENTS = [
|
|
751
|
+
{ metric: 'density', enter: 0.6, exit: 0.2, rise: 'field:entered', fall: 'field:exited' },
|
|
752
|
+
{ metric: 'attention', enter: 1.5, exit: 1.1, rise: 'field:attention-shifted', fall: 'field:attention-settled' },
|
|
753
|
+
{ metric: 'entropy', enter: 0.7, exit: 0.4, rise: 'field:entropy-warning', fall: 'field:entropy-cleared' },
|
|
754
|
+
];
|
|
755
|
+
const THRESHOLD_DEBOUNCE_MS = 120;
|
|
756
|
+
function fireThreshold(el, name, detail) {
|
|
757
|
+
el.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
|
|
758
|
+
}
|
|
759
|
+
function dispatchBodyThreshold(store, key, el, spec, value, now) {
|
|
760
|
+
let metrics = store.get(key);
|
|
761
|
+
if (!metrics)
|
|
762
|
+
store.set(key, (metrics = new Map()));
|
|
763
|
+
let th = metrics.get(spec.metric);
|
|
764
|
+
if (!th)
|
|
765
|
+
metrics.set(spec.metric, (th = new Thresholder({ enter: spec.enter, exit: spec.exit, debounceMs: THRESHOLD_DEBOUNCE_MS })));
|
|
766
|
+
const edge = th.update(value, now);
|
|
767
|
+
if (edge === 'entered')
|
|
768
|
+
fireThreshold(el, spec.rise, { metric: spec.metric, value });
|
|
769
|
+
else if (edge === 'exited')
|
|
770
|
+
fireThreshold(el, spec.fall, { metric: spec.metric, value });
|
|
771
|
+
}
|
|
772
|
+
function updateThresholdEvents(now) {
|
|
773
|
+
for (const b of bodies) {
|
|
774
|
+
if (!b.vis || b.tokens.length === 0)
|
|
775
|
+
continue;
|
|
776
|
+
for (const spec of THRESHOLD_EVENTS) {
|
|
777
|
+
let value;
|
|
778
|
+
switch (spec.metric) {
|
|
779
|
+
case 'density':
|
|
780
|
+
value = b.d;
|
|
781
|
+
break;
|
|
782
|
+
case 'attention':
|
|
783
|
+
// conserved-attention multiplier (1 = neutral); only meaningful while attention is on.
|
|
784
|
+
if (cfg.attention)
|
|
785
|
+
value = b.attn ?? 1;
|
|
786
|
+
break;
|
|
787
|
+
case 'entropy':
|
|
788
|
+
value = b.metrics?.entropy;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
if (value === undefined)
|
|
792
|
+
continue;
|
|
793
|
+
dispatchBodyThreshold(bodyThresholds, b, b.el, spec, value, now);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// relationship memory (addEdge agents, §2.7): a remembered edge crosses its memory threshold.
|
|
797
|
+
// The event dispatches on the source body's element so an app can react to "this relationship is
|
|
798
|
+
// now durably remembered" / "…has faded". (For programmatic addBody bodies the element is the
|
|
799
|
+
// internal synthetic stub; a DOM-backed edge surfaces it on a real node.)
|
|
800
|
+
for (const e of programmaticEdges) {
|
|
801
|
+
dispatchBodyThreshold(edgeThresholds, e.agent, e.from.el, { metric: 'memory', enter: 0.6, exit: 0.3, rise: 'field:memory-threshold', fall: 'field:memory-faded' }, e.agent.memory, now);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
569
804
|
// release any DOM elements a sink had docked (§22.3) — restore their transform + a11y, fire
|
|
570
805
|
// field:released. Called from supernova so element capture is conserved like particle capture.
|
|
571
806
|
function undockFrom(b) {
|
|
@@ -588,7 +823,7 @@ export function createField(canvas, opts = {}) {
|
|
|
588
823
|
// self-laying-out repulsion (Concept 3) sees where everything actually sits this frame.
|
|
589
824
|
const centers = movers.map((mv) => {
|
|
590
825
|
const r = mv.el.getBoundingClientRect();
|
|
591
|
-
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
826
|
+
return { x: r.left - originX + r.width / 2, y: r.top - originY + r.height / 2 }; // container-local (#540)
|
|
592
827
|
});
|
|
593
828
|
for (let i = 0; i < movers.length; i++) {
|
|
594
829
|
const mv = movers[i];
|
|
@@ -644,8 +879,9 @@ export function createField(canvas, opts = {}) {
|
|
|
644
879
|
// Concept 3 — self-laying-out: this element pushes off the others and drifts off
|
|
645
880
|
// dense field regions, so a cluster spreads and re-settles (e.g. on resize).
|
|
646
881
|
if (mv.layout) {
|
|
647
|
-
|
|
648
|
-
|
|
882
|
+
// #530: pass the shared centers + this index instead of allocating `centers.filter(j !== i)`
|
|
883
|
+
// every frame per mover (was O(movers²) arrays/frame on a self-laying-out cluster).
|
|
884
|
+
const rep = repelForce({ x: cx, y: cy }, centers, i);
|
|
649
885
|
const press = densityPush((sx, sy) => store.near(sx, sy, 40).length, cx, cy, 16, 6);
|
|
650
886
|
fx += rep.x + press.x;
|
|
651
887
|
fy += rep.y + press.y;
|
|
@@ -746,10 +982,10 @@ export function createField(canvas, opts = {}) {
|
|
|
746
982
|
for (const th of threadLinks) {
|
|
747
983
|
const ra = th.a.getBoundingClientRect();
|
|
748
984
|
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;
|
|
985
|
+
const ax = ra.left - originX + ra.width / 2; // container-local (#540)
|
|
986
|
+
const ay = ra.top - originY + ra.height / 2;
|
|
987
|
+
const bx = rb.left - originX + rb.width / 2;
|
|
988
|
+
const by = rb.top - originY + rb.height / 2;
|
|
753
989
|
const [cr, cg, cb] = th.c;
|
|
754
990
|
ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.22)`;
|
|
755
991
|
ctx.lineWidth = 1;
|
|
@@ -772,11 +1008,17 @@ export function createField(canvas, opts = {}) {
|
|
|
772
1008
|
// Size the drawing surfaces' backing stores to the current W×H (dpr-scaled). Split out of
|
|
773
1009
|
// resize() so the lazy `setRender('none' → drawing)` path can run exactly this once. With no
|
|
774
1010
|
// context (a field created with `render: 'none'`, §13.7 / #297) it is a no-op: the canvas
|
|
1011
|
+
// adaptive quality tier (#413): the QualityGovernor's 0–3 signal, applied to the engine's own
|
|
1012
|
+
// levers. A tier caps the backing-store DPR (the dominant fill lever) and, at 2+, skips the heaviest
|
|
1013
|
+
// ambient layer (the heatmap glow). Reversible — tier 0 restores the configured quality. Driven by
|
|
1014
|
+
// `setQualityTier`; the platform runtime forwards the governor's tier.
|
|
1015
|
+
let qualityTier = 0;
|
|
1016
|
+
const TIER_DPR = [Infinity, 1.5, 1.25, 1]; // effective DPR ceiling per tier, capping cfg.dprCap further
|
|
775
1017
|
// backing store stays 0×0 while W/H — the simulation space — keep tracking the viewport.
|
|
776
1018
|
function sizeSurfaces(dprRaw) {
|
|
777
1019
|
if (!ctx)
|
|
778
1020
|
return;
|
|
779
|
-
const dpr = Math.min(dprRaw || 1, cfg.dprCap);
|
|
1021
|
+
const dpr = Math.min(dprRaw || 1, cfg.dprCap, TIER_DPR[qualityTier] ?? Infinity);
|
|
780
1022
|
canvas.width = Math.floor(W * dpr);
|
|
781
1023
|
canvas.height = Math.floor(H * dpr);
|
|
782
1024
|
canvas.style.width = W + 'px';
|
|
@@ -789,6 +1031,9 @@ export function createField(canvas, opts = {}) {
|
|
|
789
1031
|
const vp = host.viewport();
|
|
790
1032
|
W = vp.width;
|
|
791
1033
|
H = vp.height;
|
|
1034
|
+
originX = vp.originX ?? 0;
|
|
1035
|
+
originY = vp.originY ?? 0;
|
|
1036
|
+
contained = vp.originX != null || vp.originY != null;
|
|
792
1037
|
sizeSurfaces(vp.dpr);
|
|
793
1038
|
env.W = W;
|
|
794
1039
|
env.H = H;
|
|
@@ -807,73 +1052,165 @@ export function createField(canvas, opts = {}) {
|
|
|
807
1052
|
function drawWaves() {
|
|
808
1053
|
const time = env.t;
|
|
809
1054
|
const STEP = 16;
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
ctx.
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1055
|
+
if (cfg.waveStyle === 'circular') {
|
|
1056
|
+
const c = resolvedWaveCenter || { x: W / 2, y: H / 2 };
|
|
1057
|
+
const maxRadius = Math.min(W, H) * 0.48;
|
|
1058
|
+
for (const w of waves) {
|
|
1059
|
+
const [cr, cg, cb] = w.color;
|
|
1060
|
+
ctx.beginPath();
|
|
1061
|
+
const dTheta = 0.08;
|
|
1062
|
+
let first = true;
|
|
1063
|
+
for (let theta = 0; theta <= 2 * Math.PI + 0.01; theta += dTheta) {
|
|
1064
|
+
const r = waveRAt(w, theta, time, maxRadius);
|
|
1065
|
+
const x = c.x + Math.cos(theta) * r;
|
|
1066
|
+
const y = c.y + Math.sin(theta) * r;
|
|
1067
|
+
if (first) {
|
|
1068
|
+
ctx.moveTo(x, y);
|
|
1069
|
+
first = false;
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
ctx.lineTo(x, y);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
ctx.closePath();
|
|
1076
|
+
const baseR = w.baseFrac * maxRadius + w.offsetY;
|
|
1077
|
+
const grad = ctx.createRadialGradient(c.x, c.y, Math.max(0, baseR - w.amp), c.x, c.y, baseR + w.amp + 80);
|
|
1078
|
+
grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(0.08 + w.depth * 0.04) * boot})`);
|
|
1079
|
+
grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
|
1080
|
+
ctx.fillStyle = grad;
|
|
1081
|
+
ctx.fill();
|
|
1082
|
+
}
|
|
1083
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
1084
|
+
for (const w of waves) {
|
|
1085
|
+
const [cr, cg, cb] = w.color;
|
|
1086
|
+
ctx.beginPath();
|
|
1087
|
+
const dTheta = 0.08;
|
|
1088
|
+
let first = true;
|
|
1089
|
+
for (let theta = 0; theta <= 2 * Math.PI + 0.01; theta += dTheta) {
|
|
1090
|
+
const r = waveRAt(w, theta, time, maxRadius);
|
|
1091
|
+
const x = c.x + Math.cos(theta) * r;
|
|
1092
|
+
const y = c.y + Math.sin(theta) * r;
|
|
1093
|
+
if (first) {
|
|
1094
|
+
ctx.moveTo(x, y);
|
|
1095
|
+
first = false;
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
ctx.lineTo(x, y);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
ctx.closePath();
|
|
1102
|
+
ctx.lineWidth = 5;
|
|
1103
|
+
ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.05 + w.depth * 0.04) * boot})`;
|
|
1104
|
+
ctx.stroke();
|
|
1105
|
+
ctx.lineWidth = 1.2;
|
|
1106
|
+
ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.3 + w.depth * 0.22) * boot})`;
|
|
1107
|
+
ctx.stroke();
|
|
1108
|
+
}
|
|
1109
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
825
1110
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1111
|
+
else {
|
|
1112
|
+
for (const w of waves) {
|
|
1113
|
+
const [cr, cg, cb] = w.color;
|
|
1114
|
+
ctx.beginPath();
|
|
1115
|
+
ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
|
|
1116
|
+
for (let x = 0; x <= W; x += STEP)
|
|
1117
|
+
ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
|
|
1118
|
+
ctx.lineTo(W, H);
|
|
1119
|
+
ctx.lineTo(0, H);
|
|
1120
|
+
ctx.closePath();
|
|
1121
|
+
const ty = w.baseFrac * H + w.offsetY - w.amp;
|
|
1122
|
+
const grad = ctx.createLinearGradient(0, ty, 0, ty + 320);
|
|
1123
|
+
grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(0.11 + w.depth * 0.05) * boot})`);
|
|
1124
|
+
grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
|
1125
|
+
ctx.fillStyle = grad;
|
|
1126
|
+
ctx.fill();
|
|
1127
|
+
}
|
|
1128
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
1129
|
+
for (const w of waves) {
|
|
1130
|
+
const [cr, cg, cb] = w.color;
|
|
1131
|
+
ctx.beginPath();
|
|
1132
|
+
ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
|
|
1133
|
+
for (let x = 0; x <= W; x += STEP)
|
|
1134
|
+
ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
|
|
1135
|
+
ctx.lineWidth = 5;
|
|
1136
|
+
ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.05 + w.depth * 0.04) * boot})`;
|
|
1137
|
+
ctx.stroke();
|
|
1138
|
+
ctx.lineWidth = 1.2;
|
|
1139
|
+
ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.3 + w.depth * 0.22) * boot})`;
|
|
1140
|
+
ctx.stroke();
|
|
1141
|
+
}
|
|
1142
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
841
1143
|
}
|
|
842
|
-
ctx.globalCompositeOperation = 'source-over';
|
|
843
1144
|
}
|
|
844
1145
|
function drawBound() {
|
|
845
1146
|
ctx.globalCompositeOperation = 'lighter';
|
|
846
1147
|
const time = env.t;
|
|
847
1148
|
let i = 0;
|
|
848
|
-
|
|
849
|
-
const
|
|
850
|
-
|
|
1149
|
+
if (cfg.waveStyle === 'circular') {
|
|
1150
|
+
const c = resolvedWaveCenter || { x: W / 2, y: H / 2 };
|
|
1151
|
+
const maxRadius = Math.min(W, H) * 0.48;
|
|
1152
|
+
for (const p of bound) {
|
|
1153
|
+
const w = waves[p.wi];
|
|
1154
|
+
if (!w) {
|
|
1155
|
+
i++;
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
if (env.dt) {
|
|
1159
|
+
p.progress += p.speed;
|
|
1160
|
+
if (p.progress > 1)
|
|
1161
|
+
p.progress -= 1;
|
|
1162
|
+
else if (p.progress < 0)
|
|
1163
|
+
p.progress += 1;
|
|
1164
|
+
}
|
|
1165
|
+
const theta = p.progress * 2 * Math.PI;
|
|
1166
|
+
const r = waveRAt(w, theta, time, maxRadius) + p.phase * 32;
|
|
1167
|
+
const x = c.x + Math.cos(theta) * r;
|
|
1168
|
+
const y = c.y + Math.sin(theta) * r;
|
|
1169
|
+
const [cr, cg, cb] = w.color;
|
|
1170
|
+
const tw = p.glow ? 0.6 + 0.4 * Math.sin(time * 2.2 + i) : 0.85;
|
|
1171
|
+
if (p.glow) {
|
|
1172
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.16 * tw * boot})`;
|
|
1173
|
+
ctx.beginPath();
|
|
1174
|
+
ctx.arc(x, y, p.size + 2.5, 0, 6.28318);
|
|
1175
|
+
ctx.fill();
|
|
1176
|
+
}
|
|
1177
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
|
|
1178
|
+
ctx.beginPath();
|
|
1179
|
+
ctx.arc(x, y, p.size, 0, 6.28318);
|
|
1180
|
+
ctx.fill();
|
|
851
1181
|
i++;
|
|
852
|
-
continue;
|
|
853
1182
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1183
|
+
}
|
|
1184
|
+
else {
|
|
1185
|
+
for (const p of bound) {
|
|
1186
|
+
const w = waves[p.wi];
|
|
1187
|
+
if (!w) {
|
|
1188
|
+
i++;
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
if (env.dt) {
|
|
1192
|
+
p.progress += p.speed;
|
|
1193
|
+
if (p.progress > 1)
|
|
1194
|
+
p.progress -= 1;
|
|
1195
|
+
else if (p.progress < 0)
|
|
1196
|
+
p.progress += 1;
|
|
1197
|
+
}
|
|
1198
|
+
const x = p.progress * W;
|
|
1199
|
+
const y = waveYat(w, x, time, H, 1, 1, pull) + p.phase * 32;
|
|
1200
|
+
const [cr, cg, cb] = w.color;
|
|
1201
|
+
const tw = p.glow ? 0.6 + 0.4 * Math.sin(time * 2.2 + i) : 0.85;
|
|
1202
|
+
if (p.glow) {
|
|
1203
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.16 * tw * boot})`;
|
|
1204
|
+
ctx.beginPath();
|
|
1205
|
+
ctx.arc(x, y, p.size + 2.5, 0, 6.28318);
|
|
1206
|
+
ctx.fill();
|
|
1207
|
+
}
|
|
1208
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
|
|
868
1209
|
ctx.beginPath();
|
|
869
|
-
ctx.arc(x, y, p.size
|
|
1210
|
+
ctx.arc(x, y, p.size, 0, 6.28318);
|
|
870
1211
|
ctx.fill();
|
|
1212
|
+
i++;
|
|
871
1213
|
}
|
|
872
|
-
ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
|
|
873
|
-
ctx.beginPath();
|
|
874
|
-
ctx.arc(x, y, p.size, 0, 6.28318);
|
|
875
|
-
ctx.fill();
|
|
876
|
-
i++;
|
|
877
1214
|
}
|
|
878
1215
|
ctx.globalCompositeOperation = 'source-over';
|
|
879
1216
|
}
|
|
@@ -1003,6 +1340,14 @@ export function createField(canvas, opts = {}) {
|
|
|
1003
1340
|
function drawHeatmap() {
|
|
1004
1341
|
if (!heatmap)
|
|
1005
1342
|
return;
|
|
1343
|
+
// Fade the ambient glow out as the page scrolls past the hero (≈ the first viewport). Unlike the
|
|
1344
|
+
// earlier velocity-based suppression (which popped in/out and read as choppy), this is a smooth,
|
|
1345
|
+
// MONOTONIC function of scroll POSITION — full through the top of the page, gone by ~1.15 viewports
|
|
1346
|
+
// — so it never flickers; and below the hero the whole layer is skipped (the #409 at-rest upscale
|
|
1347
|
+
// cost the heatmap is otherwise paying every frame for a glow you can't focus on mid-page).
|
|
1348
|
+
const hmFade = H > 0 ? clamp((1.15 - lastScrollY / H) / 0.85, 0, 1) : 1;
|
|
1349
|
+
if (hmFade <= 0.01)
|
|
1350
|
+
return;
|
|
1006
1351
|
const cell = heatmap.cell;
|
|
1007
1352
|
const cols = Math.max(1, Math.ceil(W / cell));
|
|
1008
1353
|
const rows = Math.max(1, Math.ceil(H / cell));
|
|
@@ -1023,7 +1368,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1023
1368
|
if (hmImg === null || frameN % 3 === 0) {
|
|
1024
1369
|
if (hmImg === null)
|
|
1025
1370
|
hmImg = hmCtx.createImageData(cols, rows);
|
|
1026
|
-
const acc = hexToRgb(cfg.accent)
|
|
1371
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1027
1372
|
const data = hmImg.data;
|
|
1028
1373
|
for (let r = 0; r < rows; r++) {
|
|
1029
1374
|
for (let c = 0; c < cols; c++) {
|
|
@@ -1039,7 +1384,9 @@ export function createField(canvas, opts = {}) {
|
|
|
1039
1384
|
}
|
|
1040
1385
|
ctx.globalCompositeOperation = 'lighter';
|
|
1041
1386
|
ctx.imageSmoothingEnabled = true;
|
|
1387
|
+
ctx.globalAlpha = hmFade; // fade with scroll position (computed above)
|
|
1042
1388
|
ctx.drawImage(hmCanvas, 0, 0, W, H); // bilinear upscale → smooth glow
|
|
1389
|
+
ctx.globalAlpha = 1;
|
|
1043
1390
|
ctx.globalCompositeOperation = 'source-over';
|
|
1044
1391
|
}
|
|
1045
1392
|
function render() {
|
|
@@ -1072,8 +1419,8 @@ export function createField(canvas, opts = {}) {
|
|
|
1072
1419
|
// whenever enabled. (An earlier scroll-suppression made it pop/fade out while scrolling, which
|
|
1073
1420
|
// read as choppy; the perf intent is served instead by the compute throttle — the texel grid is
|
|
1074
1421
|
// recomputed only every 3rd frame — so the per-frame cost is just the cached bilinear upscale.)
|
|
1075
|
-
if (heatmap)
|
|
1076
|
-
drawHeatmap();
|
|
1422
|
+
if (heatmap && qualityTier < 2)
|
|
1423
|
+
drawHeatmap(); // #413: drop the heaviest ambient layer at tier 2+
|
|
1077
1424
|
drawBound();
|
|
1078
1425
|
// free particles — cool centre → warm edge, blended toward accent (§20.8).
|
|
1079
1426
|
// metaballs (a molten iso-surface skin) and streamlines (the bare force field) REPLACE
|
|
@@ -1081,10 +1428,27 @@ export function createField(canvas, opts = {}) {
|
|
|
1081
1428
|
// keep it (their overlays read against the particles).
|
|
1082
1429
|
const showMatter = cfg.render !== 'metaballs' && cfg.render !== 'streamlines';
|
|
1083
1430
|
ctx.globalCompositeOperation = 'lighter';
|
|
1084
|
-
const acc = hexToRgb(cfg.accent)
|
|
1431
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1085
1432
|
const cx = W / 2;
|
|
1086
1433
|
const cy = H * 0.4;
|
|
1087
1434
|
const maxD = Math.hypot(Math.max(cx, W - cx), Math.max(cy, H - cy)) || 1;
|
|
1435
|
+
// Tag-tint: every body carrying a colour stains the swarm toward its tint by proximity — a
|
|
1436
|
+
// pervasive, render-time companion to the overlap-only `pigment` force, so a particle near a
|
|
1437
|
+
// tagged body wears its tag's hue even on a sparse field. Few coloured bodies, precomputed once
|
|
1438
|
+
// here; nearest-strongest wins per particle so the hues don't muddy. Reach is a touch past the
|
|
1439
|
+
// force range so the colour reads before matter actually arrives.
|
|
1440
|
+
// Rebuild the RGB cache only on the measure cadence (every 6th frame) or first frame — hexToRgb is
|
|
1441
|
+
// the churn (#515). Positions are NOT cached: the loop below reads each body's live cx/cy.
|
|
1442
|
+
if (tintCache === null || frameN % 6 === 0) {
|
|
1443
|
+
tintCache = [];
|
|
1444
|
+
for (const tb of bodies) {
|
|
1445
|
+
if (!tb.tint)
|
|
1446
|
+
continue;
|
|
1447
|
+
const reach = (tb.range || 200) * 1.4;
|
|
1448
|
+
tintCache.push({ b: tb, r2: reach * reach, rgb: hexToRgb(tb.tint) });
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const tintBodies = tintCache;
|
|
1088
1452
|
if (showMatter)
|
|
1089
1453
|
for (const p of store.particles) {
|
|
1090
1454
|
// captured matter is held in orbit by the sink — drawn dim and small (the accretion's orbital
|
|
@@ -1100,10 +1464,32 @@ export function createField(canvas, opts = {}) {
|
|
|
1100
1464
|
const d = Math.min(1, Math.hypot(p.x - cx, p.y - cy) / maxD);
|
|
1101
1465
|
const rs = d * d;
|
|
1102
1466
|
const h = p.heat;
|
|
1103
|
-
particleRGBInto(_rgb, rs, h, acc);
|
|
1467
|
+
particleRGBInto(_rgb, rs, h, acc, cfg.gradientCool, cfg.gradientWarm);
|
|
1104
1468
|
let r = _rgb[0];
|
|
1105
1469
|
let g = _rgb[1];
|
|
1106
1470
|
let b = _rgb[2];
|
|
1471
|
+
if (tintBodies.length) {
|
|
1472
|
+
let bw = 0;
|
|
1473
|
+
let brgb = null;
|
|
1474
|
+
for (const tb of tintBodies) {
|
|
1475
|
+
const dx = p.x - tb.b.cx; // live position — scroll-compensated every frame (#508)
|
|
1476
|
+
const dy = p.y - tb.b.cy;
|
|
1477
|
+
const dd2 = dx * dx + dy * dy;
|
|
1478
|
+
if (dd2 >= tb.r2)
|
|
1479
|
+
continue;
|
|
1480
|
+
const w = 1 - Math.sqrt(dd2 / tb.r2); // linear falloff, 1 at centre → 0 at reach
|
|
1481
|
+
if (w > bw) {
|
|
1482
|
+
bw = w;
|
|
1483
|
+
brgb = tb.rgb;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (brgb) {
|
|
1487
|
+
const k = bw * 0.7; // tint strength at the body centre
|
|
1488
|
+
r += (brgb[0] - r) * k;
|
|
1489
|
+
g += (brgb[1] - g) * k;
|
|
1490
|
+
b += (brgb[2] - b) * k;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1107
1493
|
if (p.color) {
|
|
1108
1494
|
// carried pigment (§20.8): a stained particle reads mostly as its own tint.
|
|
1109
1495
|
const [pr, pg, pb] = hexToRgb(p.color);
|
|
@@ -1139,7 +1525,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1139
1525
|
ctx.globalCompositeOperation = 'source-over';
|
|
1140
1526
|
if (cfg.render === 'links') {
|
|
1141
1527
|
ctx.globalCompositeOperation = 'lighter';
|
|
1142
|
-
const acc = hexToRgb(cfg.accent)
|
|
1528
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1143
1529
|
const R = 90;
|
|
1144
1530
|
ctx.lineWidth = 0.6;
|
|
1145
1531
|
for (const p of store.particles) {
|
|
@@ -1179,7 +1565,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1179
1565
|
continue;
|
|
1180
1566
|
splatDensity(mball, cols, rows, STEP, p.x, p.y, RAD, 1);
|
|
1181
1567
|
}
|
|
1182
|
-
const acc = hexToRgb(cfg.accent)
|
|
1568
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1183
1569
|
ctx.globalCompositeOperation = 'lighter';
|
|
1184
1570
|
ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.5 * boot})`;
|
|
1185
1571
|
ctx.lineWidth = 1.4;
|
|
@@ -1233,7 +1619,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1233
1619
|
vor[gy * cols + gx] = owner;
|
|
1234
1620
|
}
|
|
1235
1621
|
}
|
|
1236
|
-
const acc = hexToRgb(cfg.accent)
|
|
1622
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1237
1623
|
ctx.globalCompositeOperation = 'lighter';
|
|
1238
1624
|
ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.32 * boot})`;
|
|
1239
1625
|
ctx.lineWidth = 1;
|
|
@@ -1251,7 +1637,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1251
1637
|
// particles plus the flow they ride, in this one underlay canvas (no second surface, no blend).
|
|
1252
1638
|
if (cfg.render === 'streamlines' || cfg.render === 'flow') {
|
|
1253
1639
|
const GRID = 46;
|
|
1254
|
-
const acc = hexToRgb(cfg.accent)
|
|
1640
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1255
1641
|
ctx.lineWidth = 1;
|
|
1256
1642
|
ctx.lineCap = 'round';
|
|
1257
1643
|
// RESAMPLE the field on a cadence, not every frame. The arrows trace the body-induced force
|
|
@@ -1343,41 +1729,51 @@ export function createField(canvas, opts = {}) {
|
|
|
1343
1729
|
// unused by the current dispatch (both arrow modes read the felt field).
|
|
1344
1730
|
function drawOverlayArrows(out, structure, absolute) {
|
|
1345
1731
|
const GRID = 44;
|
|
1346
|
-
const acc = hexToRgb(cfg.accent)
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1732
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1733
|
+
// RESAMPLE the field on a cadence, not every frame (mirrors the underlay slSamples cache, #406).
|
|
1734
|
+
// The overlay grid is the same body-induced force field — it only shifts on the measure cadence
|
|
1735
|
+
// (every 6th frame) or while a flow focus animates — so a per-frame regrid (≈grid×bodies force
|
|
1736
|
+
// evals) was the same wasted work the underlay shed, surfacing as scroll jank when a Field-
|
|
1737
|
+
// Surfaces reading is live. Resample every 3rd frame (or empty cache / live flow); the EMA scale
|
|
1738
|
+
// updates with it. DRAW from the cache every frame below, so the arrows never flicker or step.
|
|
1739
|
+
if (olSamples === null || flow || frameN % 3 === 0) {
|
|
1740
|
+
const samples = [];
|
|
1741
|
+
let frameMax = 0;
|
|
1742
|
+
for (let gx = GRID / 2; gx < W; gx += GRID) {
|
|
1743
|
+
for (let gy = GRID / 2; gy < H; gy += GRID) {
|
|
1744
|
+
let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
|
|
1745
|
+
if (flow) {
|
|
1746
|
+
const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
|
|
1747
|
+
fx += b.x;
|
|
1748
|
+
fy += b.y;
|
|
1749
|
+
}
|
|
1750
|
+
const mag = Math.hypot(fx, fy);
|
|
1751
|
+
if (!(mag > 1e-9))
|
|
1752
|
+
continue; // skip dead zones / NaN
|
|
1753
|
+
samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
|
|
1754
|
+
if (mag > frameMax)
|
|
1755
|
+
frameMax = mag;
|
|
1356
1756
|
}
|
|
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
1757
|
}
|
|
1758
|
+
// Same EMA approach as the underlay streamlines (see slMaxSmoothed) — independent state so
|
|
1759
|
+
// the overlay scale never couples to the underlay's field strength. Updated with the resample.
|
|
1760
|
+
if (olMaxSmoothed === 0)
|
|
1761
|
+
olMaxSmoothed = frameMax;
|
|
1762
|
+
else
|
|
1763
|
+
olMaxSmoothed = frameMax > olMaxSmoothed
|
|
1764
|
+
? olMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
|
|
1765
|
+
: olMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
|
|
1766
|
+
olSamples = samples;
|
|
1364
1767
|
}
|
|
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
1768
|
if (olMaxSmoothed <= 0)
|
|
1374
1769
|
return;
|
|
1375
1770
|
// one backend call per arrow: shaft + two head strokes packed as three segments. Alpha varies
|
|
1376
1771
|
// 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.
|
|
1772
|
+
// call count matches the previous per-arrow beginPath/stroke exactly. `acc` is read every frame
|
|
1773
|
+
// (outside the resample) so a live setAccent recolors the cached arrows immediately.
|
|
1378
1774
|
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.2 };
|
|
1379
1775
|
const seg = new Float64Array(12);
|
|
1380
|
-
for (const s of
|
|
1776
|
+
for (const s of olSamples) {
|
|
1381
1777
|
const rel = absolute ? clamp(s.mag / olMaxSmoothed, 0, 1) : Math.sqrt(s.mag / olMaxSmoothed);
|
|
1382
1778
|
const len = GRID * 0.5 * (0.25 + 0.75 * rel);
|
|
1383
1779
|
const ex = s.gx + s.ux * len;
|
|
@@ -1415,7 +1811,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1415
1811
|
bounds: { w: W, h: H },
|
|
1416
1812
|
loopDist: 8,
|
|
1417
1813
|
});
|
|
1418
|
-
const acc = hexToRgb(cfg.accent)
|
|
1814
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1419
1815
|
// one polyline per traced curve through the backend seam (#373) — same shared stroke style.
|
|
1420
1816
|
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0.42, width: 1.1 };
|
|
1421
1817
|
for (const line of lines) {
|
|
@@ -1432,8 +1828,12 @@ export function createField(canvas, opts = {}) {
|
|
|
1432
1828
|
// `grid` — a reference lattice whose vertices are displaced along the felt field; the page's
|
|
1433
1829
|
// space itself made visible, bending where the field is strong. Reads deformation.
|
|
1434
1830
|
function drawOverlayGrid(out) {
|
|
1435
|
-
const STEP =
|
|
1436
|
-
|
|
1831
|
+
const STEP = 48; // roomy cells so each mass can dimple the lattice into a deep, legible bowl; the
|
|
1832
|
+
// CLAMP below keeps even a deep bowl foldless (denser than the field-line default still reads fine).
|
|
1833
|
+
const MAXD = 22 * cfg.gridWarp; // px displacement the field asks for at the strongest sample; the
|
|
1834
|
+
// CLAMP below is what actually keeps it legible, so gridWarp drives how fast that ceiling is reached.
|
|
1835
|
+
const CLAMP = STEP * 0.46; // a vertex can move at most ~half a cell, so adjacent lines bend hard but
|
|
1836
|
+
// can NEVER cross — the displaced grid always reads as deep curved space, never a tangle.
|
|
1437
1837
|
const cols = Math.floor(W / STEP) + 2;
|
|
1438
1838
|
const rows = Math.floor(H / STEP) + 2;
|
|
1439
1839
|
const dx = new Float32Array(cols * rows);
|
|
@@ -1454,25 +1854,124 @@ export function createField(canvas, opts = {}) {
|
|
|
1454
1854
|
}
|
|
1455
1855
|
}
|
|
1456
1856
|
}
|
|
1457
|
-
|
|
1458
|
-
|
|
1857
|
+
// Heatmap-coloured lattice: each segment is tinted by how hard the field warps it there — cool
|
|
1858
|
+
// accent where space is flat, through warm orange, to white-hot at the strongest deflection (the
|
|
1859
|
+
// mass wells). Opacity rises with the deflection too, so flat space is a faint base lattice and the
|
|
1860
|
+
// warped regions blaze — the spacetime-curvature image made literal. Consecutive same-band segments
|
|
1861
|
+
// are merged into one polyline, so the colouring costs a handful of extra strokes, not one per cell.
|
|
1862
|
+
// neon ramp: accent blue (flat space) → electric violet → neon magenta → hot neon pink (the wells).
|
|
1863
|
+
// curAccent (#530) is the cached accent RGB — the cool stop the warped regions heat away from.
|
|
1864
|
+
const HEAT = [curAccent, [120, 110, 255], [240, 70, 255], [255, 50, 130]];
|
|
1865
|
+
const BANDS = 8;
|
|
1866
|
+
const relAt = (i) => (maxMag > 0 ? Math.sqrt(mags[i] / maxMag) : 0);
|
|
1867
|
+
const bandAt = (i) => Math.round(relAt(i) * BANDS);
|
|
1868
|
+
const strokeFor = (b) => {
|
|
1869
|
+
const rel = b / BANDS;
|
|
1870
|
+
const c = sampleStops(HEAT, rel);
|
|
1871
|
+
// cool flat space sits at ~30% of the configured intensity; the hottest cells reach it in full.
|
|
1872
|
+
return { r: c[0], g: c[1], b: c[2], alpha: Math.min(1, cfg.gridIntensity * (0.3 + 0.85 * rel)), width: rel > 0.55 ? 1.4 : 1 };
|
|
1873
|
+
};
|
|
1874
|
+
// Build the raw displacement vector per vertex (direction × normalized strength × throw), then box-
|
|
1875
|
+
// blur it once so neighbouring vertices move together — a rubber sheet dimpling, not per-vertex
|
|
1876
|
+
// jitter. Blurring the displacement (not the field) is what turns the knot at each mass into a smooth
|
|
1877
|
+
// bowl: the inward pull is averaged with its calmer neighbours, so nothing collapses to a point.
|
|
1878
|
+
const wx = new Float32Array(cols * rows);
|
|
1879
|
+
const wy = new Float32Array(cols * rows);
|
|
1880
|
+
for (let i = 0; i < cols * rows; i++) {
|
|
1881
|
+
const rel = relAt(i);
|
|
1882
|
+
wx[i] = dx[i] * rel * MAXD;
|
|
1883
|
+
wy[i] = dy[i] * rel * MAXD;
|
|
1884
|
+
}
|
|
1885
|
+
const bx = new Float32Array(cols * rows);
|
|
1886
|
+
const by = new Float32Array(cols * rows);
|
|
1887
|
+
for (let gy = 0; gy < rows; gy++) {
|
|
1888
|
+
for (let gx = 0; gx < cols; gx++) {
|
|
1889
|
+
let sx = 0, sy = 0, n = 0;
|
|
1890
|
+
for (let oy = -1; oy <= 1; oy++) {
|
|
1891
|
+
for (let ox = -1; ox <= 1; ox++) {
|
|
1892
|
+
const x = gx + ox, y = gy + oy;
|
|
1893
|
+
if (x < 0 || y < 0 || x >= cols || y >= rows)
|
|
1894
|
+
continue;
|
|
1895
|
+
const j = y * cols + x;
|
|
1896
|
+
sx += wx[j];
|
|
1897
|
+
sy += wy[j];
|
|
1898
|
+
n++;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
const i = gy * cols + gx;
|
|
1902
|
+
bx[i] = sx / n;
|
|
1903
|
+
by[i] = sy / n;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
const FADE = STEP * 1.5; // taper the warp to zero within ~1.5 cells of each viewport edge, so the
|
|
1907
|
+
// lattice always frames the screen instead of pulling away from the edges under a strong inward warp.
|
|
1459
1908
|
const px = (gx, gy) => {
|
|
1460
1909
|
const i = gy * cols + gx;
|
|
1461
|
-
|
|
1462
|
-
|
|
1910
|
+
let ux = bx[i], uy = by[i];
|
|
1911
|
+
const m = Math.hypot(ux, uy);
|
|
1912
|
+
if (m > CLAMP) {
|
|
1913
|
+
ux = (ux / m) * CLAMP;
|
|
1914
|
+
uy = (uy / m) * CLAMP;
|
|
1915
|
+
} // half-cell ceiling: never fold.
|
|
1916
|
+
const px0 = gx * STEP, py0 = gy * STEP;
|
|
1917
|
+
const e = Math.min(px0, W - px0, py0, H - py0); // distance to the nearest viewport edge
|
|
1918
|
+
const k = e <= 0 ? 0 : e < FADE ? e / FADE : 1; // pin the boundary (k=0), ease to full warp inward
|
|
1919
|
+
return [px0 + ux * k, py0 + uy * k];
|
|
1920
|
+
};
|
|
1921
|
+
// Smooth the warped lines into curves: Catmull-Rom through the displaced vertices (the curve passes
|
|
1922
|
+
// through every original point, so the warp magnitude is preserved) — the lattice reads as bent
|
|
1923
|
+
// space, not faceted segments. Single-segment runs (n < 3) are already straight, returned as-is.
|
|
1924
|
+
const SUB = 4;
|
|
1925
|
+
const smooth = (p) => {
|
|
1926
|
+
const n = p.length >> 1;
|
|
1927
|
+
if (n < 3)
|
|
1928
|
+
return p;
|
|
1929
|
+
const o = [p[0], p[1]];
|
|
1930
|
+
for (let i = 0; i < n - 1; i++) {
|
|
1931
|
+
const a = (i > 0 ? i - 1 : 0) << 1;
|
|
1932
|
+
const b = i << 1;
|
|
1933
|
+
const c = (i + 1) << 1;
|
|
1934
|
+
const e = (i + 2 < n ? i + 2 : n - 1) << 1;
|
|
1935
|
+
const x0 = p[a], y0 = p[a + 1], x1 = p[b], y1 = p[b + 1], x2 = p[c], y2 = p[c + 1], x3 = p[e], y3 = p[e + 1];
|
|
1936
|
+
for (let s = 1; s <= SUB; s++) {
|
|
1937
|
+
const t = s / SUB, t2 = t * t, t3 = t2 * t;
|
|
1938
|
+
o.push(0.5 * (2 * x1 + (-x0 + x2) * t + (2 * x0 - 5 * x1 + 4 * x2 - x3) * t2 + (-x0 + 3 * x1 - 3 * x2 + x3) * t3), 0.5 * (2 * y1 + (-y0 + y2) * t + (2 * y0 - 5 * y1 + 4 * y2 - y3) * t2 + (-y0 + 3 * y1 - 3 * y2 + y3) * t3));
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
return o;
|
|
1463
1942
|
};
|
|
1464
|
-
|
|
1943
|
+
// a segment is as hot as its hottest endpoint, so a line passing a well lights up across the well.
|
|
1944
|
+
const hSeg = (gy, gx) => Math.max(bandAt(gy * cols + gx), bandAt(gy * cols + gx + 1));
|
|
1945
|
+
const vSeg = (gx, gy) => Math.max(bandAt(gy * cols + gx), bandAt((gy + 1) * cols + gx));
|
|
1465
1946
|
for (let gy = 0; gy < rows; gy++) {
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1947
|
+
let band = hSeg(gy, 0);
|
|
1948
|
+
let pts = [...px(0, gy), ...px(1, gy)];
|
|
1949
|
+
for (let gx = 1; gx < cols - 1; gx++) {
|
|
1950
|
+
const sb = hSeg(gy, gx);
|
|
1951
|
+
if (sb === band)
|
|
1952
|
+
pts.push(...px(gx + 1, gy));
|
|
1953
|
+
else {
|
|
1954
|
+
out.polyline(smooth(pts), strokeFor(band));
|
|
1955
|
+
pts = [...px(gx, gy), ...px(gx + 1, gy)];
|
|
1956
|
+
band = sb;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
out.polyline(smooth(pts), strokeFor(band));
|
|
1470
1960
|
}
|
|
1471
1961
|
for (let gx = 0; gx < cols; gx++) {
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1962
|
+
let band = vSeg(gx, 0);
|
|
1963
|
+
let pts = [...px(gx, 0), ...px(gx, 1)];
|
|
1964
|
+
for (let gy = 1; gy < rows - 1; gy++) {
|
|
1965
|
+
const sb = vSeg(gx, gy);
|
|
1966
|
+
if (sb === band)
|
|
1967
|
+
pts.push(...px(gx, gy + 1));
|
|
1968
|
+
else {
|
|
1969
|
+
out.polyline(smooth(pts), strokeFor(band));
|
|
1970
|
+
pts = [...px(gx, gy), ...px(gx, gy + 1)];
|
|
1971
|
+
band = sb;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
out.polyline(smooth(pts), strokeFor(band));
|
|
1476
1975
|
}
|
|
1477
1976
|
}
|
|
1478
1977
|
// shared scalar-contour pass for `temperature` and `energy` — splat a per-particle scalar onto a
|
|
@@ -1506,7 +2005,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1506
2005
|
max = oscalar[i];
|
|
1507
2006
|
if (max <= 0)
|
|
1508
2007
|
return;
|
|
1509
|
-
const acc = hexToRgb(cfg.accent)
|
|
2008
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1510
2009
|
const LEVELS = [0.25, 0.5, 0.78]; // nested iso-rings: faint outer shell → bright core
|
|
1511
2010
|
const packed = [];
|
|
1512
2011
|
for (let li = 0; li < LEVELS.length; li++) {
|
|
@@ -1544,7 +2043,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1544
2043
|
const SEED = 104; // seed lattice spacing (px)
|
|
1545
2044
|
const STEPPX = 9; // integration step (px)
|
|
1546
2045
|
const STEPS = 24; // max steps per path
|
|
1547
|
-
const acc = hexToRgb(cfg.accent)
|
|
2046
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1548
2047
|
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.1 };
|
|
1549
2048
|
const seg = new Float64Array(4);
|
|
1550
2049
|
for (let sx = SEED / 2; sx < W; sx += SEED) {
|
|
@@ -1582,7 +2081,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1582
2081
|
// (§8, the same number the platform mirrors to `--d`) printed beside the body. Feedback bodies
|
|
1583
2082
|
// lead (they asked to be measured); non-feedback bodies are skipped — no reading, no chip.
|
|
1584
2083
|
function drawOverlayData(out) {
|
|
1585
|
-
const acc = hexToRgb(cfg.accent)
|
|
2084
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1586
2085
|
for (const b of bodies) {
|
|
1587
2086
|
if (!b.vis || !b.feedback)
|
|
1588
2087
|
continue;
|
|
@@ -1614,6 +2113,10 @@ export function createField(canvas, opts = {}) {
|
|
|
1614
2113
|
drawOverlayPaths(out);
|
|
1615
2114
|
else if (mode === 'data')
|
|
1616
2115
|
drawOverlayData(out);
|
|
2116
|
+
// custom overlays registered via registerOverlay()
|
|
2117
|
+
const customFn = customOverlays.get(mode);
|
|
2118
|
+
if (customFn)
|
|
2119
|
+
customFn(out, env, W, H);
|
|
1617
2120
|
}
|
|
1618
2121
|
}
|
|
1619
2122
|
function frame(now) {
|
|
@@ -1634,20 +2137,48 @@ export function createField(canvas, opts = {}) {
|
|
|
1634
2137
|
if (boot < 1)
|
|
1635
2138
|
boot = Math.min(1, boot + 0.012);
|
|
1636
2139
|
easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
|
|
2140
|
+
// a contained field re-reads its origin each frame (the container's viewport position shifts as the
|
|
2141
|
+
// page scrolls); cheap — one getBoundingClientRect for the container, only when contained.
|
|
2142
|
+
if (contained) {
|
|
2143
|
+
const vp = host.viewport();
|
|
2144
|
+
originX = vp.originX ?? 0;
|
|
2145
|
+
originY = vp.originY ?? 0;
|
|
2146
|
+
}
|
|
1637
2147
|
const scrollY = host.scrollY();
|
|
2148
|
+
const dScroll = scrollY - lastScrollY;
|
|
1638
2149
|
// eased page-scroll speed for the `scrolling` data-when gate (§5).
|
|
1639
|
-
env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(
|
|
2150
|
+
env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(dScroll) * 0.3;
|
|
1640
2151
|
lastScrollY = scrollY;
|
|
2152
|
+
// Scroll-compensate the cached body centres between the every-6th-frame re-measures. The page
|
|
2153
|
+
// scrolls continuously under the fixed field, so each body's viewport position shifts every frame
|
|
2154
|
+
// even though getBoundingClientRect only runs on the measure cadence — without this the attractors
|
|
2155
|
+
// snap in 6-frame steps during scroll and the swarm reads as "pausing". Cheap and drift-free: the
|
|
2156
|
+
// elements don't move in the document between measures, so the only delta is scroll, and
|
|
2157
|
+
// measureBodies refreshes from the real rects on its own cadence. cy carries the shaped box too
|
|
2158
|
+
// (it is centred on cy ± hh). Window fields only: a contained field's local positions are
|
|
2159
|
+
// scroll-invariant (container + contents move together), so this shift would corrupt them (#540).
|
|
2160
|
+
if (dScroll !== 0 && !contained)
|
|
2161
|
+
for (const b of bodies)
|
|
2162
|
+
b.cy -= dScroll;
|
|
1641
2163
|
for (const w of waves) {
|
|
1642
2164
|
const target = scrollY * (0.025 + w.depth * 0.08); // wave parallax (§24)
|
|
1643
2165
|
w.offsetY += (target - w.offsetY) * 0.04;
|
|
1644
2166
|
}
|
|
1645
2167
|
if (bodies.length && frameN % 6 === 0) {
|
|
1646
|
-
measureBodies(bodies, W, H);
|
|
2168
|
+
measureBodies(bodies, W, H, originX, originY);
|
|
2169
|
+
detectProximityEvents(bodies); // #441 enter/exit/met — on the measure cadence, lazy
|
|
1647
2170
|
// attention-gated discharge (#365): an engagement-gated sink releases on the falling
|
|
1648
2171
|
// edge of engagement — the same conserved supernova ritual as saturation.
|
|
1649
2172
|
dischargeDisengaged(bodies, env.supernova);
|
|
1650
2173
|
}
|
|
2174
|
+
// Step programmatic edges (addEdge): a relationship is "active" while its source body is salient
|
|
2175
|
+
// (gathering matter, d > 0.08) — it then strengthens + accumulates memory, and decays while idle.
|
|
2176
|
+
// env.dt is frame-normalized (≈1 at 60fps); the dynamics rates are per-second, so convert (÷60).
|
|
2177
|
+
if (programmaticEdges.length && env.dt) {
|
|
2178
|
+
const dtS = env.dt / 60;
|
|
2179
|
+
for (const e of programmaticEdges)
|
|
2180
|
+
updateRelationship(e.agent, e.from.d > 0.08, 0, dtS);
|
|
2181
|
+
}
|
|
1651
2182
|
// spine: ease the wave-bend toward the flow focus (if set) or the engaged element (§24). A live
|
|
1652
2183
|
// flow focus (field.flowTo) takes priority, so the streamline spine curves to the moving target.
|
|
1653
2184
|
let engaged = null;
|
|
@@ -1691,8 +2222,25 @@ export function createField(canvas, opts = {}) {
|
|
|
1691
2222
|
p.vy += b.y;
|
|
1692
2223
|
}
|
|
1693
2224
|
}
|
|
2225
|
+
if (cfg.waveStyle === 'circular') {
|
|
2226
|
+
if (cfg.waveCenter) {
|
|
2227
|
+
resolvedWaveCenter = typeof cfg.waveCenter === 'function' ? cfg.waveCenter() : cfg.waveCenter;
|
|
2228
|
+
}
|
|
2229
|
+
else {
|
|
2230
|
+
const star = bodies.find((b) => b.tokens.includes('star') || b.tokens.includes('vortex'));
|
|
2231
|
+
if (star) {
|
|
2232
|
+
resolvedWaveCenter = { x: star.cx, y: star.cy };
|
|
2233
|
+
}
|
|
2234
|
+
else {
|
|
2235
|
+
resolvedWaveCenter = { x: W / 2, y: H / 2 };
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
else {
|
|
2240
|
+
resolvedWaveCenter = null;
|
|
2241
|
+
}
|
|
1694
2242
|
updateWarpTargets(); // refresh warp relocate targets from paired bodies (§22.3) before the step
|
|
1695
|
-
step({ store, bodies, env, forces: reg.forces, conditions: reg.conditions, waves });
|
|
2243
|
+
step({ store, bodies, env, forces: reg.forces, conditions: reg.conditions, waves, waveStyle: cfg.waveStyle, waveCenter: resolvedWaveCenter, separation: cfg.separation });
|
|
1696
2244
|
// hover-focus (field.focusAt): hold the focused particle still and light it up — the dwell
|
|
1697
2245
|
// affordance ("it stops and does something") before a click opens its record.
|
|
1698
2246
|
if (focusP) {
|
|
@@ -1715,7 +2263,10 @@ export function createField(canvas, opts = {}) {
|
|
|
1715
2263
|
writeFeedback();
|
|
1716
2264
|
applyCausality();
|
|
1717
2265
|
updateEvents();
|
|
2266
|
+
updateClassToggles(); // element trigger class-toggle (§22.3, FACM #687): toggle data-class on crossings
|
|
1718
2267
|
updateCaptureEvents();
|
|
2268
|
+
updateThresholdEvents(now); // reserved agent-threshold events (§22.5): debounced field:* on crossings
|
|
2269
|
+
flushBusEvents(); // #684: deliver the frame's coalesced discrete events — one per (source, type)
|
|
1719
2270
|
// Draw only when there is a surface to draw to AND the canvas can be seen. Under the
|
|
1720
2271
|
// signals-only mode (`render: 'none'`, §13.7 / #297) the engine never draws — neither the
|
|
1721
2272
|
// underlay nor the overlay — and `ctx` may not even exist. Under reduced motion the scene is
|
|
@@ -1812,6 +2363,15 @@ export function createField(canvas, opts = {}) {
|
|
|
1812
2363
|
}
|
|
1813
2364
|
},
|
|
1814
2365
|
setFormation,
|
|
2366
|
+
setWaveStyle: (style) => {
|
|
2367
|
+
cfg.waveStyle = style;
|
|
2368
|
+
},
|
|
2369
|
+
setWaveCenter: (center) => {
|
|
2370
|
+
cfg.waveCenter = center;
|
|
2371
|
+
},
|
|
2372
|
+
setSeparation: (strength) => {
|
|
2373
|
+
cfg.separation = strength >= 0 ? strength : 0;
|
|
2374
|
+
},
|
|
1815
2375
|
setAttention: (on) => {
|
|
1816
2376
|
cfg.attention = on;
|
|
1817
2377
|
if (!on)
|
|
@@ -1880,6 +2440,14 @@ export function createField(canvas, opts = {}) {
|
|
|
1880
2440
|
if (ctx)
|
|
1881
2441
|
sizeSurfaces(host.viewport().dpr); // re-size the backing store to the new ceiling now
|
|
1882
2442
|
},
|
|
2443
|
+
setQualityTier: (tier) => {
|
|
2444
|
+
const next = Math.max(0, Math.min(3, Math.floor(tier || 0)));
|
|
2445
|
+
if (next === qualityTier)
|
|
2446
|
+
return;
|
|
2447
|
+
qualityTier = next;
|
|
2448
|
+
if (ctx)
|
|
2449
|
+
sizeSurfaces(host.viewport().dpr); // re-apply the tier's effective DPR ceiling now
|
|
2450
|
+
},
|
|
1883
2451
|
threads: setThreads,
|
|
1884
2452
|
burst: (x, y, hex) => {
|
|
1885
2453
|
// discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
|
|
@@ -1985,6 +2553,34 @@ export function createField(canvas, opts = {}) {
|
|
|
1985
2553
|
}
|
|
1986
2554
|
return w;
|
|
1987
2555
|
},
|
|
2556
|
+
readParticleChannels: (channels, out) => {
|
|
2557
|
+
const ps = store.particles;
|
|
2558
|
+
let written = 0;
|
|
2559
|
+
for (let i = 0; i < ps.length; i++) {
|
|
2560
|
+
const p = ps[i];
|
|
2561
|
+
if ('report' in p && p.report !== undefined)
|
|
2562
|
+
continue;
|
|
2563
|
+
if (written >= Math.min(...out.map(b => b.length)))
|
|
2564
|
+
break;
|
|
2565
|
+
for (let ci = 0; ci < channels.length; ci++) {
|
|
2566
|
+
if (ci >= out.length)
|
|
2567
|
+
break;
|
|
2568
|
+
const ch = channels[ci];
|
|
2569
|
+
out[ci][written] =
|
|
2570
|
+
ch === 'x' ? p.x : ch === 'y' ? p.y : ch === 'z' ? (p.z ?? 0) :
|
|
2571
|
+
ch === 'vx' ? p.vx : ch === 'vy' ? p.vy :
|
|
2572
|
+
ch === 'heat' ? p.heat : ch === 'size' ? p.size :
|
|
2573
|
+
ch === 'm' ? p.m : ch === 'id' ? (p.id ?? 0) :
|
|
2574
|
+
ch === 'age' ? (p.age ?? 0) : ch === 'charge' ? (p.charge ?? 0) : 0;
|
|
2575
|
+
}
|
|
2576
|
+
written++;
|
|
2577
|
+
}
|
|
2578
|
+
return written;
|
|
2579
|
+
},
|
|
2580
|
+
registerOverlay: (name, drawFn) => {
|
|
2581
|
+
customOverlays.set(name, drawFn);
|
|
2582
|
+
return () => { customOverlays.delete(name); };
|
|
2583
|
+
},
|
|
1988
2584
|
addAgent: (spec) => {
|
|
1989
2585
|
const p = newParticle({ x: spec.x, y: spec.y, z: spec.z, species: spec.species });
|
|
1990
2586
|
p.vx = 0;
|
|
@@ -2044,8 +2640,8 @@ export function createField(canvas, opts = {}) {
|
|
|
2044
2640
|
body.onFeedback = (ch) => { Object.assign(channels, ch); spec.onFeedback?.(ch); };
|
|
2045
2641
|
programmaticBodies.push(body);
|
|
2046
2642
|
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
|
|
2048
|
-
|
|
2643
|
+
measureBodies([body], W, H, originX, originY); // init cx/cy so the first sample/force is correct
|
|
2644
|
+
const handle = {
|
|
2049
2645
|
data: spec.data,
|
|
2050
2646
|
get channels() { return channels; },
|
|
2051
2647
|
set: (params) => {
|
|
@@ -2071,9 +2667,58 @@ export function createField(canvas, opts = {}) {
|
|
|
2071
2667
|
if (i >= 0)
|
|
2072
2668
|
programmaticBodies.splice(i, 1);
|
|
2073
2669
|
bodies = bodies.filter((x) => x !== body);
|
|
2670
|
+
// drop any programmatic edges that touched this body (#601).
|
|
2671
|
+
for (let k = programmaticEdges.length - 1; k >= 0; k--) {
|
|
2672
|
+
const pe = programmaticEdges[k];
|
|
2673
|
+
if (pe.from === body || pe.to === body)
|
|
2674
|
+
programmaticEdges.splice(k, 1);
|
|
2675
|
+
}
|
|
2074
2676
|
},
|
|
2075
2677
|
};
|
|
2678
|
+
handleToBody.set(handle, body);
|
|
2679
|
+
return handle;
|
|
2076
2680
|
},
|
|
2681
|
+
addEdge: (a, b, opts) => {
|
|
2682
|
+
// a relationship between two programmatic bodies (the addBody handles). No DOM — the edge keys on
|
|
2683
|
+
// the body identities; it feeds the same pure RelationshipAgent dynamics the DOM graph uses.
|
|
2684
|
+
const from = handleToBody.get(a);
|
|
2685
|
+
const to = handleToBody.get(b);
|
|
2686
|
+
if (!from || !to)
|
|
2687
|
+
throw new Error('addEdge: both arguments must be handles returned by addBody on this field.');
|
|
2688
|
+
const agent = {
|
|
2689
|
+
id: `e${edgeSeq++}`,
|
|
2690
|
+
from: '', // body ids are internal; readEdges exposes the carried data instead
|
|
2691
|
+
to: '',
|
|
2692
|
+
type: opts?.type ?? 'related',
|
|
2693
|
+
strength: opts?.strength ?? 0.5,
|
|
2694
|
+
tension: 0,
|
|
2695
|
+
memory: 0,
|
|
2696
|
+
active: false,
|
|
2697
|
+
};
|
|
2698
|
+
const edge = { agent, from, to, fromData: a.data, toData: b.data };
|
|
2699
|
+
programmaticEdges.push(edge);
|
|
2700
|
+
return {
|
|
2701
|
+
set: (params) => {
|
|
2702
|
+
if (params.strength != null)
|
|
2703
|
+
agent.strength = params.strength < 0 ? 0 : params.strength > 1 ? 1 : params.strength;
|
|
2704
|
+
if (params.type != null)
|
|
2705
|
+
agent.type = params.type;
|
|
2706
|
+
},
|
|
2707
|
+
remove: () => {
|
|
2708
|
+
const i = programmaticEdges.indexOf(edge);
|
|
2709
|
+
if (i >= 0)
|
|
2710
|
+
programmaticEdges.splice(i, 1);
|
|
2711
|
+
},
|
|
2712
|
+
};
|
|
2713
|
+
},
|
|
2714
|
+
readEdges: () => programmaticEdges.map((e) => ({
|
|
2715
|
+
from: e.fromData,
|
|
2716
|
+
to: e.toData,
|
|
2717
|
+
type: e.agent.type,
|
|
2718
|
+
strength: e.agent.strength,
|
|
2719
|
+
memory: e.agent.memory,
|
|
2720
|
+
active: e.agent.active,
|
|
2721
|
+
})),
|
|
2077
2722
|
addField: (name, sampler) => {
|
|
2078
2723
|
fieldChannels.set(name, sampler);
|
|
2079
2724
|
return {
|
|
@@ -2091,8 +2736,18 @@ export function createField(canvas, opts = {}) {
|
|
|
2091
2736
|
const { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
|
|
2092
2737
|
return { x: fx, y: fy };
|
|
2093
2738
|
},
|
|
2094
|
-
sampleScalar: (x, y) =>
|
|
2095
|
-
|
|
2739
|
+
sampleScalar: (x, y) => {
|
|
2740
|
+
if (heatmap)
|
|
2741
|
+
return heatmap.norm(x, y);
|
|
2742
|
+
devWarnNoOp('NOOP_NO_HEATMAP', 'sampleScalar() returned 0 because the heatmap layer is off — construct with { heatmap: true } or call setHeatmap(true).');
|
|
2743
|
+
return 0;
|
|
2744
|
+
},
|
|
2745
|
+
sampleGradient: (x, y) => {
|
|
2746
|
+
if (heatmap)
|
|
2747
|
+
return heatmap.gradient(x, y);
|
|
2748
|
+
devWarnNoOp('NOOP_NO_HEATMAP', 'sampleGradient() returned { x: 0, y: 0 } because the heatmap layer is off — construct with { heatmap: true } or call setHeatmap(true).');
|
|
2749
|
+
return { x: 0, y: 0 };
|
|
2750
|
+
},
|
|
2096
2751
|
grid: (name) => env.grid(name),
|
|
2097
2752
|
on: (type, cb) => {
|
|
2098
2753
|
let set = busListeners.get(type);
|
|
@@ -2103,6 +2758,7 @@ export function createField(canvas, opts = {}) {
|
|
|
2103
2758
|
set.add(cb);
|
|
2104
2759
|
return () => void set.delete(cb);
|
|
2105
2760
|
},
|
|
2761
|
+
version: FIELD_VERSION,
|
|
2106
2762
|
scrollV: () => env.scrollV ?? 0,
|
|
2107
2763
|
setVisible: (on) => {
|
|
2108
2764
|
canvasVisible = on;
|