@fundamental-engine/core 0.9.2 → 0.9.3
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/README.md +12 -11
- package/dist/agents/event-agent.d.ts.map +1 -1
- package/dist/agents/event-agent.js +13 -9
- package/dist/agents/event-agent.js.map +1 -1
- package/dist/config/forces.config.js +2 -2
- package/dist/config/forces.config.js.map +1 -1
- package/dist/conformance/run.d.ts +2 -2
- package/dist/conformance/run.d.ts.map +1 -1
- package/dist/conformance/run.js +30 -27
- package/dist/conformance/run.js.map +1 -1
- package/dist/conformance/types.d.ts +3 -0
- package/dist/conformance/types.d.ts.map +1 -1
- package/dist/contracts/guards.d.ts +3 -1
- package/dist/contracts/guards.d.ts.map +1 -1
- package/dist/contracts/guards.js.map +1 -1
- package/dist/contracts/passport.d.ts.map +1 -1
- package/dist/contracts/passport.js +9 -0
- package/dist/contracts/passport.js.map +1 -1
- package/dist/core/accretion.d.ts +1 -1
- package/dist/core/accretion.d.ts.map +1 -1
- package/dist/core/accretion.js +1 -1
- package/dist/core/accretion.js.map +1 -1
- package/dist/core/events.d.ts +3 -3
- package/dist/core/events.d.ts.map +1 -1
- package/dist/core/events.js +1 -1
- package/dist/core/feedback-sink.d.ts +1 -1
- package/dist/core/feedback-sink.d.ts.map +1 -1
- package/dist/core/feedback-sink.js +1 -2
- package/dist/core/feedback-sink.js.map +1 -1
- package/dist/core/field.d.ts.map +1 -1
- package/dist/core/field.js +697 -82
- package/dist/core/field.js.map +1 -1
- package/dist/core/frame-harness.d.ts +2 -2
- package/dist/core/frame-harness.d.ts.map +1 -1
- package/dist/core/frame-harness.js +2 -2
- package/dist/core/frame-harness.js.map +1 -1
- package/dist/core/geometry.d.ts +9 -0
- package/dist/core/geometry.d.ts.map +1 -1
- package/dist/core/geometry.js +16 -0
- package/dist/core/geometry.js.map +1 -1
- package/dist/core/host.d.ts +95 -21
- package/dist/core/host.d.ts.map +1 -1
- package/dist/core/host.js +53 -1
- package/dist/core/host.js.map +1 -1
- package/dist/core/integrator.d.ts +22 -0
- package/dist/core/integrator.d.ts.map +1 -1
- package/dist/core/integrator.js +118 -15
- package/dist/core/integrator.js.map +1 -1
- package/dist/core/reactions.d.ts +7 -0
- package/dist/core/reactions.d.ts.map +1 -1
- package/dist/core/reactions.js +7 -0
- package/dist/core/reactions.js.map +1 -1
- package/dist/core/render-modes.d.ts +64 -0
- package/dist/core/render-modes.d.ts.map +1 -1
- package/dist/core/render-modes.js +165 -0
- package/dist/core/render-modes.js.map +1 -1
- package/dist/core/scanner.d.ts +29 -3
- package/dist/core/scanner.d.ts.map +1 -1
- package/dist/core/scanner.js +41 -3
- package/dist/core/scanner.js.map +1 -1
- package/dist/core/spatial-hash.d.ts +8 -0
- package/dist/core/spatial-hash.d.ts.map +1 -1
- package/dist/core/spatial-hash.js +26 -3
- package/dist/core/spatial-hash.js.map +1 -1
- package/dist/core/types.d.ts +308 -13
- package/dist/core/types.d.ts.map +1 -1
- package/dist/forces/extended.d.ts.map +1 -1
- package/dist/forces/extended.js +8 -2
- package/dist/forces/extended.js.map +1 -1
- package/dist/forces/natural.d.ts.map +1 -1
- package/dist/forces/natural.js +11 -12
- package/dist/forces/natural.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/recipes/compile.d.ts.map +1 -1
- package/dist/recipes/compile.js +3 -1
- package/dist/recipes/compile.js.map +1 -1
- package/dist/recipes/schema.d.ts +1 -1
- package/dist/recipes/schema.d.ts.map +1 -1
- package/dist/recipes/schema.js +1 -0
- package/dist/recipes/schema.js.map +1 -1
- package/dist/record/record.d.ts +1 -1
- package/dist/record/record.d.ts.map +1 -1
- package/dist/semantic/layers.d.ts +16 -2
- package/dist/semantic/layers.d.ts.map +1 -1
- package/dist/semantic/layers.js +20 -3
- package/dist/semantic/layers.js.map +1 -1
- package/dist/semantic/materials.js +3 -3
- package/dist/semantic/materials.js.map +1 -1
- package/dist/semantic/states.js +1 -1
- package/dist/semantic/states.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/visual/visualization.d.ts.map +1 -1
- package/dist/visual/visualization.js +4 -0
- package/dist/visual/visualization.js.map +1 -1
- package/package.json +2 -1
package/dist/core/field.js
CHANGED
|
@@ -41,8 +41,8 @@ import { registerCoreForces } from "../forces/index.js";
|
|
|
41
41
|
import { registerNaturalForces } from "../forces/natural.js";
|
|
42
42
|
import { registerExtendedForces } from "../forces/extended.js";
|
|
43
43
|
import { ScalarGridImpl } from "./scalar-grid.js";
|
|
44
|
-
import { sparkCount, burstImpulse } from "./reactions.js";
|
|
45
|
-
import { linkAlpha, marchingCell, splatDensity, nearestSite, voronoiWalls } from "./render-modes.js";
|
|
44
|
+
import { sparkCount, burstImpulse, BURST_RADIUS } from "./reactions.js";
|
|
45
|
+
import { linkAlpha, marchingCell, splatDensity, nearestSite, voronoiWalls, knockoutHoleRadius, radialVelocity, dopplerShift, wellWeight, redshiftShift, redshiftRGBInto, blackbodyT, blackbodyRGBInto, depthScale, depthProject, depthAlpha, depthBlurRadius, } from "./render-modes.js";
|
|
46
46
|
import { canvas2dBackend } from "./render-backend.js";
|
|
47
47
|
import { forceAt, netField } from "./streamlines.js";
|
|
48
48
|
import { traceFieldLines } from "./fieldlines.js";
|
|
@@ -61,6 +61,94 @@ import { applyLens } from "./query-lens.js";
|
|
|
61
61
|
// scratch before the next write (no overlapping lifetimes, no cross-instance interleaving).
|
|
62
62
|
const _flowB = { x: 0, y: 0 };
|
|
63
63
|
const _rgb = [0, 0, 0];
|
|
64
|
+
/** Deep-copy a {@link FieldPolicy} (shallow is unsafe — `budgets` is nested). `undefined` → `{}` (the
|
|
65
|
+
* unbounded default). Used on set + read so callers can neither mutate the field's live policy nor
|
|
66
|
+
* observe later mutations of the object they passed in. */
|
|
67
|
+
function clonePolicy(p) {
|
|
68
|
+
if (!p)
|
|
69
|
+
return {};
|
|
70
|
+
const out = {};
|
|
71
|
+
if (p.allowBodyDataInSnapshots != null)
|
|
72
|
+
out.allowBodyDataInSnapshots = p.allowBodyDataInSnapshots;
|
|
73
|
+
if (p.allowMotionProjection != null)
|
|
74
|
+
out.allowMotionProjection = p.allowMotionProjection;
|
|
75
|
+
if (p.maxMotionBudget != null)
|
|
76
|
+
out.maxMotionBudget = p.maxMotionBudget;
|
|
77
|
+
if (p.budgets)
|
|
78
|
+
out.budgets = { ...p.budgets };
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve {@link FieldSnapshotOptions} — an optional {@link SnapshotProfile} composed with the explicit
|
|
83
|
+
* `include*` flags — to concrete inclusion, TIGHTEST-wins. A profile establishes a baseline; an explicit
|
|
84
|
+
* flag may tighten it further but never widen it (an explicit `true` cannot re-enable what the profile
|
|
85
|
+
* turned off). `includeData` additionally passes through the policy gate at the call site (this only
|
|
86
|
+
* governs whether the CALLER asked for it). Relationships default true when nothing narrows them.
|
|
87
|
+
*/
|
|
88
|
+
function resolveSnapshotInclusion(opts) {
|
|
89
|
+
// Per-profile baselines. `debug` = everything; `agent` = structure + attribution, no opaque data;
|
|
90
|
+
// `bug-report` = structural + versions, no data; `public` = ids + shape only.
|
|
91
|
+
const base = {
|
|
92
|
+
debug: { includeParticles: true, includeRelationships: true, includeData: true, includeInfluences: true },
|
|
93
|
+
agent: { includeParticles: false, includeRelationships: true, includeData: false, includeInfluences: true },
|
|
94
|
+
'bug-report': { includeParticles: false, includeRelationships: true, includeData: false, includeInfluences: true },
|
|
95
|
+
public: { includeParticles: false, includeRelationships: false, includeData: false, includeInfluences: false },
|
|
96
|
+
};
|
|
97
|
+
const p = opts.profile;
|
|
98
|
+
if (!p) {
|
|
99
|
+
// No profile: today's defaults — relationships default true, the rest default false.
|
|
100
|
+
return {
|
|
101
|
+
includeParticles: opts.includeParticles === true,
|
|
102
|
+
includeRelationships: opts.includeRelationships !== false,
|
|
103
|
+
includeData: opts.includeData === true,
|
|
104
|
+
includeInfluences: opts.includeInfluences === true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const b = base[p];
|
|
108
|
+
// TIGHTEST wins: a flag is on only if the profile allows it AND the caller didn't explicitly turn it
|
|
109
|
+
// off. An explicit `true` can never widen past the profile's baseline.
|
|
110
|
+
return {
|
|
111
|
+
includeParticles: b.includeParticles && opts.includeParticles !== false,
|
|
112
|
+
includeRelationships: b.includeRelationships && opts.includeRelationships !== false,
|
|
113
|
+
includeData: b.includeData && opts.includeData !== false,
|
|
114
|
+
includeInfluences: b.includeInfluences && opts.includeInfluences !== false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Strip a set of dotted `redactions` paths from a plain reading (query result or snapshot). A top-level
|
|
119
|
+
* key (`'metrics'`, `'relationships'`) deletes that key from the object. A `<prefix>.<key>` path where
|
|
120
|
+
* prefix ∈ {body, relationship, influence, projection} strips `<key>` from each entry of the matching
|
|
121
|
+
* list; `body.data` strips per-body `data`. Mutates the passed object (it's always a fresh result/copy).
|
|
122
|
+
*/
|
|
123
|
+
function applyRedactions(reading, redactions) {
|
|
124
|
+
const listKeyFor = { body: 'bodies', relationship: 'relationships', influence: 'influences', projection: 'projections' };
|
|
125
|
+
for (const path of redactions) {
|
|
126
|
+
const dot = path.indexOf('.');
|
|
127
|
+
if (dot < 0) {
|
|
128
|
+
delete reading[path];
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const prefix = path.slice(0, dot);
|
|
132
|
+
const key = path.slice(dot + 1);
|
|
133
|
+
const listKey = listKeyFor[prefix];
|
|
134
|
+
if (listKey && Array.isArray(reading[listKey])) {
|
|
135
|
+
for (const entry of reading[listKey]) {
|
|
136
|
+
if (entry && typeof entry === 'object')
|
|
137
|
+
delete entry[key];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (prefix === 'metrics' && reading['metrics'] && typeof reading['metrics'] === 'object') {
|
|
141
|
+
delete reading['metrics'][key];
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
// an unrecognized prefix addresses a nested top-level object key.
|
|
145
|
+
const target = reading[prefix];
|
|
146
|
+
if (target && typeof target === 'object')
|
|
147
|
+
delete target[key];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return reading;
|
|
151
|
+
}
|
|
64
152
|
export function createField(canvas, opts = {}) {
|
|
65
153
|
// Signals-only mode (`render: 'none'`, §13.7 / #297): the full simulation + feedback pipeline
|
|
66
154
|
// runs, but the engine never acquires a 2d context, never sizes a canvas backing store (it stays
|
|
@@ -78,12 +166,37 @@ export function createField(canvas, opts = {}) {
|
|
|
78
166
|
// it (the caller owns the element + its fixed/pointer-events placement); its backing store is sized
|
|
79
167
|
// in resize() to match the main canvas dpr. Keeps core DOM-free — the canvas is handed in.
|
|
80
168
|
// Under `render: 'none'` it is never acquired either (the overlay never draws in that mode).
|
|
81
|
-
|
|
169
|
+
// The overlay canvas may be handed in eagerly (`overlayCanvas`) OR resolved lazily the first time an
|
|
170
|
+
// overlay actually becomes active (`overlayCanvasProvider`, #676) — the host defers creating a
|
|
171
|
+
// full-viewport light-DOM canvas until a reading is switched on, so the common `overlay: off` case
|
|
172
|
+
// never adds a mix-blend canvas to the compositing tree at boot. Core stays DOM-free either way: the
|
|
173
|
+
// host owns the element; core only draws to it.
|
|
174
|
+
let overlayCanvas = opts.overlayCanvas ?? null;
|
|
82
175
|
let overlayCtx = ctx ? (overlayCanvas?.getContext('2d') ?? null) : null;
|
|
83
176
|
// The overlay draws exclusively through the RenderBackend contract (#373) — the structural
|
|
84
177
|
// seam a WebGL/WebGPU surface implements later. Callers may inject one; the default wraps the
|
|
85
178
|
// overlay's own 2d context.
|
|
86
179
|
let overlayBackend = opts.overlayBackend ?? (overlayCanvas && overlayCtx ? canvas2dBackend(overlayCanvas, overlayCtx) : null);
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the overlay surface on demand (#676). If no canvas is bound yet but a provider was supplied,
|
|
182
|
+
* call it once, acquire its 2d context + default backend, and size the backing store to the live dpr.
|
|
183
|
+
* Idempotent — an already-resolved backend (eager canvas, injected backend, or a prior call) short-
|
|
184
|
+
* circuits. Called the first time an overlay reading becomes active (`setOverlay`) and on the
|
|
185
|
+
* `setRender('none' → …)` lazy path. No-op while the underlay `ctx` is absent (signals-only boot).
|
|
186
|
+
*/
|
|
187
|
+
function ensureOverlaySurface() {
|
|
188
|
+
if (overlayBackend)
|
|
189
|
+
return;
|
|
190
|
+
if (!overlayCanvas && opts.overlayCanvasProvider)
|
|
191
|
+
overlayCanvas = opts.overlayCanvasProvider() ?? null;
|
|
192
|
+
if (!overlayCanvas || !ctx)
|
|
193
|
+
return;
|
|
194
|
+
overlayCtx ??= overlayCanvas.getContext('2d');
|
|
195
|
+
if (!overlayCtx)
|
|
196
|
+
return;
|
|
197
|
+
overlayBackend = opts.overlayBackend ?? canvas2dBackend(overlayCanvas, overlayCtx);
|
|
198
|
+
overlayBackend.size(W, H, host.viewport().dpr); // size to the live viewport — resize() only fires on change
|
|
199
|
+
}
|
|
87
200
|
const store = new FieldStore();
|
|
88
201
|
let nextParticleId = 1; // monotonic stable particle identity (readParticleIds); never reused
|
|
89
202
|
const grids = new Map(); // §20.1 class [C] field buffers, lazy
|
|
@@ -183,7 +296,9 @@ export function createField(canvas, opts = {}) {
|
|
|
183
296
|
// Reserved agent-threshold events (§22.5, FIELD_EVENTS): per-body hysteretic edge detectors that
|
|
184
297
|
// turn a continuous metric (sink load, density, attention, entropy) into one debounced `field:*`
|
|
185
298
|
// CustomEvent on its rising edge — never per-frame. Lazy: a body gets a Thresholder for a metric
|
|
186
|
-
// only the first frame it has a value to test
|
|
299
|
+
// only the first frame it has a value to test. On rescan, a persisting body's Thresholders are
|
|
300
|
+
// re-keyed onto its replacement Body (scan()'s reconciliation, #966 — the carried d/attn would
|
|
301
|
+
// otherwise re-fire a rising edge already announced); a removed body's entries drop with it.
|
|
187
302
|
// Keyed body → metric-name → Thresholder; edges track relationship `memory` the same way.
|
|
188
303
|
const bodyThresholds = new WeakMap();
|
|
189
304
|
const edgeThresholds = new WeakMap();
|
|
@@ -204,7 +319,42 @@ export function createField(canvas, opts = {}) {
|
|
|
204
319
|
}
|
|
205
320
|
const host = opts.host;
|
|
206
321
|
const teardowns = []; // host event unsubscribers, called on destroy
|
|
207
|
-
|
|
322
|
+
// Reduced-motion is an ACCESSIBILITY clamp: when the host/user asks for it, motion can only be
|
|
323
|
+
// *removed*, never restored by policy. It's read live (not captured once) so it always reflects the
|
|
324
|
+
// current OS/user state.
|
|
325
|
+
const hostReducedMotion = () => host.reducedMotion?.() ?? false;
|
|
326
|
+
// Runtime FIELD POLICY (#field-policy): what THIS host/session/user/app PERMITS (runtime), distinct
|
|
327
|
+
// from governance (what doctrine allows — static lint). Replaced live via `setPolicy`. Default: no
|
|
328
|
+
// policy → unbounded, byte-identical to the pre-policy engine.
|
|
329
|
+
let policy = clonePolicy(opts.policy);
|
|
330
|
+
// The effective MOTION budget (0..1) — the single value the render/easing/integrator path reads.
|
|
331
|
+
// Unifies reduced-motion + host policy (+ perf pressure, folded via the same `min`). Reduced-motion
|
|
332
|
+
// ALWAYS wins (accessibility can only lower motion, never raise it): when it's on, this is 0.
|
|
333
|
+
const effectiveMotion = () => {
|
|
334
|
+
if (hostReducedMotion())
|
|
335
|
+
return 0; // accessibility clamp — beats any policy
|
|
336
|
+
let m = 1;
|
|
337
|
+
if (policy.allowMotionProjection === false)
|
|
338
|
+
return 0; // policy pins motion off
|
|
339
|
+
if (policy.maxMotionBudget != null)
|
|
340
|
+
m = Math.min(m, policy.maxMotionBudget);
|
|
341
|
+
if (policy.budgets?.motion != null)
|
|
342
|
+
m = Math.min(m, policy.budgets.motion);
|
|
343
|
+
return Math.max(0, Math.min(1, m)); // perf pressure folds here via the same min (currently 1)
|
|
344
|
+
};
|
|
345
|
+
// the initial static snapshot used for boot/seed state (frame 0 has no perf history):
|
|
346
|
+
const reduceMotion = effectiveMotion() <= 0;
|
|
347
|
+
// Privacy budget → snapshot data gate. A privacy budget below this withholds body `data` even when a
|
|
348
|
+
// caller passes `includeData` (policy tightens; it can't be overridden upward at the call site).
|
|
349
|
+
const PRIVACY_DATA_THRESHOLD = 0.5;
|
|
350
|
+
const policyPermitsBodyData = () => {
|
|
351
|
+
if (policy.allowBodyDataInSnapshots === false)
|
|
352
|
+
return false; // explicit deny wins
|
|
353
|
+
const pv = policy.budgets?.privacy;
|
|
354
|
+
if (pv != null && pv < PRIVACY_DATA_THRESHOLD)
|
|
355
|
+
return false; // low privacy budget → withhold
|
|
356
|
+
return true; // default: fall through to the call-site `includeData`
|
|
357
|
+
};
|
|
208
358
|
// ambient theme (#529): a named preset for the heat ramp + wave baseline; `warm` (default) reproduces
|
|
209
359
|
// the shipped palette. Individual lanes (gradientCool/gradientWarm/waveBaseline) override the preset.
|
|
210
360
|
const theme = THEMES[opts.theme ?? DEFAULT_THEME] ?? THEMES[DEFAULT_THEME];
|
|
@@ -212,12 +362,19 @@ export function createField(canvas, opts = {}) {
|
|
|
212
362
|
accent: opts.accent ?? resolvePalette(opts.palette)[0] ?? PALETTE[0] ?? '#4da3ff',
|
|
213
363
|
density: opts.density && opts.density > 0 ? opts.density : 1,
|
|
214
364
|
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.
|
|
215
|
-
waves: opts.waves ??
|
|
365
|
+
waves: opts.waves ?? false, // the background Currents (§24) are OPT-IN (#979, doc-06 Step 0): a bare field has no carrier waves; pass waves:true for the ambient resting structure.
|
|
216
366
|
waveStyle: opts.waveStyle ?? 'linear',
|
|
217
367
|
waveCenter: opts.waveCenter ?? null,
|
|
218
368
|
background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
|
|
219
369
|
mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
|
|
370
|
+
reaction: opts.reaction ?? false, // Newtonian own-emission recoil for dynamic bodies (#873)
|
|
220
371
|
separation: opts.separation != null && opts.separation >= 0 ? opts.separation : 0,
|
|
372
|
+
// DECLARED ambient bias (Wallpaper Rule, #978): the resting `ambient` formation's swirl (`orbit`)
|
|
373
|
+
// and drift (`wander`), formerly hardcoded (0.1 / 1.0) in FORMATION_BY.ambient.preset — a gray debt.
|
|
374
|
+
// Defaulting to the historical preset values keeps the resting field byte-identical; these dials let
|
|
375
|
+
// a host zero the spiral (ambientOrbit:0 → purely radial attract) or calm the drift.
|
|
376
|
+
ambientOrbit: opts.ambientOrbit != null && opts.ambientOrbit >= 0 ? opts.ambientOrbit : FORMATION_BY.ambient.preset.orbit,
|
|
377
|
+
ambientWander: opts.ambientWander != null && opts.ambientWander >= 0 ? opts.ambientWander : FORMATION_BY.ambient.preset.wander,
|
|
221
378
|
attention: opts.attention ?? false, // conserved attention (§2.4), opt-in
|
|
222
379
|
causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
|
|
223
380
|
heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
|
|
@@ -236,8 +393,25 @@ export function createField(canvas, opts = {}) {
|
|
|
236
393
|
// optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
|
|
237
394
|
// to the 2D engine; > 0 opens a shallow depth the matter drifts through, opt-in.
|
|
238
395
|
depth: opts.depth && opts.depth > 0 ? opts.depth : 0,
|
|
239
|
-
//
|
|
240
|
-
|
|
396
|
+
// DECLARED render reference points (Wallpaper Rule, #975): four content-independent constants
|
|
397
|
+
// formerly painted into the draw path, now dials whose defaults reproduce the historical values
|
|
398
|
+
// (so every render mode stays byte-identical by default).
|
|
399
|
+
// heat vignette center — viewport fractions, formerly (W/2, H·0.4). Resolved to px per frame.
|
|
400
|
+
heatCenterX: opts.heatCenter && Number.isFinite(opts.heatCenter.x) ? opts.heatCenter.x : 0.5,
|
|
401
|
+
heatCenterY: opts.heatCenter && Number.isFinite(opts.heatCenter.y) ? opts.heatCenter.y : 0.4,
|
|
402
|
+
// redshift observer — viewport fractions, formerly (W/2, H/2). Resolved to px per frame.
|
|
403
|
+
redshiftObserverX: opts.redshiftObserver && Number.isFinite(opts.redshiftObserver.x) ? opts.redshiftObserver.x : 0.5,
|
|
404
|
+
redshiftObserverY: opts.redshiftObserver && Number.isFinite(opts.redshiftObserver.y) ? opts.redshiftObserver.y : 0.5,
|
|
405
|
+
// depth-camera focal length in CSS px, formerly the hardcoded FOCAL = 480.
|
|
406
|
+
depthFocal: opts.depthFocal != null && opts.depthFocal > 0 ? opts.depthFocal : 480,
|
|
407
|
+
// heatmap scroll-fade curve (in viewports), formerly the hardcoded (1.15 - scrollY/H)/0.85 —
|
|
408
|
+
// full above start·H, gone by (start+span)·H. Defaults reproduce start=0.3, span=0.85.
|
|
409
|
+
heatmapFadeStart: opts.heatmapFade && Number.isFinite(opts.heatmapFade.start) ? opts.heatmapFade.start : 0.3,
|
|
410
|
+
heatmapFadeSpan: opts.heatmapFade && Number.isFinite(opts.heatmapFade.span) && opts.heatmapFade.span > 0 ? opts.heatmapFade.span : 0.85,
|
|
411
|
+
// the integration scheme (substrate doc 04 §Step 3, #659); 'legacy' (default) is the shipped engine.
|
|
412
|
+
integrator: (opts.integrator === 'fixed' || opts.integrator === 'velocity-verlet'
|
|
413
|
+
? opts.integrator
|
|
414
|
+
: 'legacy'),
|
|
241
415
|
// ONE write path (#228, Phase 5): every feedback write goes through a sink. The platform
|
|
242
416
|
// supplies one (D3, FeedbackRegistry via <field-root>); without it the engine installs the
|
|
243
417
|
// internal default sink, whose writes are byte-identical to the historical direct writes.
|
|
@@ -268,23 +442,37 @@ export function createField(canvas, opts = {}) {
|
|
|
268
442
|
// the simulation + feedback signals stay live. Tab-level visibility is handled separately
|
|
269
443
|
// (onVisibility stops the loop entirely).
|
|
270
444
|
let canvasVisible = true;
|
|
271
|
-
|
|
445
|
+
// the resting `ambient` formation, with its two DECLARED dials applied (#978). Everything that
|
|
446
|
+
// targets `ambient` — the initial form, env.form, and the conductor's idle drift-back — resolves
|
|
447
|
+
// through this so the override is consistent. Defaults reproduce FORMATION_BY.ambient.preset.
|
|
448
|
+
const ambientForm = {
|
|
449
|
+
...FORMATION_BY.ambient.preset,
|
|
450
|
+
orbit: cfg.ambientOrbit,
|
|
451
|
+
wander: cfg.ambientWander,
|
|
452
|
+
};
|
|
453
|
+
let formTarget = { ...ambientForm };
|
|
272
454
|
let formationName = 'ambient'; // the active formation's id, for FieldHandle.query()
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
455
|
+
// First-class body identity (substrate critical path). Every body resolves to a stable, structured
|
|
456
|
+
// FieldBodyIdentity, cached on `b.identity` the first time it is keyed. Precedence: a supplied identity
|
|
457
|
+
// (addBody({ identity })) → the `identify` field-option resolver → the element's DOM id → a monotonic
|
|
458
|
+
// `body-N` synthetic. Deterministic (never Math.random); stable for the body's life, so relationship
|
|
459
|
+
// endpoints, body readings, snapshots, and diff/replay all agree on `identity.id`.
|
|
277
460
|
let bodyIdSeq = 0;
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
461
|
+
const identify = opts.identify;
|
|
462
|
+
const bodyIdentity = (b) => {
|
|
463
|
+
if (b.identity)
|
|
464
|
+
return b.identity;
|
|
465
|
+
let ident;
|
|
466
|
+
if (identify && b.el)
|
|
467
|
+
ident = identify(b.el);
|
|
468
|
+
if (!ident) {
|
|
469
|
+
const domId = b.el && b.el.id ? b.el.id : undefined;
|
|
470
|
+
ident = { id: domId ?? `body-${bodyIdSeq++}` };
|
|
471
|
+
}
|
|
472
|
+
b.identity = ident;
|
|
473
|
+
return ident;
|
|
287
474
|
};
|
|
475
|
+
const bodyId = (b) => bodyIdentity(b).id;
|
|
288
476
|
// Shared metric/dimension reading for query() and snapshot(), so both compute identically (diff
|
|
289
477
|
// compares snapshot metrics — they must agree with what query() reports).
|
|
290
478
|
const readBodyMetrics = (b) => {
|
|
@@ -357,6 +545,8 @@ export function createField(canvas, opts = {}) {
|
|
|
357
545
|
// kinematic bodies are never touched, so with no dynamic bodies this loop is a no-op.
|
|
358
546
|
const BODY_FRICTION = 0.9; // heavier than particle FRICTION so dynamic bodies settle, not drift forever
|
|
359
547
|
const MAX_BODY_SPEED = 8; // px/frame cap — keeps a dynamic body from flinging off under a strong well
|
|
548
|
+
const MASS_REF_AREA = 4800; // px² reference (~120×40 body) → inertia 1; sqrt+clamp keeps the range sane (#872)
|
|
549
|
+
const REACTION_COEFF = 0.02; // scales own-emission recoil (summed over matter in range); capped by MAX_BODY_SPEED (#873)
|
|
360
550
|
function moveDynamicBodies() {
|
|
361
551
|
for (const b of bodies) {
|
|
362
552
|
if (b.authority !== 'dynamic' || !b.vis)
|
|
@@ -374,9 +564,34 @@ export function createField(canvas, opts = {}) {
|
|
|
374
564
|
// doesn't recoil from its own singular centre).
|
|
375
565
|
const others = bodies.filter((o) => o !== b && o.vis && o.tokens.length > 0);
|
|
376
566
|
const { fx, fy } = forceAt(others, reg.forces, env, bx, by);
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
567
|
+
// INERTIAL mass (#872): under first-class `mass`, a body's resistance to motion ∝ rendered area
|
|
568
|
+
// (sqrt, clamped) — a big heading settles slowly, a small tag snaps. Otherwise inertia stays
|
|
569
|
+
// undefined and recoil falls back to the source mass M (today's behavior, byte-identical). a = F/inertia.
|
|
570
|
+
if (cfg.mass) {
|
|
571
|
+
const area = b.hw * 2 * (b.hh * 2);
|
|
572
|
+
b.inertia = Math.min(4, Math.max(0.4, Math.sqrt(area / MASS_REF_AREA)));
|
|
573
|
+
}
|
|
574
|
+
const invM = 1 / Math.max(b.inertia ?? b.M, 1e-4);
|
|
575
|
+
// Newtonian own-emission reaction (#873): B feels the equal-and-opposite of the net impulse it
|
|
576
|
+
// imparts to nearby matter — a directional emitter recoils like a rocket, closing reciprocity
|
|
577
|
+
// through *motion*, not just feedback. Off by default (byte-identical). Best paired with `mass`.
|
|
578
|
+
let reFx = 0, reFy = 0;
|
|
579
|
+
if (cfg.reaction && b.tokens.length > 0) {
|
|
580
|
+
const r2 = b.range * b.range;
|
|
581
|
+
const self = [b];
|
|
582
|
+
for (const p of store.particles) {
|
|
583
|
+
if (p.cap)
|
|
584
|
+
continue;
|
|
585
|
+
const ddx = p.x - bx, ddy = p.y - by;
|
|
586
|
+
if (ddx * ddx + ddy * ddy > r2)
|
|
587
|
+
continue; // only matter B can actually push
|
|
588
|
+
const bf = forceAt(self, reg.forces, env, p.x, p.y);
|
|
589
|
+
reFx -= bf.fx; // third law: the body gets the opposite of what it pushes
|
|
590
|
+
reFy -= bf.fy;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
let vx = (b.bvx ?? 0) + (fx + reFx * REACTION_COEFF) * invM * env.dt;
|
|
594
|
+
let vy = (b.bvy ?? 0) + (fy + reFy * REACTION_COEFF) * invM * env.dt;
|
|
380
595
|
vx *= BODY_FRICTION;
|
|
381
596
|
vy *= BODY_FRICTION;
|
|
382
597
|
const sp = Math.hypot(vx, vy);
|
|
@@ -423,6 +638,7 @@ export function createField(canvas, opts = {}) {
|
|
|
423
638
|
let lastNow = NaN; // previous frame timestamp — drives the frame-rate-independent dt (#434)
|
|
424
639
|
let mball = null; // scratch density grid for the metaballs render mode
|
|
425
640
|
let vor = null; // scratch owner grid for the voronoi render mode
|
|
641
|
+
let depthIdx = null; // scratch draw-order index for the depth render mode (far → near)
|
|
426
642
|
// EMA (exponential moving average) of the per-frame peak magnitude for each arrow renderer.
|
|
427
643
|
// Normalizing to the raw frame max caused the entire arrow field to rescale in one step when
|
|
428
644
|
// maxMag shifted (body drag, animated strength, density ramp) — visible as a pulsing flash.
|
|
@@ -488,7 +704,7 @@ export function createField(canvas, opts = {}) {
|
|
|
488
704
|
// clear the field's CSS write-back when a still-connected host leaves the field, so it
|
|
489
705
|
// doesn't keep a frozen `--d` glow (a removed light-DOM element is gone, so it needs no clear).
|
|
490
706
|
const clearWriteback = (el) => {
|
|
491
|
-
for (const v of ['--d', '--field-density', '--load', '--
|
|
707
|
+
for (const v of ['--d', '--field-density', '--load', '--entropy', '--coherence', '--temperature'])
|
|
492
708
|
el.style.removeProperty(v);
|
|
493
709
|
};
|
|
494
710
|
const onRegister = (e) => {
|
|
@@ -514,11 +730,11 @@ export function createField(canvas, opts = {}) {
|
|
|
514
730
|
dy: 0,
|
|
515
731
|
dz: 0,
|
|
516
732
|
dist: 1,
|
|
517
|
-
form: { ...
|
|
733
|
+
form: { ...ambientForm },
|
|
518
734
|
W: 0,
|
|
519
735
|
H: 0,
|
|
520
736
|
D: cfg.depth, // the optional z volume (z-axis.md); 0 = the flat field
|
|
521
|
-
integrator: cfg.integrator, // 'legacy' (default) | 'fixed' (doc 04 §Step 3)
|
|
737
|
+
integrator: cfg.integrator, // 'legacy' (default) | 'fixed' (doc 04 §Step 3) | 'velocity-verlet' (#659)
|
|
522
738
|
t: 0,
|
|
523
739
|
frameN: 0,
|
|
524
740
|
dt: reduceMotion ? 0 : 1,
|
|
@@ -560,8 +776,8 @@ export function createField(canvas, opts = {}) {
|
|
|
560
776
|
if (b.el.dataset.fxCap === '1') {
|
|
561
777
|
b.el.dataset.fxCap = '0';
|
|
562
778
|
fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
|
|
563
|
-
if (busHas('
|
|
564
|
-
busEmit('
|
|
779
|
+
if (busHas('released'))
|
|
780
|
+
busEmit('released', { body: b, count: released.length });
|
|
565
781
|
sinkPeak.delete(b);
|
|
566
782
|
}
|
|
567
783
|
},
|
|
@@ -592,7 +808,7 @@ export function createField(canvas, opts = {}) {
|
|
|
592
808
|
if (reduceMotion || sparks.length > 260)
|
|
593
809
|
return;
|
|
594
810
|
const c = color ? hexToRgb(color) : [255, 122, 69]; // WARM default (§20.8)
|
|
595
|
-
const n = sparkCount(power);
|
|
811
|
+
const n = sparkCount(power, rng); // count through the injected rng too (#371) — directions already are
|
|
596
812
|
for (let k = 0; k < n; k++) {
|
|
597
813
|
const a = rng() * 6.28318;
|
|
598
814
|
const s = 0.8 + rng() * (power > 0 ? power : 1) * 1.7;
|
|
@@ -644,12 +860,15 @@ export function createField(canvas, opts = {}) {
|
|
|
644
860
|
for (let i = 0; i < n; i++)
|
|
645
861
|
store.add(newParticle());
|
|
646
862
|
applySeed();
|
|
647
|
-
// the Currents (§24) are
|
|
863
|
+
// the Currents (§24) are OPT-IN (#979): by default the field is just the free particles.
|
|
648
864
|
waves = cfg.waves ? buildWaves(cfg.waveBaseline) : [];
|
|
649
865
|
bound = cfg.waves ? buildBound(waves.length, cfg.density, rng) : [];
|
|
650
866
|
boundTarget = bound.length;
|
|
651
867
|
}
|
|
652
868
|
function scan() {
|
|
869
|
+
// capture the outgoing generation FIRST — the rebuild below replaces every DOM-scanned Body
|
|
870
|
+
// object wholesale, and the reconciliation pass after the rebuild carries runtime state across.
|
|
871
|
+
const prevGen = bodies;
|
|
653
872
|
const scanned = scanBodies(host.root);
|
|
654
873
|
// merge event-registered shadow-DOM hosts (deduped — a light-DOM host that also fires
|
|
655
874
|
// a registration event is counted once). Registration is the canonical discovery path;
|
|
@@ -664,6 +883,104 @@ export function createField(canvas, opts = {}) {
|
|
|
664
883
|
// programmatic bodies (addBody) aren't discoverable by the scan — carry them across the rebuild.
|
|
665
884
|
if (programmaticBodies.length > 0)
|
|
666
885
|
bodies = bodies.concat(programmaticBodies);
|
|
886
|
+
// ——— Rescan reconciliation (#966): carry body feedback state + remap captures. ———
|
|
887
|
+
// makeBody zeroes runtime state (`d: 0`, `accreted: 0`, …), which made ANY rescan a visible
|
|
888
|
+
// discontinuity: every data-feedback body's --d hard-dropped (measured 1.000 → 0.080) and eased
|
|
889
|
+
// back over ~1s, while matter a sink had captured stayed pinned to the OLD (ghost) Body — frozen,
|
|
890
|
+
// never released — as the rebuilt sink re-captured a second full capacity. Mirror the
|
|
891
|
+
// prevMovers/prevEmitters reconciliation: key the outgoing generation by (element, per-element
|
|
892
|
+
// body index) — a data-preset element expands to several virtual bodies in a stable order, so
|
|
893
|
+
// the index tells them apart — and carry each persisting body's runtime feedback state
|
|
894
|
+
// (`d`, `attn`, `accreted`, `count`, `wasOn`) onto its replacement. Programmatic (addBody)
|
|
895
|
+
// bodies persist by object identity (ob === nb) and skip naturally. Removed elements are simply
|
|
896
|
+
// absent from the new generation, so their state drops with the old Body (no leak).
|
|
897
|
+
//
|
|
898
|
+
// Body-keyed side tables — per-map carry/reset decisions:
|
|
899
|
+
// sinkPeak CARRY — the armed flag lives on el.dataset.fxCap (which survives the
|
|
900
|
+
// rescan) and `accreted` is carried, so a filling sink is still mid-cycle;
|
|
901
|
+
// dropping the peak would make the eventual `release` bus event report 0.
|
|
902
|
+
// bodyThresholds CARRY — `d`/`attn` are carried, so keeping the hysteresis + debounce state
|
|
903
|
+
// avoids a duplicate rising-edge `field:*` event on the first post-rescan
|
|
904
|
+
// frame. (Entries for removed bodies drop with the Body — WeakMap keys.)
|
|
905
|
+
// insideOf/metWith RESET (by design) — proximity membership re-derives from live geometry on
|
|
906
|
+
// the next detection pass; carrying it would need old→new remaps of both
|
|
907
|
+
// keys and set members for marginal benefit.
|
|
908
|
+
// emitAcc/thermo/metrics RESET (by design) — the fractional-emission remainder and the
|
|
909
|
+
// windowed thermodynamic measurements re-accumulate within a frame or two.
|
|
910
|
+
let bodyRemap = null; // old → new, for the capture remaps below
|
|
911
|
+
let remapCaptures = false; // some persisting body held matter → run the O(P) cap-remap pass
|
|
912
|
+
if (prevGen.length > 0) {
|
|
913
|
+
const prevByEl = new Map();
|
|
914
|
+
for (const ob of prevGen) {
|
|
915
|
+
const list = prevByEl.get(ob.el);
|
|
916
|
+
if (list)
|
|
917
|
+
list.push(ob);
|
|
918
|
+
else
|
|
919
|
+
prevByEl.set(ob.el, [ob]);
|
|
920
|
+
}
|
|
921
|
+
const nextIdx = new Map();
|
|
922
|
+
for (const nb of bodies) {
|
|
923
|
+
const list = prevByEl.get(nb.el);
|
|
924
|
+
if (!list)
|
|
925
|
+
continue;
|
|
926
|
+
const i = nextIdx.get(nb.el) ?? 0;
|
|
927
|
+
nextIdx.set(nb.el, i + 1);
|
|
928
|
+
const ob = list[i];
|
|
929
|
+
if (!ob || ob === nb)
|
|
930
|
+
continue; // index outgrew the old expansion, or same object (addBody)
|
|
931
|
+
// IDENTITY (#970): carry the resolved identity so an anonymous body keeps its synthetic
|
|
932
|
+
// `body-N` across rescans. Without this the rebuilt Body has `identity: undefined` and
|
|
933
|
+
// bodyIdentity() mints a FRESH `body-${bodyIdSeq++}` — the same element's id churned on
|
|
934
|
+
// every rescan, breaking snapshot/diff/replay continuity for anonymous bodies (state carry,
|
|
935
|
+
// captures, and relationship edges all key on identity.id). Keyed by the same (element,
|
|
936
|
+
// per-element index) as the state carry, so a preset's virtual bodies keep their ids too.
|
|
937
|
+
if (ob.identity !== undefined)
|
|
938
|
+
nb.identity = ob.identity;
|
|
939
|
+
// DYNAMIC MOTION (#970): a `data-authority="dynamic"` body's position + velocity are
|
|
940
|
+
// engine-owned (bx/by/bvx/bvy), not DOM-derived. makeBody leaves them undefined, so on
|
|
941
|
+
// rescan moveDynamicBodies() re-adopts the freshly-measured DOM centre and zeroes velocity
|
|
942
|
+
// — a drifting body teleported back to its authored slot when ANY body was added/removed.
|
|
943
|
+
// Carry the kinematic state so the body keeps moving through a rescan (the motion analog of
|
|
944
|
+
// the feedback-state carry above). Only meaningful for dynamic bodies; undefined otherwise.
|
|
945
|
+
if (ob.bx !== undefined) {
|
|
946
|
+
nb.bx = ob.bx;
|
|
947
|
+
nb.by = ob.by;
|
|
948
|
+
nb.bvx = ob.bvx;
|
|
949
|
+
nb.bvy = ob.bvy;
|
|
950
|
+
// measureBodies (below) overwrites cx/cy from the rect, but the next moveDynamicBodies()
|
|
951
|
+
// re-asserts the carried bx/by as authoritative — so the body resumes from where it was.
|
|
952
|
+
}
|
|
953
|
+
nb.d = ob.d;
|
|
954
|
+
if (ob.attn !== undefined)
|
|
955
|
+
nb.attn = ob.attn;
|
|
956
|
+
nb.accreted = ob.accreted;
|
|
957
|
+
nb.count = ob.count;
|
|
958
|
+
if (ob.wasOn !== undefined)
|
|
959
|
+
nb.wasOn = ob.wasOn;
|
|
960
|
+
const th = bodyThresholds.get(ob);
|
|
961
|
+
if (th)
|
|
962
|
+
bodyThresholds.set(nb, th);
|
|
963
|
+
const peak = sinkPeak.get(ob);
|
|
964
|
+
if (peak !== undefined)
|
|
965
|
+
sinkPeak.set(nb, peak);
|
|
966
|
+
(bodyRemap ??= new Map()).set(ob, nb);
|
|
967
|
+
if (ob.accreted > 0)
|
|
968
|
+
remapCaptures = true;
|
|
969
|
+
}
|
|
970
|
+
// Remap captured matter onto the replacement Body (the ghost-body strand: `p.cap` kept
|
|
971
|
+
// lerping to the old object's frozen centre — integrator.ts holds captured matter via
|
|
972
|
+
// `p.cap.cx/cy` — and `releaseCaptured` on the new body never matched it). One O(P) pass,
|
|
973
|
+
// and only when some persisting body actually held matter.
|
|
974
|
+
if (remapCaptures) {
|
|
975
|
+
for (const p of store.particles) {
|
|
976
|
+
if (!p.cap)
|
|
977
|
+
continue;
|
|
978
|
+
const nb = bodyRemap.get(p.cap);
|
|
979
|
+
if (nb)
|
|
980
|
+
p.cap = nb;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
667
984
|
measureBodies(bodies, W, H, originX, originY);
|
|
668
985
|
bindEngagement();
|
|
669
986
|
// Reconcile movers: carry forward offset + dock state for elements that persist across
|
|
@@ -689,7 +1006,11 @@ export function createField(canvas, opts = {}) {
|
|
|
689
1006
|
if (prev) {
|
|
690
1007
|
// Persist the in-flight state: the element was already known, keep its offset + dock
|
|
691
1008
|
// progress. Re-check dockable/warpable/layout in case attributes changed. mEl re-measured.
|
|
692
|
-
|
|
1009
|
+
// A docked element's sink reference is remapped to the sink's replacement Body (#966) —
|
|
1010
|
+
// otherwise it would hold the OLD generation's object and `undockFrom` (supernova release)
|
|
1011
|
+
// on the new body would never release it.
|
|
1012
|
+
const docked = prev.docked ? (bodyRemap?.get(prev.docked) ?? prev.docked) : null;
|
|
1013
|
+
return { el, o: prev.o, mEl, layout, dockable, dock: prev.dock, docked, warpable, warpCool: prev.warpCool };
|
|
693
1014
|
}
|
|
694
1015
|
return { el, o: { x: 0, y: 0, vx: 0, vy: 0 }, mEl, layout, dockable, dock: { dock: 0 }, docked: null, warpable, warpCool: 0 };
|
|
695
1016
|
});
|
|
@@ -854,10 +1175,9 @@ export function createField(canvas, opts = {}) {
|
|
|
854
1175
|
}
|
|
855
1176
|
}
|
|
856
1177
|
}
|
|
857
|
-
// dispatch a discrete field event on an element
|
|
1178
|
+
// dispatch a discrete field event on an element.
|
|
858
1179
|
function fireCaptureEvent(el, name, detail) {
|
|
859
1180
|
el.dispatchEvent(new CustomEvent('field:' + name, { bubbles: true, composed: true, detail }));
|
|
860
|
-
el.dispatchEvent(new CustomEvent('forces:' + name, { bubbles: true, composed: true, detail }));
|
|
861
1181
|
}
|
|
862
1182
|
// capture/release events for sink BODIES (particle accretion): fire field:captured on the rising
|
|
863
1183
|
// edge of accreting and field:released on the falling edge (§22.5). Release is also fired directly
|
|
@@ -871,15 +1191,15 @@ export function createField(canvas, opts = {}) {
|
|
|
871
1191
|
if (edge.fire === 'captured') {
|
|
872
1192
|
b.el.dataset.fxCap = '1';
|
|
873
1193
|
fireCaptureEvent(b.el, 'captured', { accreted: b.accreted, load: sinkLoad(b) });
|
|
874
|
-
if (busHas('
|
|
875
|
-
busEmit('
|
|
1194
|
+
if (busHas('captured'))
|
|
1195
|
+
busEmit('captured', { body: b, count: b.accreted });
|
|
876
1196
|
sinkPeak.set(b, b.accreted);
|
|
877
1197
|
}
|
|
878
1198
|
else if (edge.fire === 'released') {
|
|
879
1199
|
b.el.dataset.fxCap = '0';
|
|
880
1200
|
fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
|
|
881
|
-
if (busHas('
|
|
882
|
-
busEmit('
|
|
1201
|
+
if (busHas('released'))
|
|
1202
|
+
busEmit('released', { body: b, count: sinkPeak.get(b) ?? 0 });
|
|
883
1203
|
sinkPeak.delete(b);
|
|
884
1204
|
}
|
|
885
1205
|
}
|
|
@@ -1071,6 +1391,14 @@ export function createField(canvas, opts = {}) {
|
|
|
1071
1391
|
}
|
|
1072
1392
|
// engagement: hover/focus a [data-hot] element → it activates (b.on, lighting
|
|
1073
1393
|
// the spine + on-state forces) and overrides the accent with its data-color (§9).
|
|
1394
|
+
// Keyboard parity (a11y, #665): a [data-hot] body is often a container (card, row, <li>,
|
|
1395
|
+
// <aside>) whose focusable content is a CHILD (<a>/<button>). Pointer engagement uses
|
|
1396
|
+
// `pointerenter`, which fires when the cursor enters the container's box — so a mouse user
|
|
1397
|
+
// engages it. But focus does NOT bubble, so `focus`/`blur` on the container would never fire
|
|
1398
|
+
// when a keyboard user tabbed to a descendant, and the body stayed dark for keyboard-only
|
|
1399
|
+
// users. We bind the bubbling `focusin`/`focusout` instead, so tabbing into (or out of) any
|
|
1400
|
+
// focusable descendant engages the body exactly the way hover does — the RC-8 principle that
|
|
1401
|
+
// keyboard users get the same field reactions as the mouse.
|
|
1074
1402
|
function bindEngagement() {
|
|
1075
1403
|
// Reconcile across rescans (mirrors the emitter prune above): a persistent field outlives the
|
|
1076
1404
|
// [data-hot] elements swapped under it (Astro nav, dynamic content), so drop engagements whose
|
|
@@ -1083,8 +1411,8 @@ export function createField(canvas, opts = {}) {
|
|
|
1083
1411
|
return true;
|
|
1084
1412
|
e.el.removeEventListener('pointerenter', e.enter);
|
|
1085
1413
|
e.el.removeEventListener('pointerleave', e.leave);
|
|
1086
|
-
e.el.removeEventListener('
|
|
1087
|
-
e.el.removeEventListener('
|
|
1414
|
+
e.el.removeEventListener('focusin', e.enter);
|
|
1415
|
+
e.el.removeEventListener('focusout', e.leave);
|
|
1088
1416
|
delete e.el.dataset.fxEngaged;
|
|
1089
1417
|
return false;
|
|
1090
1418
|
});
|
|
@@ -1110,8 +1438,11 @@ export function createField(canvas, opts = {}) {
|
|
|
1110
1438
|
};
|
|
1111
1439
|
el.addEventListener('pointerenter', enter);
|
|
1112
1440
|
el.addEventListener('pointerleave', leave);
|
|
1113
|
-
|
|
1114
|
-
|
|
1441
|
+
// focusin/focusout (not focus/blur) so focus on a focusable DESCENDANT of a [data-hot]
|
|
1442
|
+
// container engages the body — keyboard parity with pointerenter (#665). These bubble;
|
|
1443
|
+
// focus/blur do not.
|
|
1444
|
+
el.addEventListener('focusin', enter);
|
|
1445
|
+
el.addEventListener('focusout', leave);
|
|
1115
1446
|
engaged.push({ el, enter, leave });
|
|
1116
1447
|
});
|
|
1117
1448
|
}
|
|
@@ -1186,7 +1517,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1186
1517
|
sizeSurfaces(vp.dpr);
|
|
1187
1518
|
env.W = W;
|
|
1188
1519
|
env.H = H;
|
|
1189
|
-
maxScroll = host.scrollHeight() - H || 1;
|
|
1520
|
+
maxScroll = (host.scrollHeight?.() ?? H) - H || 1;
|
|
1190
1521
|
for (const g of grids.values())
|
|
1191
1522
|
g.resize(W, H); // keep field buffers viewport-sized
|
|
1192
1523
|
if (cfg.heatmap) {
|
|
@@ -1435,7 +1766,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1435
1766
|
// ONE write path (#228): the CSS-var channels always go to the sink — the platform's
|
|
1436
1767
|
// FeedbackRegistry route (D3) when configured, otherwise the internal default sink
|
|
1437
1768
|
// (feedback-sink.ts), which performs the same direct writes the engine always made:
|
|
1438
|
-
// `--d`/`--field-density`, `--field-heatmap-density`, `--load
|
|
1769
|
+
// `--d`/`--field-density`, `--field-heatmap-density`, `--load`,
|
|
1439
1770
|
// plus the measured `--entropy`/`--coherence`/`--temperature`.
|
|
1440
1771
|
const channels = {
|
|
1441
1772
|
density: b.d,
|
|
@@ -1494,13 +1825,20 @@ export function createField(canvas, opts = {}) {
|
|
|
1494
1825
|
// MONOTONIC function of scroll POSITION — full through the top of the page, gone by ~1.15 viewports
|
|
1495
1826
|
// — so it never flickers; and below the hero the whole layer is skipped (the #409 at-rest upscale
|
|
1496
1827
|
// cost the heatmap is otherwise paying every frame for a glow you can't focus on mid-page).
|
|
1497
|
-
|
|
1828
|
+
// DECLARED scroll-fade curve (#975): full above start·H, gone by (start+span)·H — formerly the
|
|
1829
|
+
// hardcoded (1.15 - scrollY/H)/0.85 (start=0.3, span=0.85 reproduce it, so it stays byte-identical).
|
|
1830
|
+
const hmFade = H > 0 ? clamp((cfg.heatmapFadeStart + cfg.heatmapFadeSpan - lastScrollY / H) / cfg.heatmapFadeSpan, 0, 1) : 1;
|
|
1498
1831
|
if (hmFade <= 0.01)
|
|
1499
1832
|
return;
|
|
1500
1833
|
const cell = heatmap.cell;
|
|
1501
1834
|
const cols = Math.max(1, Math.ceil(W / cell));
|
|
1502
1835
|
const rows = Math.max(1, Math.ceil(H / cell));
|
|
1503
1836
|
if (!hmCanvas) {
|
|
1837
|
+
if (!host.createCanvas) {
|
|
1838
|
+
// a drawing mode reached the heatmap buffer but the host has no canvas capability; a
|
|
1839
|
+
// signals-first (render:'none') field never gets here. Fail loud rather than draw nothing.
|
|
1840
|
+
throw new Error("Fundamental: this FieldHost provides no createCanvas() — the heatmap layer needs one. Use render:'none' (signals-first) or supply a host with a createCanvas capability.");
|
|
1841
|
+
}
|
|
1504
1842
|
hmCanvas = host.createCanvas();
|
|
1505
1843
|
hmCtx = hmCanvas.getContext('2d');
|
|
1506
1844
|
}
|
|
@@ -1564,22 +1902,31 @@ export function createField(canvas, opts = {}) {
|
|
|
1564
1902
|
ctx.fillRect(0, 0, W, H);
|
|
1565
1903
|
}
|
|
1566
1904
|
drawWaves();
|
|
1567
|
-
// The heatmap is
|
|
1568
|
-
//
|
|
1569
|
-
// read as choppy; the
|
|
1570
|
-
//
|
|
1905
|
+
// The heatmap is an ambient layer that fades out with scroll POSITION (the DECLARED `heatmapFade`
|
|
1906
|
+
// curve above), not with scroll SPEED. An earlier speed-based suppression made it pop/fade in/out
|
|
1907
|
+
// while scrolling, which read as choppy; the position fade is monotonic so it never flickers, and
|
|
1908
|
+
// the per-frame cost is just the cached bilinear upscale (the texel grid recomputes every 3rd
|
|
1909
|
+
// frame). Below the fade window the whole layer is skipped.
|
|
1571
1910
|
if (heatmap && qualityTier < 2)
|
|
1572
1911
|
drawHeatmap(); // #413: drop the heaviest ambient layer at tier 2+
|
|
1573
1912
|
drawBound();
|
|
1574
1913
|
// free particles — cool centre → warm edge, blended toward accent (§20.8).
|
|
1575
1914
|
// metaballs (a molten iso-surface skin) and streamlines (the bare force field) REPLACE
|
|
1576
1915
|
// the matter per §20.6, so suppress the dot swarm for those two; dots/trails/links/voronoi
|
|
1577
|
-
// keep it (their overlays read against the particles).
|
|
1578
|
-
|
|
1916
|
+
// keep it (their overlays read against the particles). The four matter-swap modes
|
|
1917
|
+
// (knockout / redshift / blackbody / depth, #667–#670) draw their own particle pass
|
|
1918
|
+
// below — same suppression, different material.
|
|
1919
|
+
const showMatter = cfg.render !== 'metaballs' &&
|
|
1920
|
+
cfg.render !== 'streamlines' &&
|
|
1921
|
+
cfg.render !== 'knockout' &&
|
|
1922
|
+
cfg.render !== 'redshift' &&
|
|
1923
|
+
cfg.render !== 'blackbody' &&
|
|
1924
|
+
cfg.render !== 'depth';
|
|
1579
1925
|
ctx.globalCompositeOperation = 'lighter';
|
|
1580
1926
|
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1581
|
-
|
|
1582
|
-
const
|
|
1927
|
+
// DECLARED heat-vignette center (#975): viewport fractions → px, formerly the hardcoded (W/2, H·0.4).
|
|
1928
|
+
const cx = W * cfg.heatCenterX;
|
|
1929
|
+
const cy = H * cfg.heatCenterY;
|
|
1583
1930
|
const maxD = Math.hypot(Math.max(cx, W - cx), Math.max(cy, H - cy)) || 1;
|
|
1584
1931
|
// Tag-tint: every body carrying a colour stains the swarm toward its tint by proximity — a
|
|
1585
1932
|
// pervasive, render-time companion to the overlap-only `pigment` force, so a particle near a
|
|
@@ -1780,6 +2127,148 @@ export function createField(canvas, opts = {}) {
|
|
|
1780
2127
|
ctx.stroke();
|
|
1781
2128
|
ctx.globalCompositeOperation = 'source-over';
|
|
1782
2129
|
}
|
|
2130
|
+
// knockout (#667): figure-ground inversion — the field is a solid accent sheet and matter
|
|
2131
|
+
// is NEGATIVE space: each particle erases a feathered hole through everything beneath it
|
|
2132
|
+
// (waves, heatmap, sparks — a true print knockout). §11-safe by construction: matter never
|
|
2133
|
+
// assembles into letterforms; for the "field visible only inside letters" treatment the
|
|
2134
|
+
// host clips this canvas to real type with a CSS mask/clip-path — the type stays type and
|
|
2135
|
+
// the field shows through it. One full-canvas wash (the clear's cost class) + two arcs per
|
|
2136
|
+
// particle (the dots-mode cost envelope); no extra surface, no mix-blend.
|
|
2137
|
+
if (cfg.render === 'knockout') {
|
|
2138
|
+
const wash = curAccent;
|
|
2139
|
+
ctx.fillStyle = `rgba(${wash[0]},${wash[1]},${wash[2]},${0.22 * boot})`;
|
|
2140
|
+
ctx.fillRect(0, 0, W, H);
|
|
2141
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
2142
|
+
for (const p of store.particles) {
|
|
2143
|
+
if (p.cap)
|
|
2144
|
+
continue;
|
|
2145
|
+
const zk = cfg.depth > 0 ? 1 - Math.min(Math.abs(p.z ?? 0) / cfg.depth, 1) * 0.55 : 1;
|
|
2146
|
+
const hole = knockoutHoleRadius(p.size, p.heat, zk);
|
|
2147
|
+
// feathered punch: a soft rim then a hard core (destination-out reads only the alpha)
|
|
2148
|
+
ctx.fillStyle = `rgba(0,0,0,${0.38 * boot})`;
|
|
2149
|
+
ctx.beginPath();
|
|
2150
|
+
ctx.arc(p.x, p.y, hole + 2.2, 0, 6.28318);
|
|
2151
|
+
ctx.fill();
|
|
2152
|
+
ctx.fillStyle = `rgba(0,0,0,${boot})`;
|
|
2153
|
+
ctx.beginPath();
|
|
2154
|
+
ctx.arc(p.x, p.y, hole, 0, 6.28318);
|
|
2155
|
+
ctx.fill();
|
|
2156
|
+
}
|
|
2157
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
2158
|
+
}
|
|
2159
|
+
// redshift (#668): the dots geometry tinted by SPECTRAL SHIFT instead of the heat ramp —
|
|
2160
|
+
// the Doppler term reads each particle's radial velocity against an observer at the
|
|
2161
|
+
// viewport centre (receding reds, approaching blues, normalized by the unit system's
|
|
2162
|
+
// velocity cap env.c), and the gravitational term reads proximity to body wells (light
|
|
2163
|
+
// climbing out loses energy, so a well only reddens). The §20.6 "relativistic
|
|
2164
|
+
// accretion-disk" look: matter falling around a sink wears its infall.
|
|
2165
|
+
if (cfg.render === 'redshift') {
|
|
2166
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
2167
|
+
// DECLARED observer (#975): viewport fractions → px, formerly the hardcoded (W/2, H/2).
|
|
2168
|
+
const ox = W * cfg.redshiftObserverX;
|
|
2169
|
+
const oy = H * cfg.redshiftObserverY;
|
|
2170
|
+
for (const p of store.particles) {
|
|
2171
|
+
if (p.cap)
|
|
2172
|
+
continue;
|
|
2173
|
+
let well = 0;
|
|
2174
|
+
for (const b of bodies) {
|
|
2175
|
+
const reach = b.range || 200;
|
|
2176
|
+
const dx = p.x - b.cx;
|
|
2177
|
+
const dy = p.y - b.cy;
|
|
2178
|
+
const w = wellWeight(dx * dx + dy * dy, reach * reach);
|
|
2179
|
+
if (w > well)
|
|
2180
|
+
well = w;
|
|
2181
|
+
}
|
|
2182
|
+
const s = redshiftShift(dopplerShift(radialVelocity(p.x, p.y, p.vx, p.vy, ox, oy), env.c), well);
|
|
2183
|
+
redshiftRGBInto(_rgb, s);
|
|
2184
|
+
const zk = cfg.depth > 0 ? 1 - Math.min(Math.abs(p.z ?? 0) / cfg.depth, 1) * 0.55 : 1;
|
|
2185
|
+
const mag = s < 0 ? -s : s;
|
|
2186
|
+
const size = (p.size + mag * 1.6) * zk;
|
|
2187
|
+
const alpha = clamp((0.4 + 0.45 * mag) * boot * zk, 0, 1);
|
|
2188
|
+
ctx.fillStyle = `rgba(${_rgb[0] | 0},${_rgb[1] | 0},${_rgb[2] | 0},${alpha})`;
|
|
2189
|
+
ctx.beginPath();
|
|
2190
|
+
ctx.arc(p.x, p.y, size, 0, 6.28318);
|
|
2191
|
+
ctx.fill();
|
|
2192
|
+
}
|
|
2193
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
2194
|
+
}
|
|
2195
|
+
// blackbody (#669): thermal truth — each particle tinted by its ENERGY on a Planckian-ish
|
|
2196
|
+
// ramp (near-black ember → deep red → orange → warm white → blue-white), brightness rising
|
|
2197
|
+
// with temperature so cold matter barely glows and hot matter reads white (§20.6). Energy =
|
|
2198
|
+
// carried heat + kinetic (|v|² against a 0.3·env.c reference). Caveat canon: a reading of
|
|
2199
|
+
// the designed unit system, not radiometry.
|
|
2200
|
+
if (cfg.render === 'blackbody') {
|
|
2201
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
2202
|
+
for (const p of store.particles) {
|
|
2203
|
+
if (p.cap)
|
|
2204
|
+
continue;
|
|
2205
|
+
const t = blackbodyT(p.vx, p.vy, p.heat, env.c);
|
|
2206
|
+
blackbodyRGBInto(_rgb, t);
|
|
2207
|
+
const zk = cfg.depth > 0 ? 1 - Math.min(Math.abs(p.z ?? 0) / cfg.depth, 1) * 0.55 : 1;
|
|
2208
|
+
const size = (p.size + t * 2.2) * zk;
|
|
2209
|
+
const alpha = clamp((0.16 + 0.84 * t) * boot * zk, 0, 1);
|
|
2210
|
+
const cr = _rgb[0] | 0;
|
|
2211
|
+
const cg = _rgb[1] | 0;
|
|
2212
|
+
const cb = _rgb[2] | 0;
|
|
2213
|
+
// hot matter blooms: a soft halo under the crisp core, scaled by temperature
|
|
2214
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.14 * alpha})`;
|
|
2215
|
+
ctx.beginPath();
|
|
2216
|
+
ctx.arc(p.x, p.y, size + 1.4 + t * 2, 0, 6.28318);
|
|
2217
|
+
ctx.fill();
|
|
2218
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
|
|
2219
|
+
ctx.beginPath();
|
|
2220
|
+
ctx.arc(p.x, p.y, size, 0, 6.28318);
|
|
2221
|
+
ctx.fill();
|
|
2222
|
+
}
|
|
2223
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
2224
|
+
}
|
|
2225
|
+
// depth (#670): the z lane made visible — true 2.5D. Particles are sorted far-to-near
|
|
2226
|
+
// (painter's algorithm, drawn source-over so near matter OCCLUDES far — additive 'lighter'
|
|
2227
|
+
// would make the order meaningless), projected toward the viewport centre by a perspective
|
|
2228
|
+
// scale (motion parallax emerges as z integrates), and defocused with distance: a wider
|
|
2229
|
+
// soft halo + a faded core, the cheap draw-time stand-in for blur (no ctx.filter, which
|
|
2230
|
+
// costs a composited surface per particle). In a flat field (depth: 0, z ≡ 0) every factor
|
|
2231
|
+
// is exactly 1 and this is the dots pass in painter's order. The index array is a persistent
|
|
2232
|
+
// scratch (reallocated only when the count changes) — no per-frame allocation.
|
|
2233
|
+
if (cfg.render === 'depth') {
|
|
2234
|
+
const parts = store.particles;
|
|
2235
|
+
const n = parts.length;
|
|
2236
|
+
if (!depthIdx || depthIdx.length !== n)
|
|
2237
|
+
depthIdx = new Array(n);
|
|
2238
|
+
for (let i = 0; i < n; i++)
|
|
2239
|
+
depthIdx[i] = i;
|
|
2240
|
+
depthIdx.sort((a, b) => Math.abs(parts[b].z ?? 0) - Math.abs(parts[a].z ?? 0)); // far first
|
|
2241
|
+
const FOCAL = cfg.depthFocal; // DECLARED (#975): px focal length, formerly hardcoded 480.
|
|
2242
|
+
const ox = W / 2; // projection center = viewport center (perspective principal point).
|
|
2243
|
+
const oy = H / 2;
|
|
2244
|
+
for (let i = 0; i < n; i++) {
|
|
2245
|
+
const p = parts[depthIdx[i]];
|
|
2246
|
+
if (p.cap)
|
|
2247
|
+
continue;
|
|
2248
|
+
const z = p.z ?? 0;
|
|
2249
|
+
const zn = cfg.depth > 0 ? Math.min(Math.abs(z) / cfg.depth, 1) : 0;
|
|
2250
|
+
const scale = depthScale(z, FOCAL);
|
|
2251
|
+
const px = depthProject(p.x, ox, scale);
|
|
2252
|
+
const py = depthProject(p.y, oy, scale);
|
|
2253
|
+
const d = Math.min(1, Math.hypot(px - cx, py - cy) / maxD);
|
|
2254
|
+
const rs = d * d;
|
|
2255
|
+
const h = p.heat;
|
|
2256
|
+
particleRGBInto(_rgb, rs, h, acc, cfg.gradientCool, cfg.gradientWarm);
|
|
2257
|
+
const size = (p.size * (1 - 0.4 * rs) + h * 2) * scale;
|
|
2258
|
+
const alpha = clamp((0.5 - 0.3 * rs + h * 0.5) * boot * depthAlpha(zn), 0, 1);
|
|
2259
|
+
const cr = _rgb[0] | 0;
|
|
2260
|
+
const cg = _rgb[1] | 0;
|
|
2261
|
+
const cb = _rgb[2] | 0;
|
|
2262
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.12 * alpha})`;
|
|
2263
|
+
ctx.beginPath();
|
|
2264
|
+
ctx.arc(px, py, size + 1.2 + depthBlurRadius(zn), 0, 6.28318);
|
|
2265
|
+
ctx.fill();
|
|
2266
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
|
|
2267
|
+
ctx.beginPath();
|
|
2268
|
+
ctx.arc(px, py, size, 0, 6.28318);
|
|
2269
|
+
ctx.fill();
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
1783
2272
|
// streamlines: draw the force field itself — a grid of arrows along the net push a still test
|
|
1784
2273
|
// particle would feel (§20.6 diagnostic). 'streamlines' draws them ALONE (showMatter suppressed
|
|
1785
2274
|
// the dots above); 'flow' draws the SAME arrows additively over the dots already painted — the
|
|
@@ -2282,7 +2771,12 @@ export function createField(canvas, opts = {}) {
|
|
|
2282
2771
|
// "is the field animating" flag, so it must stay falsy when still and >0 when moving.
|
|
2283
2772
|
const dtRaw = Number.isFinite(lastNow) ? (now - lastNow) / 16.6667 : 1;
|
|
2284
2773
|
lastNow = now;
|
|
2285
|
-
|
|
2774
|
+
// Effective motion budget (0..1) folds reduced-motion + policy (+ perf pressure). At 0 the field is
|
|
2775
|
+
// frozen exactly as reduced-motion (`dt = 0` — the "is animating" flag stays falsy); a partial
|
|
2776
|
+
// budget scales displacement-per-second proportionally, so a `maxMotionBudget: 0.5` field drifts at
|
|
2777
|
+
// half speed. Reduced-motion always forces this to 0 (see `effectiveMotion`).
|
|
2778
|
+
const motion = effectiveMotion();
|
|
2779
|
+
env.dt = motion <= 0 ? 0 : clamp(dtRaw, 0.2, 2) * motion;
|
|
2286
2780
|
if (boot < 1)
|
|
2287
2781
|
boot = Math.min(1, boot + 0.012);
|
|
2288
2782
|
easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
|
|
@@ -2293,7 +2787,7 @@ export function createField(canvas, opts = {}) {
|
|
|
2293
2787
|
originX = vp.originX ?? 0;
|
|
2294
2788
|
originY = vp.originY ?? 0;
|
|
2295
2789
|
}
|
|
2296
|
-
const scrollY = host.scrollY();
|
|
2790
|
+
const scrollY = host.scrollY?.() ?? 0;
|
|
2297
2791
|
const dScroll = scrollY - lastScrollY;
|
|
2298
2792
|
// eased page-scroll speed for the `scrolling` data-when gate (§5).
|
|
2299
2793
|
env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(dScroll) * 0.3;
|
|
@@ -2354,7 +2848,7 @@ export function createField(canvas, opts = {}) {
|
|
|
2354
2848
|
// accent journey (§9): scroll travels the palette; a hovered element overrides.
|
|
2355
2849
|
// maxScroll is cached (scrollHeight forces a reflow); resample it twice a second.
|
|
2356
2850
|
if (frameN % 30 === 0)
|
|
2357
|
-
maxScroll = host.scrollHeight() - H || 1;
|
|
2851
|
+
maxScroll = (host.scrollHeight?.() ?? H) - H || 1;
|
|
2358
2852
|
const targetAcc = hoverAccent ? hexToRgb(hoverAccent) : sampleStops(JOURNEY, scrollY / maxScroll);
|
|
2359
2853
|
curAccent = [
|
|
2360
2854
|
curAccent[0] + (targetAcc[0] - curAccent[0]) * 0.08,
|
|
@@ -2427,7 +2921,7 @@ export function createField(canvas, opts = {}) {
|
|
|
2427
2921
|
// signals-only mode (`render: 'none'`, §13.7 / #297) the engine never draws — neither the
|
|
2428
2922
|
// underlay nor the overlay — and `ctx` may not even exist. Under reduced motion the scene is
|
|
2429
2923
|
// static (dt = 0), so a quarter-rate redraw is visually identical at a quarter of the cost.
|
|
2430
|
-
if (ctx && cfg.render !== 'none' && canvasVisible && (
|
|
2924
|
+
if (ctx && cfg.render !== 'none' && canvasVisible && (motion > 0 || frameN % 4 === 0)) {
|
|
2431
2925
|
render();
|
|
2432
2926
|
if (overlayBackend) {
|
|
2433
2927
|
const stack = overlayStack(cfg.overlay);
|
|
@@ -2440,7 +2934,8 @@ export function createField(canvas, opts = {}) {
|
|
|
2440
2934
|
function setFormation(name) {
|
|
2441
2935
|
const f = FORMATION_BY[name];
|
|
2442
2936
|
if (f) {
|
|
2443
|
-
|
|
2937
|
+
// `ambient` carries the two DECLARED dials (#978); other formations keep their authored presets.
|
|
2938
|
+
formTarget = name === 'ambient' ? { ...ambientForm } : { ...f.preset };
|
|
2444
2939
|
formationName = name;
|
|
2445
2940
|
}
|
|
2446
2941
|
}
|
|
@@ -2480,11 +2975,16 @@ export function createField(canvas, opts = {}) {
|
|
|
2480
2975
|
idleTimer.unref?.();
|
|
2481
2976
|
const onResize = () => resize();
|
|
2482
2977
|
resize();
|
|
2978
|
+
// A field created with an overlay reading ALREADY set (`overlay: 'grid'`, or `<field-root overlay=…>`
|
|
2979
|
+
// at mount) resolves its overlay surface now, so the lazily-provided canvas is created at boot rather
|
|
2980
|
+
// than only on a later setOverlay (#676). `overlay: 'off'` (the default) resolves nothing.
|
|
2981
|
+
if (overlayStack(cfg.overlay).length)
|
|
2982
|
+
ensureOverlaySurface();
|
|
2483
2983
|
// pause all work while the tab is backgrounded — stop the loop and the idle timer,
|
|
2484
2984
|
// resume cleanly when it returns (browsers throttle rAF in the background, but this
|
|
2485
2985
|
// guarantees zero work and avoids drift on return).
|
|
2486
2986
|
const onVisibility = () => {
|
|
2487
|
-
if (host.hidden()) {
|
|
2987
|
+
if (host.hidden?.() ?? false) {
|
|
2488
2988
|
host.cancelRaf(raf);
|
|
2489
2989
|
raf = 0;
|
|
2490
2990
|
}
|
|
@@ -2492,15 +2992,24 @@ export function createField(canvas, opts = {}) {
|
|
|
2492
2992
|
raf = host.raf(frame);
|
|
2493
2993
|
}
|
|
2494
2994
|
};
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2995
|
+
// Optional subscription capabilities — a MinimalFieldHost supplies none of these; the field then
|
|
2996
|
+
// never re-reads on resize, never scroll-drives, never auto-pauses, and takes no DOM body events
|
|
2997
|
+
// (programmatic bodies via addBody still work). Each is wired only when the host offers it.
|
|
2998
|
+
if (host.onResize)
|
|
2999
|
+
teardowns.push(host.onResize(onResize));
|
|
3000
|
+
if (host.onScroll)
|
|
3001
|
+
teardowns.push(host.onScroll(scrollHandler));
|
|
3002
|
+
if (host.onVisibility)
|
|
3003
|
+
teardowns.push(host.onVisibility(onVisibility));
|
|
3004
|
+
if (host.onInput)
|
|
3005
|
+
teardowns.push(host.onInput(markInput));
|
|
2499
3006
|
// shadow-DOM body events: forces:* + field:* aliases share the same idempotent handlers, so a body
|
|
2500
3007
|
// registers under either namespace; the controller dispatches both, the engine listens to both.
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
3008
|
+
if (host.onBodyEvent) {
|
|
3009
|
+
teardowns.push(host.onBodyEvent(REGISTER_BODY, onRegister));
|
|
3010
|
+
teardowns.push(host.onBodyEvent(UNREGISTER_BODY, onUnregister));
|
|
3011
|
+
teardowns.push(host.onBodyEvent(UPDATE_BODY, onUpdateBody));
|
|
3012
|
+
}
|
|
2504
3013
|
onScroll();
|
|
2505
3014
|
raf = host.raf(frame);
|
|
2506
3015
|
const handle = {
|
|
@@ -2560,18 +3069,21 @@ export function createField(canvas, opts = {}) {
|
|
|
2560
3069
|
console.warn(`Fundamental: setRender('${mode}') could not acquire a 2d context; staying in render 'none'`);
|
|
2561
3070
|
return;
|
|
2562
3071
|
}
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
overlayBackend = opts.overlayBackend ?? canvas2dBackend(overlayCanvas, overlayCtx);
|
|
2567
|
-
}
|
|
3072
|
+
// an overlay reading is already active → bring its surface up alongside the underlay (#676).
|
|
3073
|
+
if (overlayStack(cfg.overlay).length)
|
|
3074
|
+
ensureOverlaySurface();
|
|
2568
3075
|
sizeSurfaces(host.viewport().dpr); // the one deferred resize the lazy path needs
|
|
2569
3076
|
}
|
|
2570
3077
|
cfg.render = mode;
|
|
2571
3078
|
},
|
|
2572
3079
|
setOverlay: (mode) => {
|
|
2573
3080
|
cfg.overlay = mode;
|
|
2574
|
-
|
|
3081
|
+
// A non-empty reading stack resolves the overlay surface on demand (#676) — the first non-off
|
|
3082
|
+
// setOverlay is where a lazily-provided canvas is created + bound. 'off'/empty just clears any
|
|
3083
|
+
// surface that already exists and never forces one into being.
|
|
3084
|
+
if (overlayStack(mode).length)
|
|
3085
|
+
ensureOverlaySurface();
|
|
3086
|
+
else
|
|
2575
3087
|
overlayBackend?.clear(); // empty stack → clear the front surface
|
|
2576
3088
|
},
|
|
2577
3089
|
setHeatmap: (on) => {
|
|
@@ -2607,10 +3119,96 @@ export function createField(canvas, opts = {}) {
|
|
|
2607
3119
|
if (ctx)
|
|
2608
3120
|
sizeSurfaces(host.viewport().dpr); // re-apply the tier's effective DPR ceiling now
|
|
2609
3121
|
},
|
|
3122
|
+
get policy() {
|
|
3123
|
+
return clonePolicy(policy); // frozen copy — callers can't mutate the live policy
|
|
3124
|
+
},
|
|
3125
|
+
setPolicy: (next) => {
|
|
3126
|
+
// REPLACE (not merge): the field runs exactly the policy handed in. Deep-cloned so a later
|
|
3127
|
+
// mutation of the caller's object can't reach in. Takes effect next frame (motion) / next call
|
|
3128
|
+
// (privacy). Reduced-motion still wins in `effectiveMotion`, so this can't raise motion above it.
|
|
3129
|
+
policy = clonePolicy(next);
|
|
3130
|
+
},
|
|
3131
|
+
forAgent: (viewOpts) => {
|
|
3132
|
+
const caps = new Set(viewOpts.capabilities ?? []);
|
|
3133
|
+
const redactions = (viewOpts.redactions ?? []).slice();
|
|
3134
|
+
const has = (c) => caps.has(c);
|
|
3135
|
+
// If a future `budgets.agentRead` budget is 0, the agent surface is closed entirely: the most
|
|
3136
|
+
// restricted view (empty caps → ids + shape only). SEAM: only the 0 boundary is wired today; the
|
|
3137
|
+
// fractional 0<b<1 gradient (partial agent read) is DECLARED-not-yet-enforced (see FieldBudgets).
|
|
3138
|
+
const agentReadOpen = () => {
|
|
3139
|
+
const b = policy.budgets?.agentRead;
|
|
3140
|
+
return b == null || b > 0;
|
|
3141
|
+
};
|
|
3142
|
+
const scopeQuery = (q = {}) => {
|
|
3143
|
+
if (!agentReadOpen()) {
|
|
3144
|
+
// closed budget → most-restricted reading: shape only, no metrics/relationships/influences.
|
|
3145
|
+
const empty = handle.query({ ...q, include: ['bodies'] });
|
|
3146
|
+
empty.metrics = {};
|
|
3147
|
+
empty.relationships = [];
|
|
3148
|
+
empty.influences = [];
|
|
3149
|
+
if (!has('read:projections'))
|
|
3150
|
+
empty.projections = [];
|
|
3151
|
+
return applyRedactions(empty, redactions);
|
|
3152
|
+
}
|
|
3153
|
+
// Capability scoping: intersect any requested `include` with what the caps grant, so a reading
|
|
3154
|
+
// can only ever narrow. Bodies (ids + shape) are always readable — identity is the base grant.
|
|
3155
|
+
const grantable = new Set(['bodies']);
|
|
3156
|
+
if (has('read:metrics'))
|
|
3157
|
+
grantable.add('metrics');
|
|
3158
|
+
if (has('read:relationships'))
|
|
3159
|
+
grantable.add('relationships');
|
|
3160
|
+
if (has('read:influences'))
|
|
3161
|
+
grantable.add('influences');
|
|
3162
|
+
const requested = q.include ? new Set(q.include) : null;
|
|
3163
|
+
const include = [...grantable].filter((i) => !requested || requested.has(i));
|
|
3164
|
+
const res = handle.query({ ...q, include });
|
|
3165
|
+
// Belt-and-braces: strip any dimension the caps don't grant even if query populated it.
|
|
3166
|
+
if (!has('read:metrics'))
|
|
3167
|
+
res.metrics = {};
|
|
3168
|
+
if (!has('read:relationships'))
|
|
3169
|
+
res.relationships = [];
|
|
3170
|
+
if (!has('read:influences'))
|
|
3171
|
+
res.influences = [];
|
|
3172
|
+
if (!has('read:projections'))
|
|
3173
|
+
res.projections = [];
|
|
3174
|
+
return applyRedactions(res, redactions);
|
|
3175
|
+
};
|
|
3176
|
+
const scopeSnapshot = (snapOpts = {}) => {
|
|
3177
|
+
if (!agentReadOpen()) {
|
|
3178
|
+
const snap = handle.snapshot({ profile: 'public' });
|
|
3179
|
+
return applyRedactions(snap, redactions);
|
|
3180
|
+
}
|
|
3181
|
+
// read:body-data is the gate for opaque `data`: without it, force `includeData` off (tightens,
|
|
3182
|
+
// never widens — even if a profile or explicit flag asked for it). Everything else composes with
|
|
3183
|
+
// resolveSnapshotInclusion's TIGHTEST-wins rule + the field's own privacy policy downstream.
|
|
3184
|
+
const scoped = { ...snapOpts };
|
|
3185
|
+
if (!has('read:body-data'))
|
|
3186
|
+
scoped.includeData = false;
|
|
3187
|
+
if (!has('read:relationships'))
|
|
3188
|
+
scoped.includeRelationships = false;
|
|
3189
|
+
if (!has('read:influences'))
|
|
3190
|
+
scoped.includeInfluences = false;
|
|
3191
|
+
const snap = handle.snapshot(scoped);
|
|
3192
|
+
if (!has('read:projections'))
|
|
3193
|
+
snap.projections = [];
|
|
3194
|
+
return applyRedactions(snap, redactions);
|
|
3195
|
+
};
|
|
3196
|
+
const view = {
|
|
3197
|
+
get capabilities() { return Object.freeze([...caps]); },
|
|
3198
|
+
get redactions() { return Object.freeze([...redactions]); },
|
|
3199
|
+
query: scopeQuery,
|
|
3200
|
+
snapshot: scopeSnapshot,
|
|
3201
|
+
};
|
|
3202
|
+
// `replay` is present ONLY when granted — the facade's shape reflects the capability.
|
|
3203
|
+
if (has('read:replay')) {
|
|
3204
|
+
view.replay = (a, b, replayOpts) => handle.replay(a, b, replayOpts);
|
|
3205
|
+
}
|
|
3206
|
+
return Object.freeze(view);
|
|
3207
|
+
},
|
|
2610
3208
|
threads: setThreads,
|
|
2611
3209
|
burst: (x, y, hex) => {
|
|
2612
3210
|
// discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
|
|
2613
|
-
const R =
|
|
3211
|
+
const R = BURST_RADIUS;
|
|
2614
3212
|
for (const q of store.particles) {
|
|
2615
3213
|
// the blast point sits on the page plane (z = 0): matter off-plane is shoved
|
|
2616
3214
|
// deeper as well as outward — the 3D leg is 0 in a flat field (z-axis.md).
|
|
@@ -2793,6 +3391,12 @@ export function createField(canvas, opts = {}) {
|
|
|
2793
3391
|
const body = bodyFromElement(el);
|
|
2794
3392
|
body.rect = toRect;
|
|
2795
3393
|
body.data = spec.data;
|
|
3394
|
+
// first-class identity: a supplied identity pins the stable id (a bare string is shorthand for
|
|
3395
|
+
// { id }); omitted ⇒ the engine derives a synthetic `body-N` on first keying. A programmatic body
|
|
3396
|
+
// has no DOM id, so a supplied identity is the only way to reference it stably across snapshots.
|
|
3397
|
+
if (spec.identity != null) {
|
|
3398
|
+
body.identity = typeof spec.identity === 'string' ? { id: spec.identity } : spec.identity;
|
|
3399
|
+
}
|
|
2796
3400
|
if (spec.authority)
|
|
2797
3401
|
body.authority = spec.authority; // body-authority (doc 04); default anchored
|
|
2798
3402
|
body.feedback = true; // programmatic bodies always compute channels (the CSS write hits the
|
|
@@ -2920,8 +3524,10 @@ export function createField(canvas, opts = {}) {
|
|
|
2920
3524
|
const bodyReadings = want.has('bodies')
|
|
2921
3525
|
? matched.map((b) => {
|
|
2922
3526
|
const { metrics, dimensions } = readBodyMetrics(b);
|
|
3527
|
+
const identity = bodyIdentity(b);
|
|
2923
3528
|
return {
|
|
2924
|
-
id:
|
|
3529
|
+
id: identity.id,
|
|
3530
|
+
identity,
|
|
2925
3531
|
rect: { x: b.cx - b.hw, y: b.cy - b.hh, width: b.hw * 2, height: b.hh * 2 },
|
|
2926
3532
|
tokens: b.tokens.slice(),
|
|
2927
3533
|
metrics,
|
|
@@ -2977,12 +3583,15 @@ export function createField(canvas, opts = {}) {
|
|
|
2977
3583
|
return q.lens ? applyLens(result, q.lens) : result;
|
|
2978
3584
|
},
|
|
2979
3585
|
snapshot: (opts = {}) => {
|
|
2980
|
-
const
|
|
3586
|
+
const inc = resolveSnapshotInclusion(opts);
|
|
3587
|
+
const includeRelationships = inc.includeRelationships;
|
|
2981
3588
|
const visible = bodies.filter((b) => b.vis);
|
|
2982
3589
|
const snapBodies = visible.map((b) => {
|
|
2983
3590
|
const { metrics, dimensions } = readBodyMetrics(b);
|
|
3591
|
+
const identity = bodyIdentity(b);
|
|
2984
3592
|
const reading = {
|
|
2985
|
-
id:
|
|
3593
|
+
id: identity.id,
|
|
3594
|
+
identity,
|
|
2986
3595
|
authority: b.authority ?? 'anchored',
|
|
2987
3596
|
rect: { x: b.cx - b.hw, y: b.cy - b.hh, width: b.hw * 2, height: b.hh * 2 },
|
|
2988
3597
|
position: { x: b.cx, y: b.cy, z: 0 },
|
|
@@ -2990,7 +3599,10 @@ export function createField(canvas, opts = {}) {
|
|
|
2990
3599
|
metrics,
|
|
2991
3600
|
dimensions,
|
|
2992
3601
|
};
|
|
2993
|
-
|
|
3602
|
+
// Privacy gate: the caller must ask for data AND the policy must permit it. Policy tightens —
|
|
3603
|
+
// `allowBodyDataInSnapshots === false` (or a privacy budget below threshold) withholds body
|
|
3604
|
+
// `data` even when the call opts in. A policy can restrict, never widen, the call-site default.
|
|
3605
|
+
if (inc.includeData && policyPermitsBodyData())
|
|
2994
3606
|
reading.data = cloneData(b.data);
|
|
2995
3607
|
return reading;
|
|
2996
3608
|
});
|
|
@@ -3023,7 +3635,7 @@ export function createField(canvas, opts = {}) {
|
|
|
3023
3635
|
metrics: fieldMetrics,
|
|
3024
3636
|
projections: projectionList(),
|
|
3025
3637
|
};
|
|
3026
|
-
if (
|
|
3638
|
+
if (inc.includeParticles) {
|
|
3027
3639
|
const out = [];
|
|
3028
3640
|
for (const p of store.particles) {
|
|
3029
3641
|
if (p.cap)
|
|
@@ -3032,7 +3644,7 @@ export function createField(canvas, opts = {}) {
|
|
|
3032
3644
|
}
|
|
3033
3645
|
snap.particles = out;
|
|
3034
3646
|
}
|
|
3035
|
-
if (
|
|
3647
|
+
if (inc.includeInfluences) {
|
|
3036
3648
|
// per-body force attribution: each body's own forces at its centre, by channel (linear Δv,
|
|
3037
3649
|
// thermal heat, …). A later replay() derives `cause: 'force'` steps from how these shift.
|
|
3038
3650
|
const inf = [];
|
|
@@ -3113,8 +3725,8 @@ export function createField(canvas, opts = {}) {
|
|
|
3113
3725
|
for (const e of engaged) {
|
|
3114
3726
|
e.el.removeEventListener('pointerenter', e.enter);
|
|
3115
3727
|
e.el.removeEventListener('pointerleave', e.leave);
|
|
3116
|
-
e.el.removeEventListener('
|
|
3117
|
-
e.el.removeEventListener('
|
|
3728
|
+
e.el.removeEventListener('focusin', e.enter);
|
|
3729
|
+
e.el.removeEventListener('focusout', e.leave);
|
|
3118
3730
|
delete e.el.dataset.fxEngaged;
|
|
3119
3731
|
}
|
|
3120
3732
|
engaged = [];
|
|
@@ -3135,6 +3747,9 @@ export function createField(canvas, opts = {}) {
|
|
|
3135
3747
|
clone.remove();
|
|
3136
3748
|
emitters = [];
|
|
3137
3749
|
store.clear();
|
|
3750
|
+
// release host-side state last (e.g. a contained host's data-field-boundary marker, #980)
|
|
3751
|
+
// so an outer field re-adopts this field's bodies on its next rescan.
|
|
3752
|
+
host.detach?.();
|
|
3138
3753
|
},
|
|
3139
3754
|
};
|
|
3140
3755
|
return handle;
|