@fundamental-engine/core 0.8.0 → 0.9.1
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/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 +22 -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 +537 -78
- 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/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/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 +88 -0
- 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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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 +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/core/field.js
CHANGED
|
@@ -20,7 +20,7 @@ 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";
|
|
@@ -30,11 +30,13 @@ import { feedbackTarget, feedbackWeight } from "./feedback.js";
|
|
|
30
30
|
import { defaultFeedbackSink } from "./feedback-sink.js";
|
|
31
31
|
import { thermoMetrics } from "./thermo.js";
|
|
32
32
|
import { attentionMuls } from "./attention.js";
|
|
33
|
+
import { updateRelationship } from "../agents/relationship.js";
|
|
34
|
+
import { Thresholder } from "../agents/event-agent.js";
|
|
33
35
|
import { spillover } from "./causality.js";
|
|
34
36
|
import { integrateOffset, anchorForce, elementMass, repelForce, densityPush } from "./agents.js";
|
|
35
37
|
import { releaseCaptured, sinkLoad, captureEdge, dischargeDisengaged } from "./accretion.js";
|
|
36
38
|
import { withinCapture, stepDock, dockTransform } from "./dock.js";
|
|
37
|
-
import { parseEventBindings, triggerActive } from "./events.js";
|
|
39
|
+
import { FieldEventCoalescer, parseEventBindings, triggerActive } from "./events.js";
|
|
38
40
|
import { registerCoreForces } from "../forces/index.js";
|
|
39
41
|
import { registerNaturalForces } from "../forces/natural.js";
|
|
40
42
|
import { registerExtendedForces } from "../forces/extended.js";
|
|
@@ -87,12 +89,29 @@ export function createField(canvas, opts = {}) {
|
|
|
87
89
|
// when unused: detection passes guard on `busHas(type)` so zero listeners cost nothing.
|
|
88
90
|
const busListeners = new Map();
|
|
89
91
|
const busHas = (type) => (busListeners.get(type)?.size ?? 0) > 0;
|
|
90
|
-
function
|
|
92
|
+
function busDeliver(type, payload) {
|
|
91
93
|
const set = busListeners.get(type);
|
|
92
94
|
if (set)
|
|
93
95
|
for (const cb of set)
|
|
94
96
|
cb(payload);
|
|
95
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
|
+
}
|
|
96
115
|
const sinkPeak = new WeakMap(); // matter held at the rising edge, for the release count
|
|
97
116
|
// #441 body-proximity events. Object-identity membership (survives rescan; removed bodies pruned).
|
|
98
117
|
const insideOf = new Map(); // enter/exit: bodies currently within a body's range
|
|
@@ -151,7 +170,22 @@ export function createField(canvas, opts = {}) {
|
|
|
151
170
|
m.delete(k);
|
|
152
171
|
}
|
|
153
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();
|
|
154
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();
|
|
155
189
|
// All 36 forces are registered on every field — there is no opt-in. Any of them activates per-body
|
|
156
190
|
// through its `data-body` token (e.g. `data-body="lens crystallize"`); an unused force costs nothing.
|
|
157
191
|
registerCoreForces(reg); // the canonical nine (§6)
|
|
@@ -175,8 +209,11 @@ export function createField(canvas, opts = {}) {
|
|
|
175
209
|
density: opts.density && opts.density > 0 ? opts.density : 1,
|
|
176
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.
|
|
177
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,
|
|
178
214
|
background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
|
|
179
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,
|
|
180
217
|
attention: opts.attention ?? false, // conserved attention (§2.4), opt-in
|
|
181
218
|
causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
|
|
182
219
|
heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
|
|
@@ -229,6 +266,7 @@ export function createField(canvas, opts = {}) {
|
|
|
229
266
|
let waves = [];
|
|
230
267
|
let bound = [];
|
|
231
268
|
let boundTarget = 0;
|
|
269
|
+
let resolvedWaveCenter = null;
|
|
232
270
|
// the injected sources (#371): every random draw and wall-clock read in the engine flows
|
|
233
271
|
// through these two, so a seeded rng + fixed clock make a run reproducible end to end.
|
|
234
272
|
const rng = opts.rng ?? Math.random;
|
|
@@ -278,6 +316,10 @@ export function createField(canvas, opts = {}) {
|
|
|
278
316
|
let emitters = [];
|
|
279
317
|
let sparks = [];
|
|
280
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 = [];
|
|
281
323
|
let engaged = []; // [data-hot] listeners, for teardown
|
|
282
324
|
// shadow-DOM participation (docs/engine-reference/shadow-dom.md): encapsulated components dispatch composed
|
|
283
325
|
// register/unregister/update events; the field registers the HOST and never inspects the
|
|
@@ -359,6 +401,12 @@ export function createField(canvas, opts = {}) {
|
|
|
359
401
|
tearBoundNear(bound, waves, b.cx, b.cy, 320, W, H, env.t, (p) => void store.add(newParticle(p)));
|
|
360
402
|
// release docks any DOM elements this sink had captured (§22.3, element capture).
|
|
361
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 });
|
|
362
410
|
// and fires field:released on the falling edge of accreting (capture/release events, §22.5).
|
|
363
411
|
if (b.el.dataset.fxCap === '1') {
|
|
364
412
|
b.el.dataset.fxCap = '0';
|
|
@@ -504,6 +552,16 @@ export function createField(canvas, opts = {}) {
|
|
|
504
552
|
bindings: parseEventBindings(el.dataset.on ?? ''),
|
|
505
553
|
};
|
|
506
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
|
+
});
|
|
507
565
|
// resolve `warp` pairings (§22.3 relocate): a body's data-pair selector → the paired body, whose
|
|
508
566
|
// live centre becomes the relocate target each frame (updateWarpTargets).
|
|
509
567
|
for (const b of bodies) {
|
|
@@ -622,6 +680,31 @@ export function createField(canvas, opts = {}) {
|
|
|
622
680
|
}
|
|
623
681
|
}
|
|
624
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
|
+
}
|
|
625
708
|
// dispatch a discrete field event on an element, with the forces:* alias (migration window).
|
|
626
709
|
function fireCaptureEvent(el, name, detail) {
|
|
627
710
|
el.dispatchEvent(new CustomEvent('field:' + name, { bubbles: true, composed: true, detail }));
|
|
@@ -652,6 +735,72 @@ export function createField(canvas, opts = {}) {
|
|
|
652
735
|
}
|
|
653
736
|
}
|
|
654
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
|
+
}
|
|
655
804
|
// release any DOM elements a sink had docked (§22.3) — restore their transform + a11y, fire
|
|
656
805
|
// field:released. Called from supernova so element capture is conserved like particle capture.
|
|
657
806
|
function undockFrom(b) {
|
|
@@ -903,73 +1052,165 @@ export function createField(canvas, opts = {}) {
|
|
|
903
1052
|
function drawWaves() {
|
|
904
1053
|
const time = env.t;
|
|
905
1054
|
const STEP = 16;
|
|
906
|
-
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
ctx.
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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';
|
|
921
1110
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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';
|
|
937
1143
|
}
|
|
938
|
-
ctx.globalCompositeOperation = 'source-over';
|
|
939
1144
|
}
|
|
940
1145
|
function drawBound() {
|
|
941
1146
|
ctx.globalCompositeOperation = 'lighter';
|
|
942
1147
|
const time = env.t;
|
|
943
1148
|
let i = 0;
|
|
944
|
-
|
|
945
|
-
const
|
|
946
|
-
|
|
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();
|
|
947
1181
|
i++;
|
|
948
|
-
continue;
|
|
949
1182
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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})`;
|
|
964
1209
|
ctx.beginPath();
|
|
965
|
-
ctx.arc(x, y, p.size
|
|
1210
|
+
ctx.arc(x, y, p.size, 0, 6.28318);
|
|
966
1211
|
ctx.fill();
|
|
1212
|
+
i++;
|
|
967
1213
|
}
|
|
968
|
-
ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
|
|
969
|
-
ctx.beginPath();
|
|
970
|
-
ctx.arc(x, y, p.size, 0, 6.28318);
|
|
971
|
-
ctx.fill();
|
|
972
|
-
i++;
|
|
973
1214
|
}
|
|
974
1215
|
ctx.globalCompositeOperation = 'source-over';
|
|
975
1216
|
}
|
|
@@ -1587,9 +1828,12 @@ export function createField(canvas, opts = {}) {
|
|
|
1587
1828
|
// `grid` — a reference lattice whose vertices are displaced along the felt field; the page's
|
|
1588
1829
|
// space itself made visible, bending where the field is strong. Reads deformation.
|
|
1589
1830
|
function drawOverlayGrid(out) {
|
|
1590
|
-
const STEP =
|
|
1591
|
-
|
|
1592
|
-
|
|
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.
|
|
1593
1837
|
const cols = Math.floor(W / STEP) + 2;
|
|
1594
1838
|
const rows = Math.floor(H / STEP) + 2;
|
|
1595
1839
|
const dx = new Float32Array(cols * rows);
|
|
@@ -1610,27 +1854,124 @@ export function createField(canvas, opts = {}) {
|
|
|
1610
1854
|
}
|
|
1611
1855
|
}
|
|
1612
1856
|
}
|
|
1613
|
-
|
|
1614
|
-
//
|
|
1615
|
-
//
|
|
1616
|
-
|
|
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.
|
|
1617
1908
|
const px = (gx, gy) => {
|
|
1618
1909
|
const i = gy * cols + gx;
|
|
1619
|
-
|
|
1620
|
-
|
|
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];
|
|
1621
1920
|
};
|
|
1622
|
-
|
|
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;
|
|
1942
|
+
};
|
|
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));
|
|
1623
1946
|
for (let gy = 0; gy < rows; gy++) {
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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));
|
|
1628
1960
|
}
|
|
1629
1961
|
for (let gx = 0; gx < cols; gx++) {
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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));
|
|
1634
1975
|
}
|
|
1635
1976
|
}
|
|
1636
1977
|
// shared scalar-contour pass for `temperature` and `energy` — splat a per-particle scalar onto a
|
|
@@ -1772,6 +2113,10 @@ export function createField(canvas, opts = {}) {
|
|
|
1772
2113
|
drawOverlayPaths(out);
|
|
1773
2114
|
else if (mode === 'data')
|
|
1774
2115
|
drawOverlayData(out);
|
|
2116
|
+
// custom overlays registered via registerOverlay()
|
|
2117
|
+
const customFn = customOverlays.get(mode);
|
|
2118
|
+
if (customFn)
|
|
2119
|
+
customFn(out, env, W, H);
|
|
1775
2120
|
}
|
|
1776
2121
|
}
|
|
1777
2122
|
function frame(now) {
|
|
@@ -1826,6 +2171,14 @@ export function createField(canvas, opts = {}) {
|
|
|
1826
2171
|
// edge of engagement — the same conserved supernova ritual as saturation.
|
|
1827
2172
|
dischargeDisengaged(bodies, env.supernova);
|
|
1828
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
|
+
}
|
|
1829
2182
|
// spine: ease the wave-bend toward the flow focus (if set) or the engaged element (§24). A live
|
|
1830
2183
|
// flow focus (field.flowTo) takes priority, so the streamline spine curves to the moving target.
|
|
1831
2184
|
let engaged = null;
|
|
@@ -1869,8 +2222,25 @@ export function createField(canvas, opts = {}) {
|
|
|
1869
2222
|
p.vy += b.y;
|
|
1870
2223
|
}
|
|
1871
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
|
+
}
|
|
1872
2242
|
updateWarpTargets(); // refresh warp relocate targets from paired bodies (§22.3) before the step
|
|
1873
|
-
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 });
|
|
1874
2244
|
// hover-focus (field.focusAt): hold the focused particle still and light it up — the dwell
|
|
1875
2245
|
// affordance ("it stops and does something") before a click opens its record.
|
|
1876
2246
|
if (focusP) {
|
|
@@ -1893,7 +2263,10 @@ export function createField(canvas, opts = {}) {
|
|
|
1893
2263
|
writeFeedback();
|
|
1894
2264
|
applyCausality();
|
|
1895
2265
|
updateEvents();
|
|
2266
|
+
updateClassToggles(); // element trigger class-toggle (§22.3, FACM #687): toggle data-class on crossings
|
|
1896
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)
|
|
1897
2270
|
// Draw only when there is a surface to draw to AND the canvas can be seen. Under the
|
|
1898
2271
|
// signals-only mode (`render: 'none'`, §13.7 / #297) the engine never draws — neither the
|
|
1899
2272
|
// underlay nor the overlay — and `ctx` may not even exist. Under reduced motion the scene is
|
|
@@ -1990,6 +2363,15 @@ export function createField(canvas, opts = {}) {
|
|
|
1990
2363
|
}
|
|
1991
2364
|
},
|
|
1992
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
|
+
},
|
|
1993
2375
|
setAttention: (on) => {
|
|
1994
2376
|
cfg.attention = on;
|
|
1995
2377
|
if (!on)
|
|
@@ -2171,6 +2553,34 @@ export function createField(canvas, opts = {}) {
|
|
|
2171
2553
|
}
|
|
2172
2554
|
return w;
|
|
2173
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
|
+
},
|
|
2174
2584
|
addAgent: (spec) => {
|
|
2175
2585
|
const p = newParticle({ x: spec.x, y: spec.y, z: spec.z, species: spec.species });
|
|
2176
2586
|
p.vx = 0;
|
|
@@ -2231,7 +2641,7 @@ export function createField(canvas, opts = {}) {
|
|
|
2231
2641
|
programmaticBodies.push(body);
|
|
2232
2642
|
bodies = bodies.concat(body); // live this frame, before the next scan re-merges it
|
|
2233
2643
|
measureBodies([body], W, H, originX, originY); // init cx/cy so the first sample/force is correct
|
|
2234
|
-
|
|
2644
|
+
const handle = {
|
|
2235
2645
|
data: spec.data,
|
|
2236
2646
|
get channels() { return channels; },
|
|
2237
2647
|
set: (params) => {
|
|
@@ -2257,9 +2667,58 @@ export function createField(canvas, opts = {}) {
|
|
|
2257
2667
|
if (i >= 0)
|
|
2258
2668
|
programmaticBodies.splice(i, 1);
|
|
2259
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
|
+
}
|
|
2676
|
+
},
|
|
2677
|
+
};
|
|
2678
|
+
handleToBody.set(handle, body);
|
|
2679
|
+
return handle;
|
|
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);
|
|
2260
2711
|
},
|
|
2261
2712
|
};
|
|
2262
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
|
+
})),
|
|
2263
2722
|
addField: (name, sampler) => {
|
|
2264
2723
|
fieldChannels.set(name, sampler);
|
|
2265
2724
|
return {
|