@fundamental-engine/core 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/agents/element-agent.d.ts +38 -0
- package/dist/agents/element-agent.d.ts.map +1 -0
- package/dist/agents/element-agent.js +70 -0
- package/dist/agents/element-agent.js.map +1 -0
- package/dist/agents/event-agent.d.ts +47 -0
- package/dist/agents/event-agent.d.ts.map +1 -0
- package/dist/agents/event-agent.js +82 -0
- package/dist/agents/event-agent.js.map +1 -0
- package/dist/agents/index.d.ts +17 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +57 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/agents/region-agents.d.ts +40 -0
- package/dist/agents/region-agents.d.ts.map +1 -0
- package/dist/agents/region-agents.js +22 -0
- package/dist/agents/region-agents.js.map +1 -0
- package/dist/agents/relationship.d.ts +55 -0
- package/dist/agents/relationship.d.ts.map +1 -0
- package/dist/agents/relationship.js +40 -0
- package/dist/agents/relationship.js.map +1 -0
- package/dist/agents/user-agent.d.ts +57 -0
- package/dist/agents/user-agent.d.ts.map +1 -0
- package/dist/agents/user-agent.js +45 -0
- package/dist/agents/user-agent.js.map +1 -0
- package/dist/config/forces.config.d.ts +101 -0
- package/dist/config/forces.config.d.ts.map +1 -0
- package/dist/config/forces.config.js +239 -0
- package/dist/config/forces.config.js.map +1 -0
- package/dist/config/manual.d.ts +134 -0
- package/dist/config/manual.d.ts.map +1 -0
- package/dist/config/manual.js +604 -0
- package/dist/config/manual.js.map +1 -0
- package/dist/config/palettes.d.ts +18 -0
- package/dist/config/palettes.d.ts.map +1 -0
- package/dist/config/palettes.js +34 -0
- package/dist/config/palettes.js.map +1 -0
- package/dist/config/presets.d.ts +48 -0
- package/dist/config/presets.d.ts.map +1 -0
- package/dist/config/presets.js +87 -0
- package/dist/config/presets.js.map +1 -0
- package/dist/config/tokens.d.ts +3 -0
- package/dist/config/tokens.d.ts.map +1 -0
- package/dist/config/tokens.js +16 -0
- package/dist/config/tokens.js.map +1 -0
- package/dist/conformance/expectations.d.ts +40 -0
- package/dist/conformance/expectations.d.ts.map +1 -0
- package/dist/conformance/expectations.js +347 -0
- package/dist/conformance/expectations.js.map +1 -0
- package/dist/conformance/experiments.d.ts +17 -0
- package/dist/conformance/experiments.d.ts.map +1 -0
- package/dist/conformance/experiments.js +875 -0
- package/dist/conformance/experiments.js.map +1 -0
- package/dist/conformance/run.d.ts +18 -0
- package/dist/conformance/run.d.ts.map +1 -0
- package/dist/conformance/run.js +240 -0
- package/dist/conformance/run.js.map +1 -0
- package/dist/conformance/types.d.ts +100 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/conformance/types.js +2 -0
- package/dist/conformance/types.js.map +1 -0
- package/dist/contracts/guards.d.ts +51 -0
- package/dist/contracts/guards.d.ts.map +1 -0
- package/dist/contracts/guards.js +100 -0
- package/dist/contracts/guards.js.map +1 -0
- package/dist/contracts/index.d.ts +18 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +107 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/contracts/passport.d.ts +88 -0
- package/dist/contracts/passport.d.ts.map +1 -0
- package/dist/contracts/passport.js +135 -0
- package/dist/contracts/passport.js.map +1 -0
- package/dist/contracts/types.d.ts +120 -0
- package/dist/contracts/types.d.ts.map +1 -0
- package/dist/contracts/types.js +24 -0
- package/dist/contracts/types.js.map +1 -0
- package/dist/core/accretion.d.ts +50 -0
- package/dist/core/accretion.d.ts.map +1 -0
- package/dist/core/accretion.js +98 -0
- package/dist/core/accretion.js.map +1 -0
- package/dist/core/agents.d.ts +31 -0
- package/dist/core/agents.d.ts.map +1 -0
- package/dist/core/agents.js +51 -0
- package/dist/core/agents.js.map +1 -0
- package/dist/core/attention.d.ts +72 -0
- package/dist/core/attention.d.ts.map +1 -0
- package/dist/core/attention.js +122 -0
- package/dist/core/attention.js.map +1 -0
- package/dist/core/causality.d.ts +38 -0
- package/dist/core/causality.d.ts.map +1 -0
- package/dist/core/causality.js +64 -0
- package/dist/core/causality.js.map +1 -0
- package/dist/core/conditions.d.ts +10 -0
- package/dist/core/conditions.d.ts.map +1 -0
- package/dist/core/conditions.js +22 -0
- package/dist/core/conditions.js.map +1 -0
- package/dist/core/currents.d.ts +53 -0
- package/dist/core/currents.d.ts.map +1 -0
- package/dist/core/currents.js +65 -0
- package/dist/core/currents.js.map +1 -0
- package/dist/core/dock.d.ts +35 -0
- package/dist/core/dock.d.ts.map +1 -0
- package/dist/core/dock.js +39 -0
- package/dist/core/dock.js.map +1 -0
- package/dist/core/events.d.ts +23 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +34 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/feedback-sink.d.ts +32 -0
- package/dist/core/feedback-sink.d.ts.map +1 -0
- package/dist/core/feedback-sink.js +53 -0
- package/dist/core/feedback-sink.js.map +1 -0
- package/dist/core/feedback.d.ts +11 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +16 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/field-store.d.ts +26 -0
- package/dist/core/field-store.d.ts.map +1 -0
- package/dist/core/field-store.js +54 -0
- package/dist/core/field-store.js.map +1 -0
- package/dist/core/field.d.ts +18 -0
- package/dist/core/field.d.ts.map +1 -0
- package/dist/core/field.js +1943 -0
- package/dist/core/field.js.map +1 -0
- package/dist/core/fieldline-seeds.d.ts +25 -0
- package/dist/core/fieldline-seeds.d.ts.map +1 -0
- package/dist/core/fieldline-seeds.js +32 -0
- package/dist/core/fieldline-seeds.js.map +1 -0
- package/dist/core/fieldlines.d.ts +75 -0
- package/dist/core/fieldlines.d.ts.map +1 -0
- package/dist/core/fieldlines.js +111 -0
- package/dist/core/fieldlines.js.map +1 -0
- package/dist/core/flow.d.ts +38 -0
- package/dist/core/flow.d.ts.map +1 -0
- package/dist/core/flow.js +27 -0
- package/dist/core/flow.js.map +1 -0
- package/dist/core/formations.d.ts +11 -0
- package/dist/core/formations.d.ts.map +1 -0
- package/dist/core/formations.js +22 -0
- package/dist/core/formations.js.map +1 -0
- package/dist/core/geometry.d.ts +67 -0
- package/dist/core/geometry.d.ts.map +1 -0
- package/dist/core/geometry.js +68 -0
- package/dist/core/geometry.js.map +1 -0
- package/dist/core/heatmap.d.ts +22 -0
- package/dist/core/heatmap.d.ts.map +1 -0
- package/dist/core/heatmap.js +55 -0
- package/dist/core/heatmap.js.map +1 -0
- package/dist/core/host.d.ts +46 -0
- package/dist/core/host.d.ts.map +1 -0
- package/dist/core/host.js +11 -0
- package/dist/core/host.js.map +1 -0
- package/dist/core/integrator.d.ts +24 -0
- package/dist/core/integrator.d.ts.map +1 -0
- package/dist/core/integrator.js +375 -0
- package/dist/core/integrator.js.map +1 -0
- package/dist/core/math.d.ts +37 -0
- package/dist/core/math.d.ts.map +1 -0
- package/dist/core/math.js +77 -0
- package/dist/core/math.js.map +1 -0
- package/dist/core/reactions.d.ts +32 -0
- package/dist/core/reactions.d.ts.map +1 -0
- package/dist/core/reactions.js +45 -0
- package/dist/core/reactions.js.map +1 -0
- package/dist/core/registry.d.ts +13 -0
- package/dist/core/registry.d.ts.map +1 -0
- package/dist/core/registry.js +20 -0
- package/dist/core/registry.js.map +1 -0
- package/dist/core/render-backend.d.ts +46 -0
- package/dist/core/render-backend.d.ts.map +1 -0
- package/dist/core/render-backend.js +75 -0
- package/dist/core/render-backend.js.map +1 -0
- package/dist/core/render-modes.d.ts +42 -0
- package/dist/core/render-modes.d.ts.map +1 -0
- package/dist/core/render-modes.js +141 -0
- package/dist/core/render-modes.js.map +1 -0
- package/dist/core/reservoir.d.ts +43 -0
- package/dist/core/reservoir.d.ts.map +1 -0
- package/dist/core/reservoir.js +207 -0
- package/dist/core/reservoir.js.map +1 -0
- package/dist/core/scalar-grid.d.ts +51 -0
- package/dist/core/scalar-grid.d.ts.map +1 -0
- package/dist/core/scalar-grid.js +146 -0
- package/dist/core/scalar-grid.js.map +1 -0
- package/dist/core/scanner.d.ts +59 -0
- package/dist/core/scanner.d.ts.map +1 -0
- package/dist/core/scanner.js +260 -0
- package/dist/core/scanner.js.map +1 -0
- package/dist/core/shadow.d.ts +69 -0
- package/dist/core/shadow.d.ts.map +1 -0
- package/dist/core/shadow.js +84 -0
- package/dist/core/shadow.js.map +1 -0
- package/dist/core/spatial-hash.d.ts +30 -0
- package/dist/core/spatial-hash.d.ts.map +1 -0
- package/dist/core/spatial-hash.js +64 -0
- package/dist/core/spatial-hash.js.map +1 -0
- package/dist/core/streamlines.d.ts +29 -0
- package/dist/core/streamlines.d.ts.map +1 -0
- package/dist/core/streamlines.js +70 -0
- package/dist/core/streamlines.js.map +1 -0
- package/dist/core/surface.d.ts +19 -0
- package/dist/core/surface.d.ts.map +1 -0
- package/dist/core/surface.js +21 -0
- package/dist/core/surface.js.map +1 -0
- package/dist/core/temporal.d.ts +110 -0
- package/dist/core/temporal.d.ts.map +1 -0
- package/dist/core/temporal.js +139 -0
- package/dist/core/temporal.js.map +1 -0
- package/dist/core/thermo.d.ts +48 -0
- package/dist/core/thermo.d.ts.map +1 -0
- package/dist/core/thermo.js +48 -0
- package/dist/core/thermo.js.map +1 -0
- package/dist/core/types.d.ts +610 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/weights.d.ts +111 -0
- package/dist/core/weights.d.ts.map +1 -0
- package/dist/core/weights.js +128 -0
- package/dist/core/weights.js.map +1 -0
- package/dist/diagnostics/energy.d.ts +21 -0
- package/dist/diagnostics/energy.d.ts.map +1 -0
- package/dist/diagnostics/energy.js +27 -0
- package/dist/diagnostics/energy.js.map +1 -0
- package/dist/diagnostics/fields.d.ts +23 -0
- package/dist/diagnostics/fields.d.ts.map +1 -0
- package/dist/diagnostics/fields.js +30 -0
- package/dist/diagnostics/fields.js.map +1 -0
- package/dist/diagnostics/index.d.ts +46 -0
- package/dist/diagnostics/index.d.ts.map +1 -0
- package/dist/diagnostics/index.js +23 -0
- package/dist/diagnostics/index.js.map +1 -0
- package/dist/diagnostics/modes.d.ts +108 -0
- package/dist/diagnostics/modes.d.ts.map +1 -0
- package/dist/diagnostics/modes.js +181 -0
- package/dist/diagnostics/modes.js.map +1 -0
- package/dist/diagnostics/potential.d.ts +30 -0
- package/dist/diagnostics/potential.d.ts.map +1 -0
- package/dist/diagnostics/potential.js +43 -0
- package/dist/diagnostics/potential.js.map +1 -0
- package/dist/diagnostics/probes.d.ts +31 -0
- package/dist/diagnostics/probes.d.ts.map +1 -0
- package/dist/diagnostics/probes.js +61 -0
- package/dist/diagnostics/probes.js.map +1 -0
- package/dist/diagnostics/render.d.ts +49 -0
- package/dist/diagnostics/render.d.ts.map +1 -0
- package/dist/diagnostics/render.js +132 -0
- package/dist/diagnostics/render.js.map +1 -0
- package/dist/export.d.ts +18 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +17 -0
- package/dist/export.js.map +1 -0
- package/dist/forces/extended.d.ts +121 -0
- package/dist/forces/extended.d.ts.map +1 -0
- package/dist/forces/extended.js +674 -0
- package/dist/forces/extended.js.map +1 -0
- package/dist/forces/index.d.ts +33 -0
- package/dist/forces/index.d.ts.map +1 -0
- package/dist/forces/index.js +237 -0
- package/dist/forces/index.js.map +1 -0
- package/dist/forces/natural.d.ts +106 -0
- package/dist/forces/natural.d.ts.map +1 -0
- package/dist/forces/natural.js +385 -0
- package/dist/forces/natural.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect/budget.d.ts +17 -0
- package/dist/inspect/budget.d.ts.map +1 -0
- package/dist/inspect/budget.js +19 -0
- package/dist/inspect/budget.js.map +1 -0
- package/dist/inspect/index.d.ts +10 -0
- package/dist/inspect/index.d.ts.map +1 -0
- package/dist/inspect/index.js +10 -0
- package/dist/inspect/index.js.map +1 -0
- package/dist/inspect/report.d.ts +17 -0
- package/dist/inspect/report.d.ts.map +1 -0
- package/dist/inspect/report.js +44 -0
- package/dist/inspect/report.js.map +1 -0
- package/dist/inspect/snapshot.d.ts +21 -0
- package/dist/inspect/snapshot.d.ts.map +1 -0
- package/dist/inspect/snapshot.js +30 -0
- package/dist/inspect/snapshot.js.map +1 -0
- package/dist/recipes/catalog.d.ts +51 -0
- package/dist/recipes/catalog.d.ts.map +1 -0
- package/dist/recipes/catalog.js +1496 -0
- package/dist/recipes/catalog.js.map +1 -0
- package/dist/recipes/charge.d.ts +18 -0
- package/dist/recipes/charge.d.ts.map +1 -0
- package/dist/recipes/charge.js +27 -0
- package/dist/recipes/charge.js.map +1 -0
- package/dist/recipes/compile.d.ts +93 -0
- package/dist/recipes/compile.d.ts.map +1 -0
- package/dist/recipes/compile.js +113 -0
- package/dist/recipes/compile.js.map +1 -0
- package/dist/recipes/explain.d.ts +8 -0
- package/dist/recipes/explain.d.ts.map +1 -0
- package/dist/recipes/explain.js +46 -0
- package/dist/recipes/explain.js.map +1 -0
- package/dist/recipes/gallery.d.ts +6 -0
- package/dist/recipes/gallery.d.ts.map +1 -0
- package/dist/recipes/gallery.js +6 -0
- package/dist/recipes/gallery.js.map +1 -0
- package/dist/recipes/gravity.d.ts +16 -0
- package/dist/recipes/gravity.d.ts.map +1 -0
- package/dist/recipes/gravity.js +27 -0
- package/dist/recipes/gravity.js.map +1 -0
- package/dist/recipes/index.d.ts +18 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +36 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/recipes/intent.d.ts +44 -0
- package/dist/recipes/intent.d.ts.map +1 -0
- package/dist/recipes/intent.js +46 -0
- package/dist/recipes/intent.js.map +1 -0
- package/dist/recipes/schema.d.ts +103 -0
- package/dist/recipes/schema.d.ts.map +1 -0
- package/dist/recipes/schema.js +123 -0
- package/dist/recipes/schema.js.map +1 -0
- package/dist/recipes/wayfinding.d.ts +39 -0
- package/dist/recipes/wayfinding.d.ts.map +1 -0
- package/dist/recipes/wayfinding.js +77 -0
- package/dist/recipes/wayfinding.js.map +1 -0
- package/dist/semantic/index.d.ts +13 -0
- package/dist/semantic/index.d.ts.map +1 -0
- package/dist/semantic/index.js +31 -0
- package/dist/semantic/index.js.map +1 -0
- package/dist/semantic/layers.d.ts +24 -0
- package/dist/semantic/layers.d.ts.map +1 -0
- package/dist/semantic/layers.js +27 -0
- package/dist/semantic/layers.js.map +1 -0
- package/dist/semantic/materials.d.ts +20 -0
- package/dist/semantic/materials.d.ts.map +1 -0
- package/dist/semantic/materials.js +17 -0
- package/dist/semantic/materials.js.map +1 -0
- package/dist/semantic/states.d.ts +11 -0
- package/dist/semantic/states.d.ts.map +1 -0
- package/dist/semantic/states.js +26 -0
- package/dist/semantic/states.js.map +1 -0
- package/dist/visual/channels.d.ts +71 -0
- package/dist/visual/channels.d.ts.map +1 -0
- package/dist/visual/channels.js +70 -0
- package/dist/visual/channels.js.map +1 -0
- package/dist/visual/index.d.ts +39 -0
- package/dist/visual/index.d.ts.map +1 -0
- package/dist/visual/index.js +30 -0
- package/dist/visual/index.js.map +1 -0
- package/dist/visual/lint.d.ts +41 -0
- package/dist/visual/lint.d.ts.map +1 -0
- package/dist/visual/lint.js +58 -0
- package/dist/visual/lint.js.map +1 -0
- package/dist/visual/mapping.d.ts +13 -0
- package/dist/visual/mapping.d.ts.map +1 -0
- package/dist/visual/mapping.js +43 -0
- package/dist/visual/mapping.js.map +1 -0
- package/dist/visual/semantic-text.d.ts +28 -0
- package/dist/visual/semantic-text.d.ts.map +1 -0
- package/dist/visual/semantic-text.js +36 -0
- package/dist/visual/semantic-text.js.map +1 -0
- package/dist/visual/tokens.d.ts +23 -0
- package/dist/visual/tokens.d.ts.map +1 -0
- package/dist/visual/tokens.js +54 -0
- package/dist/visual/tokens.js.map +1 -0
- package/dist/visual/visualization.d.ts +31 -0
- package/dist/visual/visualization.d.ts.map +1 -0
- package/dist/visual/visualization.js +47 -0
- package/dist/visual/visualization.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1943 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createField — the browser entry point (§13).
|
|
3
|
+
*
|
|
4
|
+
* Mounts the simulation against a `<canvas>`: builds the particle pool, scans
|
|
5
|
+
* the document for `[data-body]` bodies, runs the rAF loop (measure → reindex →
|
|
6
|
+
* step → render), and exposes the public `FieldHandle`. Pure glue — the testable
|
|
7
|
+
* physics lives in field-store / integrator / scanner.
|
|
8
|
+
*
|
|
9
|
+
* ── Renderer-agnostic (frontier) ──────────────────────────────────────────────────────
|
|
10
|
+
* This engine touches NO DOM globals. Every environment touchpoint — viewport size, scroll, rAF,
|
|
11
|
+
* reduced-motion, visibility, the scan root, and event wiring — goes through an injected
|
|
12
|
+
* {@link FieldHost} (default `browserHost()`). The browser's `window`/`document` surface is isolated
|
|
13
|
+
* in `browser-host.ts` (the one allowlisted DOM module, with `export.ts`). Pass `opts.host` to drive
|
|
14
|
+
* the same engine from a different renderer/environment. Enforced by `dom-boundary.test.ts`.
|
|
15
|
+
*/
|
|
16
|
+
import { FieldStore } from "./field-store.js";
|
|
17
|
+
import { createRegistry } from "./registry.js";
|
|
18
|
+
import { step } from "./integrator.js";
|
|
19
|
+
import { scanBodies, measureBodies, bodyFromElement } from "./scanner.js";
|
|
20
|
+
import { ShadowRegistry, REGISTER_BODY, UNREGISTER_BODY, UPDATE_BODY, } from "./shadow.js";
|
|
21
|
+
import { easeFormation } from "./formations.js";
|
|
22
|
+
import { Heatmap } from "./heatmap.js";
|
|
23
|
+
import { buildWaves, buildBound, waveYat, } from "./currents.js";
|
|
24
|
+
import { healWaves, tearBoundNear, tearBoundByForces, induceCharges } from "./reservoir.js";
|
|
25
|
+
import { FORMATION_BY, PALETTE } from "../config/forces.config.js";
|
|
26
|
+
import { resolvePalette } from "../config/palettes.js";
|
|
27
|
+
import { clamp, hexToRgb, particleRGB, rgbToHex, sampleStops } from "./math.js";
|
|
28
|
+
import { feedbackTarget, feedbackWeight } from "./feedback.js";
|
|
29
|
+
import { defaultFeedbackSink } from "./feedback-sink.js";
|
|
30
|
+
import { thermoMetrics } from "./thermo.js";
|
|
31
|
+
import { attentionMuls } from "./attention.js";
|
|
32
|
+
import { spillover } from "./causality.js";
|
|
33
|
+
import { integrateOffset, anchorForce, elementMass, repelForce, densityPush } from "./agents.js";
|
|
34
|
+
import { releaseCaptured, sinkLoad, captureEdge, dischargeDisengaged } from "./accretion.js";
|
|
35
|
+
import { withinCapture, stepDock, dockTransform } from "./dock.js";
|
|
36
|
+
import { parseEventBindings, triggerActive } from "./events.js";
|
|
37
|
+
import { registerCoreForces } from "../forces/index.js";
|
|
38
|
+
import { registerNaturalForces } from "../forces/natural.js";
|
|
39
|
+
import { registerExtendedForces } from "../forces/extended.js";
|
|
40
|
+
import { ScalarGridImpl } from "./scalar-grid.js";
|
|
41
|
+
import { sparkCount, burstImpulse } from "./reactions.js";
|
|
42
|
+
import { linkAlpha, marchingCell, splatDensity, nearestSite, voronoiWalls } from "./render-modes.js";
|
|
43
|
+
import { canvas2dBackend } from "./render-backend.js";
|
|
44
|
+
import { forceAt, netField } from "./streamlines.js";
|
|
45
|
+
import { traceFieldLines } from "./fieldlines.js";
|
|
46
|
+
import { fieldLineSeeds } from "./fieldline-seeds.js";
|
|
47
|
+
import { flowBias, makeFlowFocus } from "./flow.js";
|
|
48
|
+
import { energyReport } from "../diagnostics/energy.js";
|
|
49
|
+
// the Currents' cool baseline palette — a subset of the force palette (§24.4).
|
|
50
|
+
const WAVE_RGB = ['#4da3ff', '#2dd4bf', '#a78bfa'].map(hexToRgb);
|
|
51
|
+
export function createField(canvas, opts = {}) {
|
|
52
|
+
// Signals-only mode (`render: 'none'`, §13.7 / #297): the full simulation + feedback pipeline
|
|
53
|
+
// runs, but the engine never acquires a 2d context, never sizes a canvas backing store (it stays
|
|
54
|
+
// 0×0 — the allocation win), and never draws. The field exists purely as signals: `--d`, `--load`,
|
|
55
|
+
// `--lit`, capture events, `scrollV()`. `ctx` stays null until `setRender` to a drawing mode
|
|
56
|
+
// acquires it lazily (and sizes the store then) — so a field created with 'none' allocates no
|
|
57
|
+
// render surface at all unless asked to draw.
|
|
58
|
+
let ctx = null;
|
|
59
|
+
if ((opts.render ?? 'dots') !== 'none') {
|
|
60
|
+
ctx = canvas.getContext('2d');
|
|
61
|
+
if (!ctx)
|
|
62
|
+
throw new Error('field-ui: 2D canvas context unavailable');
|
|
63
|
+
}
|
|
64
|
+
// Field Surfaces: the optional OVERLAY surface, drawn in front of page content. Core only draws to
|
|
65
|
+
// it (the caller owns the element + its fixed/pointer-events placement); its backing store is sized
|
|
66
|
+
// in resize() to match the main canvas dpr. Keeps core DOM-free — the canvas is handed in.
|
|
67
|
+
// Under `render: 'none'` it is never acquired either (the overlay never draws in that mode).
|
|
68
|
+
const overlayCanvas = opts.overlayCanvas ?? null;
|
|
69
|
+
let overlayCtx = ctx ? (overlayCanvas?.getContext('2d') ?? null) : null;
|
|
70
|
+
// The overlay draws exclusively through the RenderBackend contract (#373) — the structural
|
|
71
|
+
// seam a WebGL/WebGPU surface implements later. Callers may inject one; the default wraps the
|
|
72
|
+
// overlay's own 2d context.
|
|
73
|
+
let overlayBackend = opts.overlayBackend ?? (overlayCanvas && overlayCtx ? canvas2dBackend(overlayCanvas, overlayCtx) : null);
|
|
74
|
+
const store = new FieldStore();
|
|
75
|
+
const grids = new Map(); // §20.1 class [C] field buffers, lazy
|
|
76
|
+
const reg = createRegistry();
|
|
77
|
+
registerCoreForces(reg); // the canonical nine (§6)
|
|
78
|
+
registerNaturalForces(reg); // natural primitives: gravity + charge (§20.10), opt-in
|
|
79
|
+
registerExtendedForces(reg); // designed extended forces: lens, … (§20.3), opt-in
|
|
80
|
+
// the environment seam: all DOM access goes through this injected host — core imports zero DOM.
|
|
81
|
+
// In the browser, pass `browserHost()` from @fundamental-engine/platform (or use createBrowserField); the
|
|
82
|
+
// @fundamental-engine/{elements,react,vanilla} entry points wire it for you.
|
|
83
|
+
if (!opts.host) {
|
|
84
|
+
throw new Error('field-ui: createField requires opts.host. Use @fundamental-engine/vanilla (createField/mountField) or ' +
|
|
85
|
+
'@fundamental-engine/elements / @fundamental-engine/react, or pass browserHost() from @fundamental-engine/platform.');
|
|
86
|
+
}
|
|
87
|
+
const host = opts.host;
|
|
88
|
+
const teardowns = []; // host event unsubscribers, called on destroy
|
|
89
|
+
const reduceMotion = host.reducedMotion();
|
|
90
|
+
const cfg = {
|
|
91
|
+
accent: opts.accent ?? resolvePalette(opts.palette)[0] ?? PALETTE[0] ?? '#4da3ff',
|
|
92
|
+
density: opts.density && opts.density > 0 ? opts.density : 1,
|
|
93
|
+
render: opts.render ?? 'dots',
|
|
94
|
+
waves: opts.waves ?? true, // draw the background Currents (§24); opt-out for the bare field
|
|
95
|
+
background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
|
|
96
|
+
mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
|
|
97
|
+
attention: opts.attention ?? false, // conserved attention (§2.4), opt-in
|
|
98
|
+
causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
|
|
99
|
+
heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
|
|
100
|
+
overlay: opts.overlay ?? 'off', // Field Surfaces: overlay-surface visualization mode, opt-in
|
|
101
|
+
// optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
|
|
102
|
+
// to the 2D engine; > 0 opens a shallow depth the matter drifts through, opt-in.
|
|
103
|
+
depth: opts.depth && opts.depth > 0 ? opts.depth : 0,
|
|
104
|
+
// ONE write path (#228, Phase 5): every feedback write goes through a sink. The platform
|
|
105
|
+
// supplies one (D3, FeedbackRegistry via <field-root>); without it the engine installs the
|
|
106
|
+
// internal default sink, whose writes are byte-identical to the historical direct writes.
|
|
107
|
+
feedbackSink: opts.feedbackSink ?? defaultFeedbackSink,
|
|
108
|
+
};
|
|
109
|
+
let heatmap = null; // lazily built once the viewport size is known
|
|
110
|
+
let bodies = [];
|
|
111
|
+
let W = 0;
|
|
112
|
+
let H = 0;
|
|
113
|
+
// cached page scroll extent — reading scrollHeight forces a synchronous reflow, so
|
|
114
|
+
// we cache it (refreshed on resize + sampled occasionally) rather than per frame.
|
|
115
|
+
let maxScroll = 1;
|
|
116
|
+
let lastScrollY = 0; // for the per-frame scroll speed that drives the `scrolling` gate (§5)
|
|
117
|
+
// last variable-font weight written per element — changing fontVariationSettings
|
|
118
|
+
// reflows the text, so we only write it when the rounded weight actually changes.
|
|
119
|
+
const lastWeight = new WeakMap();
|
|
120
|
+
let raf = 0;
|
|
121
|
+
let frameN = 0;
|
|
122
|
+
// element-level visibility (FieldHandle.setVisible): false skips draw work each frame while
|
|
123
|
+
// the simulation + feedback signals stay live. Tab-level visibility is handled separately
|
|
124
|
+
// (onVisibility stops the loop entirely).
|
|
125
|
+
let canvasVisible = true;
|
|
126
|
+
let formTarget = { ...FORMATION_BY.ambient.preset };
|
|
127
|
+
let waves = [];
|
|
128
|
+
let bound = [];
|
|
129
|
+
let boundTarget = 0;
|
|
130
|
+
// the injected sources (#371): every random draw and wall-clock read in the engine flows
|
|
131
|
+
// through these two, so a seeded rng + fixed clock make a run reproducible end to end.
|
|
132
|
+
const rng = opts.rng ?? Math.random;
|
|
133
|
+
const wallNow = opts.now ?? (() => performance.now());
|
|
134
|
+
let boot = reduceMotion ? 1 : 0;
|
|
135
|
+
let mball = null; // scratch density grid for the metaballs render mode
|
|
136
|
+
let vor = null; // scratch owner grid for the voronoi render mode
|
|
137
|
+
// EMA (exponential moving average) of the per-frame peak magnitude for each arrow renderer.
|
|
138
|
+
// Normalizing to the raw frame max caused the entire arrow field to rescale in one step when
|
|
139
|
+
// maxMag shifted (body drag, animated strength, density ramp) — visible as a pulsing flash.
|
|
140
|
+
// The EMA tracks the true level but smooths transients: fast to rise (alpha=0.3 on peaks above
|
|
141
|
+
// the smoothed value), slow to fall (alpha=0.1 when the field weakens), so arrows converge
|
|
142
|
+
// quickly when a strong body is added but don't snap back on a single quiet frame.
|
|
143
|
+
// Each renderer keeps independent state so underlay and overlay don't cross-influence.
|
|
144
|
+
let slMaxSmoothed = 0; // underlay streamlines
|
|
145
|
+
let olMaxSmoothed = 0; // overlay arrows (streamlines / force-vectors / field-lines)
|
|
146
|
+
// Cached force-field samples for the underlay streamlines / 'flow' arrows. The sampled field is
|
|
147
|
+
// driven by body positions, which only update on the measureBodies cadence (every 6th frame), so
|
|
148
|
+
// re-sampling the whole grid every frame is wasted work that surfaces as scroll jank. We resample
|
|
149
|
+
// on a cadence and DRAW from this cache every frame (so the arrows never flicker or step).
|
|
150
|
+
let slSamples = null;
|
|
151
|
+
let slQuiescent = [];
|
|
152
|
+
// hard pool ceiling for class-[S] sources (§20.1) — generous above the ~130·density
|
|
153
|
+
// base field so emission is never starved, but bounded so the sim can't grow forever.
|
|
154
|
+
const spawnCeiling = Math.round(130 * cfg.density) * 4;
|
|
155
|
+
const pull = { x: 0, y: 0, k: 0 }; // the "spine" — waves bend to the engaged body
|
|
156
|
+
let flow = null; // a movable flow focus the field bends toward (field.flowTo)
|
|
157
|
+
let focusP = null; // the hover-focused particle (field.focusAt): held still + lit
|
|
158
|
+
let focusX = 0;
|
|
159
|
+
let focusY = 0;
|
|
160
|
+
let JOURNEY = resolvePalette(opts.palette).map(hexToRgb); // the accent journey (§9)
|
|
161
|
+
let curAccent = hexToRgb(cfg.accent);
|
|
162
|
+
let hoverAccent = null;
|
|
163
|
+
let threadLinks = [];
|
|
164
|
+
let movers = [];
|
|
165
|
+
// element emit (§22.3): bodies that clone a decorative template into the DOM, budgeted by data-max.
|
|
166
|
+
let emitters = [];
|
|
167
|
+
let sparks = [];
|
|
168
|
+
let eventEls = [];
|
|
169
|
+
let engaged = []; // [data-hot] listeners, for teardown
|
|
170
|
+
// shadow-DOM participation (docs/engine-reference/shadow-dom.md): encapsulated components dispatch composed
|
|
171
|
+
// register/unregister/update events; the field registers the HOST and never inspects the
|
|
172
|
+
// shadow tree. The events bubble (composed) to the document, so we listen there.
|
|
173
|
+
const shadow = new ShadowRegistry();
|
|
174
|
+
// Coalesce a burst of registration events (N components mounting at once) into ONE rescan on
|
|
175
|
+
// the next microtask, instead of a full-document scan per event.
|
|
176
|
+
let scanQueued = false;
|
|
177
|
+
const scheduleScan = () => {
|
|
178
|
+
if (scanQueued)
|
|
179
|
+
return;
|
|
180
|
+
scanQueued = true;
|
|
181
|
+
queueMicrotask(() => {
|
|
182
|
+
scanQueued = false;
|
|
183
|
+
scan();
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
// clear the field's CSS write-back when a still-connected host leaves the field, so it
|
|
187
|
+
// doesn't keep a frozen `--d` glow (a removed light-DOM element is gone, so it needs no clear).
|
|
188
|
+
const clearWriteback = (el) => {
|
|
189
|
+
for (const v of ['--d', '--field-density', '--load', '--mass', '--entropy', '--coherence', '--temperature'])
|
|
190
|
+
el.style.removeProperty(v);
|
|
191
|
+
};
|
|
192
|
+
const onRegister = (e) => {
|
|
193
|
+
const d = e.detail;
|
|
194
|
+
if (d?.element) {
|
|
195
|
+
shadow.register(d);
|
|
196
|
+
scheduleScan();
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const onUnregister = (e) => {
|
|
200
|
+
const d = e.detail;
|
|
201
|
+
if (d?.element) {
|
|
202
|
+
shadow.unregister(d.element);
|
|
203
|
+
clearWriteback(d.writeTarget ?? d.element);
|
|
204
|
+
scheduleScan();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const onUpdateBody = scheduleScan; // attrs/geometry changed → re-scan (coalesced)
|
|
208
|
+
const probe = { x: 0, y: 0, vx: 0, vy: 0, m: 1, heat: 0, size: 1, cap: null };
|
|
209
|
+
const t0 = wallNow();
|
|
210
|
+
const env = {
|
|
211
|
+
dx: 0,
|
|
212
|
+
dy: 0,
|
|
213
|
+
dz: 0,
|
|
214
|
+
dist: 1,
|
|
215
|
+
form: { ...FORMATION_BY.ambient.preset },
|
|
216
|
+
W: 0,
|
|
217
|
+
H: 0,
|
|
218
|
+
D: cfg.depth, // the optional z volume (z-axis.md); 0 = the flat field
|
|
219
|
+
t: 0,
|
|
220
|
+
frameN: 0,
|
|
221
|
+
dt: reduceMotion ? 0 : 1,
|
|
222
|
+
c: 12,
|
|
223
|
+
G: 1,
|
|
224
|
+
scrollV: 0,
|
|
225
|
+
rng,
|
|
226
|
+
spark: (x, y, power, color) => spawnSpark(x, y, power, color),
|
|
227
|
+
supernova: (b) => {
|
|
228
|
+
// release exactly what was captured — radial, from the core (§6.9, accretion.ts). Held matter
|
|
229
|
+
// is conserved: released particles stay in the pool. `releaseCaptured` resets b.accreted to 0.
|
|
230
|
+
const released = releaseCaptured(store.particles, b, rng);
|
|
231
|
+
const justReleased = new Set(released);
|
|
232
|
+
// the blast shoves nearby *free* matter outward (but not the matter it just released).
|
|
233
|
+
for (const q of store.particles) {
|
|
234
|
+
if (justReleased.has(q))
|
|
235
|
+
continue;
|
|
236
|
+
const dx = q.x - b.cx;
|
|
237
|
+
const dy = q.y - b.cy;
|
|
238
|
+
const d = Math.hypot(dx, dy) || 1;
|
|
239
|
+
if (d < 320) {
|
|
240
|
+
const f = (1 - d / 320) * 4;
|
|
241
|
+
q.vx += (dx / d) * f;
|
|
242
|
+
q.vy += (dy / d) * f;
|
|
243
|
+
q.heat = Math.max(q.heat, 0.8);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// the blast also tears nearby bound matter off the Currents (§6.9, §2.4).
|
|
247
|
+
tearBoundNear(bound, waves, b.cx, b.cy, 320, W, H, env.t, (p) => void store.add(newParticle(p)));
|
|
248
|
+
// release docks any DOM elements this sink had captured (§22.3, element capture).
|
|
249
|
+
undockFrom(b);
|
|
250
|
+
// and fires field:released on the falling edge of accreting (capture/release events, §22.5).
|
|
251
|
+
if (b.el.dataset.fxCap === '1') {
|
|
252
|
+
b.el.dataset.fxCap = '0';
|
|
253
|
+
fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
// class-[S] sources emit through here, capped by a hard pool ceiling (the
|
|
257
|
+
// conservation backstop, §20.1): even a source with no lifespan can't grow the
|
|
258
|
+
// count without bound. The ceiling is generous above the base field so normal
|
|
259
|
+
// emission is never starved.
|
|
260
|
+
spawn: (p) => {
|
|
261
|
+
if (store.size >= spawnCeiling)
|
|
262
|
+
return;
|
|
263
|
+
store.add(newParticle(p));
|
|
264
|
+
},
|
|
265
|
+
neighbors: (p, r) => store.neighbors(p, r),
|
|
266
|
+
// scalar field-buffer service (§20.1 class [C]): created on demand, so a page
|
|
267
|
+
// with no diffuse/propagate body allocates nothing. Grids named "wave…" use the
|
|
268
|
+
// wave scheme; everything else diffuses.
|
|
269
|
+
grid: (name) => {
|
|
270
|
+
let g = grids.get(name);
|
|
271
|
+
if (!g) {
|
|
272
|
+
const mode = name.startsWith('wave') ? 'wave' : name.startsWith('memory') ? 'memory' : 'diffuse';
|
|
273
|
+
g = new ScalarGridImpl(W, H, mode);
|
|
274
|
+
grids.set(name, g);
|
|
275
|
+
}
|
|
276
|
+
return g;
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
function spawnSpark(x, y, power, color) {
|
|
280
|
+
if (reduceMotion || sparks.length > 260)
|
|
281
|
+
return;
|
|
282
|
+
const c = color ? hexToRgb(color) : [255, 122, 69]; // WARM default (§20.8)
|
|
283
|
+
const n = sparkCount(power);
|
|
284
|
+
for (let k = 0; k < n; k++) {
|
|
285
|
+
const a = rng() * 6.28318;
|
|
286
|
+
const s = 0.8 + rng() * (power > 0 ? power : 1) * 1.7;
|
|
287
|
+
sparks.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 1, c });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function newParticle(seed = {}) {
|
|
291
|
+
const size = seed.size ?? 0.7 + rng() * 1.8;
|
|
292
|
+
return {
|
|
293
|
+
x: seed.x ?? rng() * W,
|
|
294
|
+
y: seed.y ?? rng() * H,
|
|
295
|
+
vx: seed.vx ?? (rng() - 0.5) * 0.25,
|
|
296
|
+
vy: seed.vy ?? (rng() - 0.5) * 0.18,
|
|
297
|
+
// the optional z lane (z-axis.md): seeded through the volume in a depth > 0
|
|
298
|
+
// field, pinned to the plane (0) in the flat default — through the injectable rng (#371).
|
|
299
|
+
z: seed.z ?? (cfg.depth > 0 ? rng() * cfg.depth : 0),
|
|
300
|
+
vz: seed.vz ?? (cfg.depth > 0 ? (rng() - 0.5) * 0.18 : 0),
|
|
301
|
+
m: seed.m ?? (cfg.mass ? size : 1), // mass ∝ size when first-class mass is on
|
|
302
|
+
heat: seed.heat ?? 0,
|
|
303
|
+
size,
|
|
304
|
+
gx: seed.gx ?? rng(),
|
|
305
|
+
gy: seed.gy ?? rng(),
|
|
306
|
+
gz: seed.gz ?? rng(),
|
|
307
|
+
cap: null,
|
|
308
|
+
...(seed.age != null ? { age: seed.age } : {}), // mortal matter (a [S] source)
|
|
309
|
+
...(seed.color != null ? { color: seed.color } : {}),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// optional per-particle data records (FieldHandle.seed) — round-robined onto the base pool, with
|
|
313
|
+
// each record's weight scaling that particle's mass + size. Re-applied on every build (resize/density).
|
|
314
|
+
let seeded = [];
|
|
315
|
+
function applySeed() {
|
|
316
|
+
if (!seeded.length)
|
|
317
|
+
return;
|
|
318
|
+
const ps = store.particles;
|
|
319
|
+
for (let i = 0; i < ps.length; i++) {
|
|
320
|
+
const a = seeded[i % seeded.length];
|
|
321
|
+
ps[i].atom = a;
|
|
322
|
+
const w = typeof a.weight === 'number' ? Math.max(0, Math.min(1, a.weight)) : 0.5;
|
|
323
|
+
ps[i].size *= 0.6 + w * 0.9; // richer record → bigger dot
|
|
324
|
+
ps[i].m *= 0.6 + w * 1.2; // richer record → heavier (more central)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function build() {
|
|
328
|
+
store.clear();
|
|
329
|
+
const n = Math.round(130 * cfg.density);
|
|
330
|
+
for (let i = 0; i < n; i++)
|
|
331
|
+
store.add(newParticle());
|
|
332
|
+
applySeed();
|
|
333
|
+
// the Currents (§24) are opt-out: with waves off, the field is just the free particles.
|
|
334
|
+
waves = cfg.waves ? buildWaves(WAVE_RGB) : [];
|
|
335
|
+
bound = cfg.waves ? buildBound(waves.length, cfg.density, rng) : [];
|
|
336
|
+
boundTarget = bound.length;
|
|
337
|
+
}
|
|
338
|
+
function scan() {
|
|
339
|
+
const scanned = scanBodies(host.root);
|
|
340
|
+
// merge event-registered shadow-DOM hosts (deduped — a light-DOM host that also fires
|
|
341
|
+
// a registration event is counted once). Registration is the canonical discovery path;
|
|
342
|
+
// light-DOM scanning is the compatibility fallback (shadow-dom.md §16).
|
|
343
|
+
if (shadow.size > 0) {
|
|
344
|
+
const seen = new Set(scanned.map((b) => b.el));
|
|
345
|
+
bodies = scanned.concat(shadow.bodies(bodyFromElement).filter((b) => !seen.has(b.el)));
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
bodies = scanned;
|
|
349
|
+
}
|
|
350
|
+
measureBodies(bodies, W, H);
|
|
351
|
+
bindEngagement();
|
|
352
|
+
// Reconcile movers: carry forward offset + dock state for elements that persist across
|
|
353
|
+
// rescans (shadow-DOM re-register, Astro nav re-mounts, explicit rescan()). An element that
|
|
354
|
+
// was docked under the old record but is absent from the new scan has already left the DOM;
|
|
355
|
+
// nothing to restore (the isConnected guard in updateMovers already cleared its dock state).
|
|
356
|
+
// An element that is present in both scans keeps its in-flight offset and dock progress so a
|
|
357
|
+
// rescan during a live dock animation doesn't reset the element to its layout slot.
|
|
358
|
+
const prevMovers = new Map(movers.map((mv) => [mv.el, mv]));
|
|
359
|
+
movers = [...host.root.querySelectorAll('[data-move]')].map((node) => {
|
|
360
|
+
const el = node;
|
|
361
|
+
const r = el.getBoundingClientRect();
|
|
362
|
+
const seeded = Number.parseFloat(el.dataset.mass ?? '');
|
|
363
|
+
const mEl = Number.isFinite(seeded) ? seeded : elementMass(r.width * r.height);
|
|
364
|
+
// `data-move="layout"` opts into the self-laying-out forces (Concept 3): mutual
|
|
365
|
+
// repulsion + density pressure. Plain `data-move` just drifts with the field.
|
|
366
|
+
const layout = (el.dataset.move ?? '').trim() === 'layout';
|
|
367
|
+
// `data-dock` opts into element capture (§22.3): the element docks when it falls into a sink.
|
|
368
|
+
const dockable = el.hasAttribute('data-dock');
|
|
369
|
+
// `data-warp` opts into element relocate (§22.3): teleport on entering a warp throat.
|
|
370
|
+
const warpable = el.hasAttribute('data-warp');
|
|
371
|
+
const prev = prevMovers.get(el);
|
|
372
|
+
if (prev) {
|
|
373
|
+
// Persist the in-flight state: the element was already known, keep its offset + dock
|
|
374
|
+
// progress. Re-check dockable/warpable/layout in case attributes changed. mEl re-measured.
|
|
375
|
+
return { el, o: prev.o, mEl, layout, dockable, dock: prev.dock, docked: prev.docked, warpable, warpCool: prev.warpCool };
|
|
376
|
+
}
|
|
377
|
+
return { el, o: { x: 0, y: 0, vx: 0, vy: 0 }, mEl, layout, dockable, dock: { dock: 0 }, docked: null, warpable, warpCool: 0 };
|
|
378
|
+
});
|
|
379
|
+
eventEls = [...host.root.querySelectorAll('[data-on]')].map((node) => {
|
|
380
|
+
const el = node;
|
|
381
|
+
return {
|
|
382
|
+
el,
|
|
383
|
+
body: bodies.find((b) => b.el === el) ?? null,
|
|
384
|
+
bindings: parseEventBindings(el.dataset.on ?? ''),
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
// resolve `warp` pairings (§22.3 relocate): a body's data-pair selector → the paired body, whose
|
|
388
|
+
// live centre becomes the relocate target each frame (updateWarpTargets).
|
|
389
|
+
for (const b of bodies) {
|
|
390
|
+
if (!b.pair)
|
|
391
|
+
continue;
|
|
392
|
+
let target = null;
|
|
393
|
+
try {
|
|
394
|
+
target = host.root.querySelector(b.pair);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
target = null; // invalid selector → unpaired, the force no-ops
|
|
398
|
+
}
|
|
399
|
+
b.pairBody = target ? bodies.find((o) => o.el === target) : undefined;
|
|
400
|
+
}
|
|
401
|
+
// element emit (§22.3): bodies with data-emit clone a referenced template, capped by data-max.
|
|
402
|
+
// Reconcile across rescans: emitter elements that persist carry their existing clones forward
|
|
403
|
+
// (cap × rescans accumulation is the regression from #260). Clones that have been disconnected
|
|
404
|
+
// from the DOM (e.g. the emitter's subtree was replaced during an Astro nav) are pruned here
|
|
405
|
+
// before re-use. Emitter elements that have left the DOM have their clones removed.
|
|
406
|
+
const prevEmitters = new Map(emitters.map((em) => [em.el, em]));
|
|
407
|
+
// clean up clones belonging to emitter elements that are no longer in the scan root.
|
|
408
|
+
for (const [el, em] of prevEmitters) {
|
|
409
|
+
if (!host.root.contains(el)) {
|
|
410
|
+
for (const clone of em.emitted)
|
|
411
|
+
clone.remove();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
emitters = [...host.root.querySelectorAll('[data-emit]')].map((node) => {
|
|
415
|
+
const el = node;
|
|
416
|
+
const sel = el.dataset.emit ?? '';
|
|
417
|
+
let tmpl = null;
|
|
418
|
+
try {
|
|
419
|
+
tmpl = sel ? host.root.querySelector(sel) : null;
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
tmpl = null;
|
|
423
|
+
}
|
|
424
|
+
const cap = Math.max(0, Math.round(Number.parseFloat(el.dataset.max ?? '') || 8));
|
|
425
|
+
const prev = prevEmitters.get(el);
|
|
426
|
+
if (prev) {
|
|
427
|
+
// Carry the existing clones forward, but prune any that were disconnected while the
|
|
428
|
+
// emitter element itself persisted (partial subtree replacement). Cap may have changed.
|
|
429
|
+
const live = prev.emitted.filter((c) => c.isConnected);
|
|
430
|
+
// Remove clones that exceed the (possibly changed) cap.
|
|
431
|
+
while (live.length > cap)
|
|
432
|
+
live.pop().remove();
|
|
433
|
+
return { el, tmpl, cap, emitted: live };
|
|
434
|
+
}
|
|
435
|
+
return { el, tmpl, cap, emitted: [] };
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
// refresh each warp body's relocate target from its paired body's live centre (§22.3 relocate).
|
|
439
|
+
function updateWarpTargets() {
|
|
440
|
+
for (const b of bodies) {
|
|
441
|
+
if (b.pairBody) {
|
|
442
|
+
// If the paired element has left the DOM, sever the warp link so the wormhole closes
|
|
443
|
+
// rather than relocating matter to a ghost node. A later rescan re-resolves naturally
|
|
444
|
+
// when (or if) the element returns. The isConnected check is cheap — only reached when
|
|
445
|
+
// pairBody is set, so the common zero-pair case pays nothing.
|
|
446
|
+
if (!b.pairBody.el.isConnected) {
|
|
447
|
+
b.warpHas = false;
|
|
448
|
+
b.pairBody = undefined;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (b.pairBody.vis) {
|
|
452
|
+
b.warpX = b.pairBody.cx;
|
|
453
|
+
b.warpY = b.pairBody.cy;
|
|
454
|
+
b.warpHas = true;
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
b.warpHas = false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
else if (b.pair) {
|
|
461
|
+
b.warpHas = false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// element emit (§22.3): clone the decorative template into the emit element, budgeted by cap. Clones
|
|
466
|
+
// are aria-hidden + inert (decorative, not meaningful; focusable descendants must not reach tab
|
|
467
|
+
// order) and id-stripped (no duplicate ids); removed on destroy.
|
|
468
|
+
function updateEmitters() {
|
|
469
|
+
if (emitters.length === 0 || env.frameN % 30 !== 0)
|
|
470
|
+
return;
|
|
471
|
+
for (const em of emitters) {
|
|
472
|
+
if (!em.tmpl || em.emitted.length >= em.cap)
|
|
473
|
+
continue;
|
|
474
|
+
const clone = em.tmpl.cloneNode(true);
|
|
475
|
+
clone.removeAttribute('id');
|
|
476
|
+
clone.setAttribute('aria-hidden', 'true');
|
|
477
|
+
clone.setAttribute('inert', '');
|
|
478
|
+
clone.dataset.fieldEmitted = '';
|
|
479
|
+
em.el.appendChild(clone);
|
|
480
|
+
em.emitted.push(clone);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function updateEvents() {
|
|
484
|
+
if (eventEls.length === 0)
|
|
485
|
+
return;
|
|
486
|
+
for (const ev of eventEls) {
|
|
487
|
+
const s = ev.body
|
|
488
|
+
? { d: ev.body.d, on: ev.body.on, accreted: ev.body.accreted }
|
|
489
|
+
: { d: 0, on: ev.el.dataset.active === '1', accreted: 0 };
|
|
490
|
+
for (const bind of ev.bindings) {
|
|
491
|
+
const active = triggerActive(bind.trigger, s);
|
|
492
|
+
if (active && !bind.armed) {
|
|
493
|
+
bind.armed = true;
|
|
494
|
+
ev.el.dispatchEvent(new CustomEvent(bind.event, {
|
|
495
|
+
bubbles: true,
|
|
496
|
+
detail: { trigger: bind.trigger, d: s.d, on: s.on, accreted: s.accreted },
|
|
497
|
+
}));
|
|
498
|
+
}
|
|
499
|
+
else if (!active) {
|
|
500
|
+
bind.armed = false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// dispatch a discrete field event on an element, with the forces:* alias (migration window).
|
|
506
|
+
function fireCaptureEvent(el, name, detail) {
|
|
507
|
+
el.dispatchEvent(new CustomEvent('field:' + name, { bubbles: true, composed: true, detail }));
|
|
508
|
+
el.dispatchEvent(new CustomEvent('forces:' + name, { bubbles: true, composed: true, detail }));
|
|
509
|
+
}
|
|
510
|
+
// capture/release events for sink BODIES (particle accretion): fire field:captured on the rising
|
|
511
|
+
// edge of accreting and field:released on the falling edge (§22.5). Release is also fired directly
|
|
512
|
+
// from supernova so a same-frame fill+release never drops it.
|
|
513
|
+
function updateCaptureEvents() {
|
|
514
|
+
for (const b of bodies) {
|
|
515
|
+
if (!b.vis || b.tokens.indexOf('sink') < 0)
|
|
516
|
+
continue;
|
|
517
|
+
const armed = b.el.dataset.fxCap === '1';
|
|
518
|
+
const edge = captureEdge(armed, b.accreted > 0);
|
|
519
|
+
if (edge.fire === 'captured') {
|
|
520
|
+
b.el.dataset.fxCap = '1';
|
|
521
|
+
fireCaptureEvent(b.el, 'captured', { accreted: b.accreted, load: sinkLoad(b) });
|
|
522
|
+
}
|
|
523
|
+
else if (edge.fire === 'released') {
|
|
524
|
+
b.el.dataset.fxCap = '0';
|
|
525
|
+
fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// release any DOM elements a sink had docked (§22.3) — restore their transform + a11y, fire
|
|
530
|
+
// field:released. Called from supernova so element capture is conserved like particle capture.
|
|
531
|
+
function undockFrom(b) {
|
|
532
|
+
for (const mv of movers) {
|
|
533
|
+
if (mv.docked !== b)
|
|
534
|
+
continue;
|
|
535
|
+
mv.docked = null;
|
|
536
|
+
mv.dock.dock = 0;
|
|
537
|
+
if (mv.el.getAttribute('aria-hidden') === 'true')
|
|
538
|
+
mv.el.removeAttribute('aria-hidden');
|
|
539
|
+
mv.el.removeAttribute('inert');
|
|
540
|
+
mv.el.style.opacity = '';
|
|
541
|
+
fireCaptureEvent(mv.el, 'released', {});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function updateMovers() {
|
|
545
|
+
if (movers.length === 0)
|
|
546
|
+
return;
|
|
547
|
+
// current screen centres (the rect already reflects each element's transform), so the
|
|
548
|
+
// self-laying-out repulsion (Concept 3) sees where everything actually sits this frame.
|
|
549
|
+
const centers = movers.map((mv) => {
|
|
550
|
+
const r = mv.el.getBoundingClientRect();
|
|
551
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
552
|
+
});
|
|
553
|
+
for (let i = 0; i < movers.length; i++) {
|
|
554
|
+
const mv = movers[i];
|
|
555
|
+
// If this element has been removed from the DOM while docked, drop the dock reference so
|
|
556
|
+
// the sink no longer believes it holds the element. Restore is moot for a detached node;
|
|
557
|
+
// we just clear state so no per-frame work runs for it going forward. Consistent with how
|
|
558
|
+
// the rescan reconciliation (scan()) drops stale bodies: absent nodes are simply gone.
|
|
559
|
+
if (!mv.el.isConnected) {
|
|
560
|
+
if (mv.docked) {
|
|
561
|
+
mv.docked = null;
|
|
562
|
+
mv.dock.dock = 0;
|
|
563
|
+
}
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const cx = centers[i].x;
|
|
567
|
+
const cy = centers[i].y;
|
|
568
|
+
// docked: collapse toward the sink core and hold there, skipping force integration (§22.3).
|
|
569
|
+
if (mv.docked) {
|
|
570
|
+
const home = { x: cx - mv.o.x, y: cy - mv.o.y };
|
|
571
|
+
mv.dock.dock = stepDock(mv.dock.dock, 1);
|
|
572
|
+
const tf = dockTransform(home, mv.o, { x: mv.docked.cx, y: mv.docked.cy }, mv.dock.dock);
|
|
573
|
+
mv.el.style.transform = `translate(${tf.tx.toFixed(2)}px, ${tf.ty.toFixed(2)}px) scale(${tf.scale.toFixed(3)})`;
|
|
574
|
+
mv.el.style.opacity = tf.opacity.toFixed(3);
|
|
575
|
+
if (mv.dock.dock >= 1 && mv.el.getAttribute('aria-hidden') !== 'true') {
|
|
576
|
+
mv.el.setAttribute('aria-hidden', 'true');
|
|
577
|
+
mv.el.setAttribute('inert', '');
|
|
578
|
+
}
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
probe.x = cx;
|
|
582
|
+
probe.y = cy;
|
|
583
|
+
probe.vx = 0;
|
|
584
|
+
probe.vy = 0;
|
|
585
|
+
probe.heat = 0;
|
|
586
|
+
probe.cap = null;
|
|
587
|
+
// probe the net field force at the element's centre (reuse the force modules)
|
|
588
|
+
for (const b of bodies) {
|
|
589
|
+
if (!b.vis || b.tokens.length === 0 || b.el === mv.el)
|
|
590
|
+
continue;
|
|
591
|
+
const dx = b.cx - cx;
|
|
592
|
+
const dy = b.cy - cy;
|
|
593
|
+
const d = Math.hypot(dx, dy);
|
|
594
|
+
env.dx = dx;
|
|
595
|
+
env.dy = dy;
|
|
596
|
+
env.dist = d < 1 ? 1 : d;
|
|
597
|
+
for (const tok of b.tokens)
|
|
598
|
+
reg.forces[tok]?.apply(b, probe, env);
|
|
599
|
+
}
|
|
600
|
+
probe.cap = null; // never let a mover get captured
|
|
601
|
+
const a = anchorForce(mv.o);
|
|
602
|
+
let fx = probe.vx + a.x;
|
|
603
|
+
let fy = probe.vy + a.y;
|
|
604
|
+
// Concept 3 — self-laying-out: this element pushes off the others and drifts off
|
|
605
|
+
// dense field regions, so a cluster spreads and re-settles (e.g. on resize).
|
|
606
|
+
if (mv.layout) {
|
|
607
|
+
const others = centers.filter((_, j) => j !== i);
|
|
608
|
+
const rep = repelForce({ x: cx, y: cy }, others);
|
|
609
|
+
const press = densityPush((sx, sy) => store.near(sx, sy, 40).length, cx, cy, 16, 6);
|
|
610
|
+
fx += rep.x + press.x;
|
|
611
|
+
fy += rep.y + press.y;
|
|
612
|
+
}
|
|
613
|
+
// the layout-slot centre (home), captured before integration mutates the offset.
|
|
614
|
+
const home = { x: cx - mv.o.x, y: cy - mv.o.y };
|
|
615
|
+
integrateOffset(mv.o, fx, fy, mv.mEl, 0.9);
|
|
616
|
+
mv.el.style.transform = `translate(${mv.o.x.toFixed(2)}px, ${mv.o.y.toFixed(2)}px)`;
|
|
617
|
+
// element capture (§22.3): a [data-dock] mover that lands inside a sink's radius docks. Test
|
|
618
|
+
// against the post-integration centre (home + new offset); never let a body dock into itself.
|
|
619
|
+
if (mv.dockable) {
|
|
620
|
+
const here = { x: home.x + mv.o.x, y: home.y + mv.o.y };
|
|
621
|
+
const sink = bodies.find((b) => b.vis && b.el !== mv.el && b.tokens.indexOf('sink') >= 0 && withinCapture(here, b));
|
|
622
|
+
if (sink) {
|
|
623
|
+
mv.docked = sink;
|
|
624
|
+
fireCaptureEvent(mv.el, 'captured', { sink: sink.el });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// element relocate (§22.3): a [data-warp] mover entering a warp throat teleports its offset so
|
|
628
|
+
// its screen position jumps to the throat's pair. A cooldown prevents immediate re-triggering.
|
|
629
|
+
if (mv.warpCool > 0)
|
|
630
|
+
mv.warpCool -= 1;
|
|
631
|
+
if (mv.warpable && mv.warpCool === 0) {
|
|
632
|
+
const here = { x: home.x + mv.o.x, y: home.y + mv.o.y };
|
|
633
|
+
const throat = bodies.find((b) => b.vis && b.el !== mv.el && b.warpHas && b.tokens.indexOf('warp') >= 0 && withinCapture(here, b));
|
|
634
|
+
if (throat) {
|
|
635
|
+
// place the element's centre at the paired throat: offset = pairCentre − home.
|
|
636
|
+
mv.o.x = throat.warpX - home.x;
|
|
637
|
+
mv.o.y = throat.warpY - home.y;
|
|
638
|
+
mv.o.vx = 0;
|
|
639
|
+
mv.o.vy = 0;
|
|
640
|
+
mv.el.style.transform = `translate(${mv.o.x.toFixed(2)}px, ${mv.o.y.toFixed(2)}px)`;
|
|
641
|
+
mv.warpCool = 45;
|
|
642
|
+
fireCaptureEvent(mv.el, 'relocated', { from: throat.el });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// engagement: hover/focus a [data-hot] element → it activates (b.on, lighting
|
|
648
|
+
// the spine + on-state forces) and overrides the accent with its data-color (§9).
|
|
649
|
+
function bindEngagement() {
|
|
650
|
+
host.root.querySelectorAll('[data-hot]').forEach((node) => {
|
|
651
|
+
const el = node;
|
|
652
|
+
if (el.dataset.fxEngaged === '1')
|
|
653
|
+
return;
|
|
654
|
+
el.dataset.fxEngaged = '1';
|
|
655
|
+
const enter = () => {
|
|
656
|
+
el.dataset.active = '1';
|
|
657
|
+
hoverAccent = el.dataset.color ?? null;
|
|
658
|
+
const group = el.closest('[data-index][data-threads]');
|
|
659
|
+
if (group) {
|
|
660
|
+
const sibs = [...group.querySelectorAll('[data-hot]')].filter((s) => s !== el);
|
|
661
|
+
setThreads(sibs.map((s) => ({ a: el, b: s, color: el.dataset.color ?? undefined })));
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
const leave = () => {
|
|
665
|
+
el.dataset.active = '0';
|
|
666
|
+
hoverAccent = null;
|
|
667
|
+
setThreads(null);
|
|
668
|
+
};
|
|
669
|
+
el.addEventListener('pointerenter', enter);
|
|
670
|
+
el.addEventListener('pointerleave', leave);
|
|
671
|
+
el.addEventListener('focus', enter);
|
|
672
|
+
el.addEventListener('blur', leave);
|
|
673
|
+
engaged.push({ el, enter, leave });
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
function setThreads(list) {
|
|
677
|
+
threadLinks = (list ?? []).map((t) => ({
|
|
678
|
+
a: t.a,
|
|
679
|
+
b: t.b,
|
|
680
|
+
c: hexToRgb(t.color ?? cfg.accent),
|
|
681
|
+
seed: rng() * 6.28,
|
|
682
|
+
}));
|
|
683
|
+
}
|
|
684
|
+
function drawThreads() {
|
|
685
|
+
if (threadLinks.length === 0)
|
|
686
|
+
return;
|
|
687
|
+
const time = env.t;
|
|
688
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
689
|
+
for (const th of threadLinks) {
|
|
690
|
+
const ra = th.a.getBoundingClientRect();
|
|
691
|
+
const rb = th.b.getBoundingClientRect();
|
|
692
|
+
const ax = ra.left + ra.width / 2;
|
|
693
|
+
const ay = ra.top + ra.height / 2;
|
|
694
|
+
const bx = rb.left + rb.width / 2;
|
|
695
|
+
const by = rb.top + rb.height / 2;
|
|
696
|
+
const [cr, cg, cb] = th.c;
|
|
697
|
+
ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.22)`;
|
|
698
|
+
ctx.lineWidth = 1;
|
|
699
|
+
ctx.beginPath();
|
|
700
|
+
ctx.moveTo(ax, ay);
|
|
701
|
+
ctx.lineTo(bx, by);
|
|
702
|
+
ctx.stroke();
|
|
703
|
+
for (let k = 0; k < 3; k++) {
|
|
704
|
+
const tt = (time * 0.6 + th.seed + k / 3) % 1;
|
|
705
|
+
const px = ax + (bx - ax) * tt;
|
|
706
|
+
const py = ay + (by - ay) * tt;
|
|
707
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${(1 - tt) * 0.9})`;
|
|
708
|
+
ctx.beginPath();
|
|
709
|
+
ctx.arc(px, py, 2.2, 0, 6.28318);
|
|
710
|
+
ctx.fill();
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
714
|
+
}
|
|
715
|
+
// Size the drawing surfaces' backing stores to the current W×H (dpr-scaled). Split out of
|
|
716
|
+
// resize() so the lazy `setRender('none' → drawing)` path can run exactly this once. With no
|
|
717
|
+
// context (a field created with `render: 'none'`, §13.7 / #297) it is a no-op: the canvas
|
|
718
|
+
// backing store stays 0×0 while W/H — the simulation space — keep tracking the viewport.
|
|
719
|
+
function sizeSurfaces(dprRaw) {
|
|
720
|
+
if (!ctx)
|
|
721
|
+
return;
|
|
722
|
+
const dpr = Math.min(dprRaw || 1, 2);
|
|
723
|
+
canvas.width = Math.floor(W * dpr);
|
|
724
|
+
canvas.height = Math.floor(H * dpr);
|
|
725
|
+
canvas.style.width = W + 'px';
|
|
726
|
+
canvas.style.height = H + 'px';
|
|
727
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
728
|
+
// size the overlay surface's backing store to match (same dpr transform → same CSS coords).
|
|
729
|
+
overlayBackend?.size(W, H, dpr);
|
|
730
|
+
}
|
|
731
|
+
function resize() {
|
|
732
|
+
const vp = host.viewport();
|
|
733
|
+
W = vp.width;
|
|
734
|
+
H = vp.height;
|
|
735
|
+
sizeSurfaces(vp.dpr);
|
|
736
|
+
env.W = W;
|
|
737
|
+
env.H = H;
|
|
738
|
+
maxScroll = host.scrollHeight() - H || 1;
|
|
739
|
+
for (const g of grids.values())
|
|
740
|
+
g.resize(W, H); // keep field buffers viewport-sized
|
|
741
|
+
if (cfg.heatmap) {
|
|
742
|
+
if (!heatmap)
|
|
743
|
+
heatmap = new Heatmap(W, H);
|
|
744
|
+
else
|
|
745
|
+
heatmap.resize(W, H);
|
|
746
|
+
}
|
|
747
|
+
build();
|
|
748
|
+
scan();
|
|
749
|
+
}
|
|
750
|
+
function drawWaves() {
|
|
751
|
+
const time = env.t;
|
|
752
|
+
const STEP = 16;
|
|
753
|
+
for (const w of waves) {
|
|
754
|
+
const [cr, cg, cb] = w.color;
|
|
755
|
+
ctx.beginPath();
|
|
756
|
+
ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
|
|
757
|
+
for (let x = 0; x <= W; x += STEP)
|
|
758
|
+
ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
|
|
759
|
+
ctx.lineTo(W, H);
|
|
760
|
+
ctx.lineTo(0, H);
|
|
761
|
+
ctx.closePath();
|
|
762
|
+
const ty = w.baseFrac * H + w.offsetY - w.amp;
|
|
763
|
+
const grad = ctx.createLinearGradient(0, ty, 0, ty + 320);
|
|
764
|
+
grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(0.11 + w.depth * 0.05) * boot})`);
|
|
765
|
+
grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
|
766
|
+
ctx.fillStyle = grad;
|
|
767
|
+
ctx.fill();
|
|
768
|
+
}
|
|
769
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
770
|
+
for (const w of waves) {
|
|
771
|
+
const [cr, cg, cb] = w.color;
|
|
772
|
+
ctx.beginPath();
|
|
773
|
+
ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
|
|
774
|
+
for (let x = 0; x <= W; x += STEP)
|
|
775
|
+
ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
|
|
776
|
+
// glow via a wide faint underlay then a crisp line (both additive) — not
|
|
777
|
+
// shadowBlur, which made each of the five long strokes cost several ×.
|
|
778
|
+
ctx.lineWidth = 5;
|
|
779
|
+
ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.05 + w.depth * 0.04) * boot})`;
|
|
780
|
+
ctx.stroke();
|
|
781
|
+
ctx.lineWidth = 1.2;
|
|
782
|
+
ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.3 + w.depth * 0.22) * boot})`;
|
|
783
|
+
ctx.stroke();
|
|
784
|
+
}
|
|
785
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
786
|
+
}
|
|
787
|
+
function drawBound() {
|
|
788
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
789
|
+
const time = env.t;
|
|
790
|
+
let i = 0;
|
|
791
|
+
for (const p of bound) {
|
|
792
|
+
const w = waves[p.wi];
|
|
793
|
+
if (!w) {
|
|
794
|
+
i++;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (env.dt) {
|
|
798
|
+
p.progress += p.speed;
|
|
799
|
+
if (p.progress > 1)
|
|
800
|
+
p.progress -= 1;
|
|
801
|
+
else if (p.progress < 0)
|
|
802
|
+
p.progress += 1;
|
|
803
|
+
}
|
|
804
|
+
const x = p.progress * W;
|
|
805
|
+
const y = waveYat(w, x, time, H, 1, 1, pull) + p.phase * 32;
|
|
806
|
+
const [cr, cg, cb] = w.color;
|
|
807
|
+
const tw = p.glow ? 0.6 + 0.4 * Math.sin(time * 2.2 + i) : 0.85;
|
|
808
|
+
if (p.glow) {
|
|
809
|
+
// additive halo instead of shadowBlur (the canvas is composited 'lighter')
|
|
810
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.16 * tw * boot})`;
|
|
811
|
+
ctx.beginPath();
|
|
812
|
+
ctx.arc(x, y, p.size + 2.5, 0, 6.28318);
|
|
813
|
+
ctx.fill();
|
|
814
|
+
}
|
|
815
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
|
|
816
|
+
ctx.beginPath();
|
|
817
|
+
ctx.arc(x, y, p.size, 0, 6.28318);
|
|
818
|
+
ctx.fill();
|
|
819
|
+
i++;
|
|
820
|
+
}
|
|
821
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
822
|
+
}
|
|
823
|
+
// conserved attention (§2.4): redistribute one finite strength budget across the
|
|
824
|
+
// visible bodies by demand (strength × engagement). Rest-neutral and total-
|
|
825
|
+
// conserving, so the live field is unchanged until a body is engaged. Runs before
|
|
826
|
+
// step() so the integrator reads this frame's `attn`.
|
|
827
|
+
function applyAttention() {
|
|
828
|
+
if (!cfg.attention)
|
|
829
|
+
return;
|
|
830
|
+
for (const b of bodies)
|
|
831
|
+
b.attn = 1;
|
|
832
|
+
const vis = bodies.filter((b) => b.vis && b.tokens.length > 0);
|
|
833
|
+
if (vis.length === 0)
|
|
834
|
+
return;
|
|
835
|
+
const muls = attentionMuls(vis);
|
|
836
|
+
for (let i = 0; i < vis.length; i++)
|
|
837
|
+
vis[i].attn = muls[i];
|
|
838
|
+
}
|
|
839
|
+
// cross-boundary causality (Concept 4): a saturated body's density spills to its
|
|
840
|
+
// neighbours, so engaging one lights its siblings. Writes `--lit` and fires a
|
|
841
|
+
// debounced field:lit / field:dim on each element as it crosses the threshold.
|
|
842
|
+
function applyCausality() {
|
|
843
|
+
if (!cfg.causality)
|
|
844
|
+
return;
|
|
845
|
+
const vis = bodies.filter((b) => b.vis && b.tokens.length > 0);
|
|
846
|
+
if (vis.length === 0)
|
|
847
|
+
return;
|
|
848
|
+
if (vis.length === 1) {
|
|
849
|
+
writeLit(vis[0], vis[0].d);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const delta = spillover(vis.map((b) => ({ d: b.d, cx: b.cx, cy: b.cy })));
|
|
853
|
+
for (let i = 0; i < vis.length; i++) {
|
|
854
|
+
writeLit(vis[i], clamp(vis[i].d + delta[i], 0, 1));
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function writeLit(b, lit) {
|
|
858
|
+
// the lit channel goes through the sink (#228): the platform's writes --lit + fires
|
|
859
|
+
// field:lit/dim with hysteresis via FeedbackRegistry (D3); the internal default sink writes
|
|
860
|
+
// --lit and fires the same hysteretic events directly (byte-identical to the legacy path).
|
|
861
|
+
cfg.feedbackSink(b.el, { lit });
|
|
862
|
+
}
|
|
863
|
+
function writeFeedback() {
|
|
864
|
+
for (const b of bodies) {
|
|
865
|
+
if (!b.feedback)
|
|
866
|
+
continue;
|
|
867
|
+
const target = feedbackTarget(b.count, b.on);
|
|
868
|
+
b.d += (target - b.d) * 0.08;
|
|
869
|
+
// write to the host, or a separate write target for shadow-DOM bodies (§11).
|
|
870
|
+
const writeEl = b.writeTarget ?? b.el;
|
|
871
|
+
// font-variation weight is a typographic render effect (not a CSS custom property), so it is
|
|
872
|
+
// applied in-engine on both paths, idempotently via lastWeight.
|
|
873
|
+
if (b.fmax) {
|
|
874
|
+
const w = feedbackWeight(b.fmin, b.fmax, b.d);
|
|
875
|
+
if (lastWeight.get(writeEl) !== w) {
|
|
876
|
+
lastWeight.set(writeEl, w);
|
|
877
|
+
writeEl.style.fontVariationSettings = `"wght" ${w}` + (b.opsz ? `, "opsz" ${b.opsz}` : '');
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
// the heatmap's local density at the body (distinct from `--d`, the body's own gathered
|
|
881
|
+
// density); the accretion load is the sink fill fraction ∈ [0,1].
|
|
882
|
+
const heatmapDensity = heatmap ? heatmap.norm(b.cx, b.cy) : undefined;
|
|
883
|
+
const load = b.tokens.indexOf('sink') >= 0 && b.capacity > 0 ? sinkLoad(b) : undefined;
|
|
884
|
+
// measured thermodynamics (workover v0.3 §"Metrics"): entropy / coherence / temperature
|
|
885
|
+
// from the local sample the integrator accumulated this frame (b.thermo — the same
|
|
886
|
+
// range/2 window as the density count), eased like `d` so the signals stay calm.
|
|
887
|
+
const t = thermoMetrics(b.thermo);
|
|
888
|
+
const m = (b.metrics ??= { entropy: 0, coherence: 1, temperature: 0 });
|
|
889
|
+
m.entropy += (t.entropy - m.entropy) * 0.08;
|
|
890
|
+
m.coherence += (t.coherence - m.coherence) * 0.08;
|
|
891
|
+
m.temperature += (t.temperature - m.temperature) * 0.08;
|
|
892
|
+
// ONE write path (#228): the CSS-var channels always go to the sink — the platform's
|
|
893
|
+
// FeedbackRegistry route (D3) when configured, otherwise the internal default sink
|
|
894
|
+
// (feedback-sink.ts), which performs the same direct writes the engine always made:
|
|
895
|
+
// `--d`/`--field-density`, `--field-heatmap-density`, `--load`/`--mass`,
|
|
896
|
+
// plus the measured `--entropy`/`--coherence`/`--temperature`.
|
|
897
|
+
cfg.feedbackSink(writeEl, {
|
|
898
|
+
density: b.d,
|
|
899
|
+
heatmapDensity,
|
|
900
|
+
load,
|
|
901
|
+
entropy: m.entropy,
|
|
902
|
+
coherence: m.coherence,
|
|
903
|
+
temperature: m.temperature,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function drawSparks() {
|
|
908
|
+
if (sparks.length === 0)
|
|
909
|
+
return;
|
|
910
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
911
|
+
for (let i = sparks.length - 1; i >= 0; i--) {
|
|
912
|
+
const s = sparks[i];
|
|
913
|
+
if (!s)
|
|
914
|
+
continue;
|
|
915
|
+
s.x += s.vx;
|
|
916
|
+
s.y += s.vy;
|
|
917
|
+
s.vx *= 0.9;
|
|
918
|
+
s.vy *= 0.9;
|
|
919
|
+
s.life *= 0.85;
|
|
920
|
+
if (s.life < 0.05) {
|
|
921
|
+
sparks.splice(i, 1);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
const [r, g, b] = s.c;
|
|
925
|
+
// additive halo + core (no shadowBlur) — sparks can burst in bulk on impact.
|
|
926
|
+
ctx.fillStyle = `rgba(${r},${g},${b},${0.18 * s.life})`;
|
|
927
|
+
ctx.beginPath();
|
|
928
|
+
ctx.arc(s.x, s.y, 2 + s.life * 4, 0, 6.28318);
|
|
929
|
+
ctx.fill();
|
|
930
|
+
ctx.fillStyle = `rgba(${r},${g},${b},${s.life})`;
|
|
931
|
+
ctx.beginPath();
|
|
932
|
+
ctx.arc(s.x, s.y, 0.6 + s.life * 1.5, 0, 6.28318);
|
|
933
|
+
ctx.fill();
|
|
934
|
+
}
|
|
935
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
936
|
+
}
|
|
937
|
+
// density heatmap (H1) — a 'glow' underlay. Rasterize the normalized field to a tiny
|
|
938
|
+
// offscreen buffer (one texel per grid cell), then upscale it over the canvas with bilinear
|
|
939
|
+
// smoothing: a smooth glow for the cost of a small image, no blocky per-cell blobs.
|
|
940
|
+
let hmCanvas = null;
|
|
941
|
+
let hmCtx = null;
|
|
942
|
+
let hmImg = null; // reused buffer — no per-frame allocation
|
|
943
|
+
function drawHeatmap() {
|
|
944
|
+
if (!heatmap)
|
|
945
|
+
return;
|
|
946
|
+
const cell = heatmap.cell;
|
|
947
|
+
const cols = Math.max(1, Math.ceil(W / cell));
|
|
948
|
+
const rows = Math.max(1, Math.ceil(H / cell));
|
|
949
|
+
if (!hmCanvas) {
|
|
950
|
+
hmCanvas = host.createCanvas();
|
|
951
|
+
hmCtx = hmCanvas.getContext('2d');
|
|
952
|
+
}
|
|
953
|
+
if (!hmCtx)
|
|
954
|
+
return;
|
|
955
|
+
if (hmCanvas.width !== cols || hmCanvas.height !== rows) {
|
|
956
|
+
hmCanvas.width = cols;
|
|
957
|
+
hmCanvas.height = rows;
|
|
958
|
+
hmImg = null; // dims changed → rebuild the buffer
|
|
959
|
+
}
|
|
960
|
+
// RECOMPUTE the density texel grid on a cadence, not every frame: the heatmap is a smooth,
|
|
961
|
+
// slow-moving glow (and the field it samples only shifts on the measureBodies cadence), so a
|
|
962
|
+
// ~20 Hz refresh is imperceptible — and it keeps the per-frame heatmap cost to just the upscale.
|
|
963
|
+
if (hmImg === null || frameN % 3 === 0) {
|
|
964
|
+
if (hmImg === null)
|
|
965
|
+
hmImg = hmCtx.createImageData(cols, rows);
|
|
966
|
+
const acc = hexToRgb(cfg.accent);
|
|
967
|
+
const data = hmImg.data;
|
|
968
|
+
for (let r = 0; r < rows; r++) {
|
|
969
|
+
for (let c = 0; c < cols; c++) {
|
|
970
|
+
const v = heatmap.norm(c * cell + cell / 2, r * cell + cell / 2);
|
|
971
|
+
const i = (r * cols + c) * 4;
|
|
972
|
+
data[i] = acc[0];
|
|
973
|
+
data[i + 1] = acc[1];
|
|
974
|
+
data[i + 2] = acc[2];
|
|
975
|
+
data[i + 3] = Math.round(clamp(v * 0.5 * boot, 0, 1) * 255);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
hmCtx.putImageData(hmImg, 0, 0);
|
|
979
|
+
}
|
|
980
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
981
|
+
ctx.imageSmoothingEnabled = true;
|
|
982
|
+
ctx.drawImage(hmCanvas, 0, 0, W, H); // bilinear upscale → smooth glow
|
|
983
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
984
|
+
}
|
|
985
|
+
function render() {
|
|
986
|
+
// substrate clear — 'trails' uses a faded clear so motion light-paints (§20.6).
|
|
987
|
+
if (cfg.background === 'transparent') {
|
|
988
|
+
// clear to TRANSPARENT so the underlay composites over light content without blanking it.
|
|
989
|
+
if (cfg.render === 'trails') {
|
|
990
|
+
// light-paint that fades to transparent (not to black): remove ~22% of existing alpha
|
|
991
|
+
// each frame via destination-out, instead of laying an opaque near-black veil over it.
|
|
992
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
993
|
+
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
|
994
|
+
ctx.fillRect(0, 0, W, H);
|
|
995
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
996
|
+
}
|
|
997
|
+
else {
|
|
998
|
+
ctx.clearRect(0, 0, W, H);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
if (cfg.render === 'trails') {
|
|
1003
|
+
ctx.fillStyle = 'rgba(5,6,11,0.22)';
|
|
1004
|
+
}
|
|
1005
|
+
else {
|
|
1006
|
+
ctx.fillStyle = 'rgb(5,6,11)';
|
|
1007
|
+
}
|
|
1008
|
+
ctx.fillRect(0, 0, W, H);
|
|
1009
|
+
}
|
|
1010
|
+
drawWaves();
|
|
1011
|
+
// The heatmap is a full-viewport bilinear-upscale glow — the heaviest per-frame layer. It's
|
|
1012
|
+
// ambient density you read at rest, not detail you track mid-scroll, so suppress it while the
|
|
1013
|
+
// page is scrolling fast (eased env.scrollV). Scrolling never pays the heatmap's fill cost; the
|
|
1014
|
+
// glow returns the moment the page settles. (Body charge/glow is CSS --load, unaffected.)
|
|
1015
|
+
if (heatmap && (env.scrollV ?? 0) < 6)
|
|
1016
|
+
drawHeatmap();
|
|
1017
|
+
drawBound();
|
|
1018
|
+
// free particles — cool centre → warm edge, blended toward accent (§20.8).
|
|
1019
|
+
// metaballs (a molten iso-surface skin) and streamlines (the bare force field) REPLACE
|
|
1020
|
+
// the matter per §20.6, so suppress the dot swarm for those two; dots/trails/links/voronoi
|
|
1021
|
+
// keep it (their overlays read against the particles).
|
|
1022
|
+
const showMatter = cfg.render !== 'metaballs' && cfg.render !== 'streamlines';
|
|
1023
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
1024
|
+
const acc = hexToRgb(cfg.accent);
|
|
1025
|
+
const cx = W / 2;
|
|
1026
|
+
const cy = H * 0.4;
|
|
1027
|
+
const maxD = Math.hypot(Math.max(cx, W - cx), Math.max(cy, H - cy)) || 1;
|
|
1028
|
+
if (showMatter)
|
|
1029
|
+
for (const p of store.particles) {
|
|
1030
|
+
// captured matter is held in orbit by the sink — drawn dim and small (the accretion's orbital
|
|
1031
|
+
// work), distinct from the free swarm. It stays visible: the body gathers and holds a real
|
|
1032
|
+
// cloud, then the supernova flings it back out. (Conserved either way — see accretion.ts.)
|
|
1033
|
+
if (p.cap) {
|
|
1034
|
+
ctx.fillStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.55 * boot})`;
|
|
1035
|
+
ctx.beginPath();
|
|
1036
|
+
ctx.arc(p.x, p.y, 1.3, 0, 6.28318);
|
|
1037
|
+
ctx.fill();
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
const d = Math.min(1, Math.hypot(p.x - cx, p.y - cy) / maxD);
|
|
1041
|
+
const rs = d * d;
|
|
1042
|
+
const h = p.heat;
|
|
1043
|
+
let [r, g, b] = particleRGB(rs, h, acc);
|
|
1044
|
+
if (p.color) {
|
|
1045
|
+
// carried pigment (§20.8): a stained particle reads mostly as its own tint.
|
|
1046
|
+
const [pr, pg, pb] = hexToRgb(p.color);
|
|
1047
|
+
r += (pr - r) * 0.75;
|
|
1048
|
+
g += (pg - g) * 0.75;
|
|
1049
|
+
b += (pb - b) * 0.75;
|
|
1050
|
+
}
|
|
1051
|
+
// depth recession (z-axis.md): in a depth > 0 field, matter deeper in the volume
|
|
1052
|
+
// draws smaller and fainter — the flat field's factor is exactly 1.
|
|
1053
|
+
const zk = cfg.depth > 0 ? 1 - Math.min(Math.abs(p.z ?? 0) / cfg.depth, 1) * 0.55 : 1;
|
|
1054
|
+
const size = (p.size * (1 - 0.4 * rs) + h * 2) * zk;
|
|
1055
|
+
const alpha = clamp((0.5 - 0.3 * rs + h * 0.5) * boot * zk, 0, 1);
|
|
1056
|
+
const cr = r | 0;
|
|
1057
|
+
const cg = g | 0;
|
|
1058
|
+
const cb = b | 0;
|
|
1059
|
+
// Soft additive glow, not a solid disc. Three concentric discs — a wide faint aura, a mid
|
|
1060
|
+
// body, a small bright core — sum under the 'lighter' composite into a smooth radial falloff,
|
|
1061
|
+
// so the particle reads as LIGHT rather than a hard filled circle. Cheap (a few small arcs,
|
|
1062
|
+
// no per-particle gradient or shadowBlur — the costs §20.8 deliberately avoids), and it keeps
|
|
1063
|
+
// the per-particle tint. Hotter matter glows wider.
|
|
1064
|
+
const col = `${cr},${cg},${cb}`;
|
|
1065
|
+
ctx.fillStyle = `rgba(${col},${(0.05 + 0.08 * h) * boot * zk})`;
|
|
1066
|
+
ctx.beginPath();
|
|
1067
|
+
ctx.arc(p.x, p.y, (size + 1) * (2 + h * 1.6), 0, 6.28318);
|
|
1068
|
+
ctx.fill();
|
|
1069
|
+
ctx.fillStyle = `rgba(${col},${0.16 * alpha})`;
|
|
1070
|
+
ctx.beginPath();
|
|
1071
|
+
ctx.arc(p.x, p.y, size * 1.25 + 0.6, 0, 6.28318);
|
|
1072
|
+
ctx.fill();
|
|
1073
|
+
ctx.fillStyle = `rgba(${col},${0.55 * alpha})`;
|
|
1074
|
+
ctx.beginPath();
|
|
1075
|
+
ctx.arc(p.x, p.y, Math.max(0.4, size * 0.55), 0, 6.28318);
|
|
1076
|
+
ctx.fill();
|
|
1077
|
+
}
|
|
1078
|
+
drawSparks();
|
|
1079
|
+
drawThreads();
|
|
1080
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
1081
|
+
if (cfg.render === 'links') {
|
|
1082
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
1083
|
+
const acc = hexToRgb(cfg.accent);
|
|
1084
|
+
const R = 90;
|
|
1085
|
+
ctx.lineWidth = 0.6;
|
|
1086
|
+
for (const p of store.particles) {
|
|
1087
|
+
if (p.cap)
|
|
1088
|
+
continue;
|
|
1089
|
+
for (const q of store.neighbors(p, R)) {
|
|
1090
|
+
// draw each undirected pair once
|
|
1091
|
+
if (q.x < p.x || (q.x === p.x && q.y < p.y))
|
|
1092
|
+
continue;
|
|
1093
|
+
const a = linkAlpha(Math.hypot(q.x - p.x, q.y - p.y), R);
|
|
1094
|
+
if (a <= 0)
|
|
1095
|
+
continue;
|
|
1096
|
+
ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${a})`;
|
|
1097
|
+
ctx.beginPath();
|
|
1098
|
+
ctx.moveTo(p.x, p.y);
|
|
1099
|
+
ctx.lineTo(q.x, q.y);
|
|
1100
|
+
ctx.stroke();
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
1104
|
+
}
|
|
1105
|
+
// metaballs: trace a single iso-contour of the particle density field, so the swarm
|
|
1106
|
+
// reads as one liquid skin rather than discrete dots (§20.6). Particles splat a smooth
|
|
1107
|
+
// kernel onto a coarse grid; marching squares walks the threshold cell by cell.
|
|
1108
|
+
if (cfg.render === 'metaballs') {
|
|
1109
|
+
const STEP = 16; // grid resolution (px)
|
|
1110
|
+
const RAD = 34; // kernel radius (px)
|
|
1111
|
+
const LEVEL = 0.9; // iso threshold → the blob skin
|
|
1112
|
+
const cols = Math.ceil(W / STEP) + 1;
|
|
1113
|
+
const rows = Math.ceil(H / STEP) + 1;
|
|
1114
|
+
if (!mball || mball.length !== cols * rows)
|
|
1115
|
+
mball = new Float32Array(cols * rows);
|
|
1116
|
+
else
|
|
1117
|
+
mball.fill(0);
|
|
1118
|
+
for (const p of store.particles) {
|
|
1119
|
+
if (p.cap)
|
|
1120
|
+
continue;
|
|
1121
|
+
splatDensity(mball, cols, rows, STEP, p.x, p.y, RAD, 1);
|
|
1122
|
+
}
|
|
1123
|
+
const acc = hexToRgb(cfg.accent);
|
|
1124
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
1125
|
+
ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.5 * boot})`;
|
|
1126
|
+
ctx.lineWidth = 1.4;
|
|
1127
|
+
ctx.lineCap = 'round';
|
|
1128
|
+
ctx.beginPath();
|
|
1129
|
+
for (let gy = 0; gy < rows - 1; gy++) {
|
|
1130
|
+
for (let gx = 0; gx < cols - 1; gx++) {
|
|
1131
|
+
const tl = mball[gy * cols + gx];
|
|
1132
|
+
const tr = mball[gy * cols + gx + 1];
|
|
1133
|
+
const br = mball[(gy + 1) * cols + gx + 1];
|
|
1134
|
+
const bl = mball[(gy + 1) * cols + gx];
|
|
1135
|
+
const segs = marchingCell(tl, tr, br, bl, LEVEL);
|
|
1136
|
+
if (!segs.length)
|
|
1137
|
+
continue;
|
|
1138
|
+
const ox = gx * STEP;
|
|
1139
|
+
const oy = gy * STEP;
|
|
1140
|
+
for (const s of segs) {
|
|
1141
|
+
ctx.moveTo(ox + s.x1 * STEP, oy + s.y1 * STEP);
|
|
1142
|
+
ctx.lineTo(ox + s.x2 * STEP, oy + s.y2 * STEP);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
ctx.stroke();
|
|
1147
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
1148
|
+
}
|
|
1149
|
+
// voronoi: assign each grid node its nearest particle, then stroke the walls where
|
|
1150
|
+
// adjacent nodes belong to different cells — the shattered-glass look (§20.6).
|
|
1151
|
+
if (cfg.render === 'voronoi') {
|
|
1152
|
+
const STEP = 18; // grid resolution (px)
|
|
1153
|
+
const SEARCH = STEP * 3; // candidate radius for the nearest-site query
|
|
1154
|
+
const cols = Math.ceil(W / STEP) + 1;
|
|
1155
|
+
const rows = Math.ceil(H / STEP) + 1;
|
|
1156
|
+
if (!vor || vor.length !== cols * rows)
|
|
1157
|
+
vor = new Int32Array(cols * rows);
|
|
1158
|
+
// a stable owner id per particle (its index in the pool) so adjacent nodes compare.
|
|
1159
|
+
const parts = store.particles;
|
|
1160
|
+
const idOf = new Map();
|
|
1161
|
+
for (let i = 0; i < parts.length; i++)
|
|
1162
|
+
idOf.set(parts[i], i);
|
|
1163
|
+
for (let gy = 0; gy < rows; gy++) {
|
|
1164
|
+
for (let gx = 0; gx < cols; gx++) {
|
|
1165
|
+
const nx = gx * STEP;
|
|
1166
|
+
const ny = gy * STEP;
|
|
1167
|
+
const cands = store.near(nx, ny, SEARCH);
|
|
1168
|
+
let owner = -1;
|
|
1169
|
+
if (cands.length) {
|
|
1170
|
+
const k = nearestSite(nx, ny, cands);
|
|
1171
|
+
if (k >= 0)
|
|
1172
|
+
owner = idOf.get(cands[k]) ?? -1;
|
|
1173
|
+
}
|
|
1174
|
+
vor[gy * cols + gx] = owner;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
const acc = hexToRgb(cfg.accent);
|
|
1178
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
1179
|
+
ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.32 * boot})`;
|
|
1180
|
+
ctx.lineWidth = 1;
|
|
1181
|
+
ctx.beginPath();
|
|
1182
|
+
for (const s of voronoiWalls(vor, cols, rows)) {
|
|
1183
|
+
ctx.moveTo(s.x1 * STEP, s.y1 * STEP);
|
|
1184
|
+
ctx.lineTo(s.x2 * STEP, s.y2 * STEP);
|
|
1185
|
+
}
|
|
1186
|
+
ctx.stroke();
|
|
1187
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
1188
|
+
}
|
|
1189
|
+
// streamlines: draw the force field itself — a grid of arrows along the net push a still test
|
|
1190
|
+
// particle would feel (§20.6 diagnostic). 'streamlines' draws them ALONE (showMatter suppressed
|
|
1191
|
+
// the dots above); 'flow' draws the SAME arrows additively over the dots already painted — the
|
|
1192
|
+
// particles plus the flow they ride, in this one underlay canvas (no second surface, no blend).
|
|
1193
|
+
if (cfg.render === 'streamlines' || cfg.render === 'flow') {
|
|
1194
|
+
const GRID = 46;
|
|
1195
|
+
const acc = hexToRgb(cfg.accent);
|
|
1196
|
+
ctx.lineWidth = 1;
|
|
1197
|
+
ctx.lineCap = 'round';
|
|
1198
|
+
// RESAMPLE the field on a cadence, not every frame. The arrows trace the body-induced force
|
|
1199
|
+
// field, which only changes when bodies are re-measured (every 6th frame) or a flow focus is
|
|
1200
|
+
// live — so a per-frame regrid (≈grid×bodies force evals) was wasted work that surfaced as
|
|
1201
|
+
// scroll choppiness. Resample every 3rd frame (or when the cache is empty, or while a flow
|
|
1202
|
+
// focus is animating); DRAW from the cache every frame so the arrows never flicker or step.
|
|
1203
|
+
if (slSamples === null || flow || frameN % 3 === 0) {
|
|
1204
|
+
// Sample the field on the grid, then scale arrows RELATIVE to the strongest sample, so a
|
|
1205
|
+
// weak field (a magnetic/electric dipole, magnitudes ~1e-5) reads as clearly as a strong
|
|
1206
|
+
// one (an attractor). Absolute scaling drowned the dipole below the visibility cutoff.
|
|
1207
|
+
const samples = [];
|
|
1208
|
+
const quiescent = [];
|
|
1209
|
+
let frameMax = 0;
|
|
1210
|
+
for (let gx = GRID / 2; gx < W; gx += GRID) {
|
|
1211
|
+
for (let gy = GRID / 2; gy < H; gy += GRID) {
|
|
1212
|
+
let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
|
|
1213
|
+
// a live flow focus bends the rendered field lines toward the target (field.flowTo).
|
|
1214
|
+
if (flow) {
|
|
1215
|
+
const b = flowBias(gx, gy, flow, 0.04);
|
|
1216
|
+
fx += b.x;
|
|
1217
|
+
fy += b.y;
|
|
1218
|
+
}
|
|
1219
|
+
const mag = Math.hypot(fx, fy);
|
|
1220
|
+
// Skip only true dead zones / NaN (a tiny epsilon, not an absolute magnitude floor) —
|
|
1221
|
+
// a weak dipole's outer field is still a real pattern and must survive to be scaled.
|
|
1222
|
+
if (!(mag > 1e-9)) {
|
|
1223
|
+
quiescent.push({ gx, gy }); // quiescent → a faint dot, drawn from cache below
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
|
|
1227
|
+
if (mag > frameMax)
|
|
1228
|
+
frameMax = mag;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// Ease the normalization reference: rise quickly when the field strengthens, decay slowly
|
|
1232
|
+
// when it weakens — prevents a single strong frame from spiking the scale, and a single
|
|
1233
|
+
// quiet frame from collapsing it. Seed on first frame (slMaxSmoothed === 0).
|
|
1234
|
+
if (slMaxSmoothed === 0)
|
|
1235
|
+
slMaxSmoothed = frameMax;
|
|
1236
|
+
else
|
|
1237
|
+
slMaxSmoothed = frameMax > slMaxSmoothed
|
|
1238
|
+
? slMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
|
|
1239
|
+
: slMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
|
|
1240
|
+
slSamples = samples;
|
|
1241
|
+
slQuiescent = quiescent;
|
|
1242
|
+
}
|
|
1243
|
+
// DRAW from the cache every frame (the canvas is cleared each frame, so quiescent dots and
|
|
1244
|
+
// arrows must both be re-laid even on the frames we don't resample).
|
|
1245
|
+
if (slQuiescent.length) {
|
|
1246
|
+
ctx.fillStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},0.05)`;
|
|
1247
|
+
for (const q of slQuiescent)
|
|
1248
|
+
ctx.fillRect(q.gx - 0.5, q.gy - 0.5, 1, 1);
|
|
1249
|
+
}
|
|
1250
|
+
if (slMaxSmoothed > 0 && slSamples) {
|
|
1251
|
+
for (const s of slSamples) {
|
|
1252
|
+
const rel = Math.sqrt(s.mag / slMaxSmoothed); // sqrt compresses the range so weak vectors still read
|
|
1253
|
+
const len = GRID * 0.46 * (0.28 + 0.72 * rel);
|
|
1254
|
+
const ex = s.gx + s.ux * len;
|
|
1255
|
+
const ey = s.gy + s.uy * len;
|
|
1256
|
+
ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${clamp(0.1 + rel * 0.5, 0, 0.72)})`;
|
|
1257
|
+
ctx.beginPath();
|
|
1258
|
+
ctx.moveTo(s.gx, s.gy);
|
|
1259
|
+
ctx.lineTo(ex, ey);
|
|
1260
|
+
const ah = 3.4;
|
|
1261
|
+
ctx.moveTo(ex, ey);
|
|
1262
|
+
ctx.lineTo(ex - s.ux * ah - s.uy * ah * 0.6, ey - s.uy * ah + s.ux * ah * 0.6);
|
|
1263
|
+
ctx.moveTo(ex, ey);
|
|
1264
|
+
ctx.lineTo(ex - s.ux * ah + s.uy * ah * 0.6, ey - s.uy * ah - s.ux * ah * 0.6);
|
|
1265
|
+
ctx.stroke();
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
// Field Surfaces — the overlay readings. `setOverlay` accepts one reading or an additive stack;
|
|
1271
|
+
// the stack is drawn in order onto the one front surface (cleared once per frame), so several
|
|
1272
|
+
// quantities — flow, deformation, heat, energy, traced paths, per-body measurements — compose over
|
|
1273
|
+
// any underlay matter mode. Every reading is a line/text diagnostic by design: the overlay sits in
|
|
1274
|
+
// front of page content and must reveal, never occlude (visualization-methods taxonomy, Surfaces &
|
|
1275
|
+
// Placement). All are tinted with the travelling accent, so setAccent recolors the overlay too.
|
|
1276
|
+
/** Normalize an OverlayInput to the drawable stack — `'off'` anywhere or an empty list = nothing. */
|
|
1277
|
+
function overlayStack(input) {
|
|
1278
|
+
const list = input === undefined ? [] : Array.isArray(input) ? input : [input];
|
|
1279
|
+
return list.filter((m) => m !== 'off');
|
|
1280
|
+
}
|
|
1281
|
+
// arrows along the sampled felt force field — `streamlines` (sqrt-compressed) and `force-vectors`
|
|
1282
|
+
// (absolute magnitude). `field-lines` no longer routes here: it traces the real field-structure
|
|
1283
|
+
// curves (drawOverlayFieldLines). The `structure` arm is retained for the backend contract but is
|
|
1284
|
+
// unused by the current dispatch (both arrow modes read the felt field).
|
|
1285
|
+
function drawOverlayArrows(out, structure, absolute) {
|
|
1286
|
+
const GRID = 44;
|
|
1287
|
+
const acc = hexToRgb(cfg.accent);
|
|
1288
|
+
const samples = [];
|
|
1289
|
+
let frameMax = 0;
|
|
1290
|
+
for (let gx = GRID / 2; gx < W; gx += GRID) {
|
|
1291
|
+
for (let gy = GRID / 2; gy < H; gy += GRID) {
|
|
1292
|
+
let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
|
|
1293
|
+
if (flow) {
|
|
1294
|
+
const b = flowBias(gx, gy, flow, 0.04);
|
|
1295
|
+
fx += b.x;
|
|
1296
|
+
fy += b.y;
|
|
1297
|
+
}
|
|
1298
|
+
const mag = Math.hypot(fx, fy);
|
|
1299
|
+
if (!(mag > 1e-9))
|
|
1300
|
+
continue; // skip dead zones / NaN
|
|
1301
|
+
samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
|
|
1302
|
+
if (mag > frameMax)
|
|
1303
|
+
frameMax = mag;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
// Same EMA approach as the underlay streamlines (see slMaxSmoothed) — independent state so
|
|
1307
|
+
// the overlay scale never couples to the underlay's field strength.
|
|
1308
|
+
if (olMaxSmoothed === 0)
|
|
1309
|
+
olMaxSmoothed = frameMax;
|
|
1310
|
+
else
|
|
1311
|
+
olMaxSmoothed = frameMax > olMaxSmoothed
|
|
1312
|
+
? olMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
|
|
1313
|
+
: olMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
|
|
1314
|
+
if (olMaxSmoothed <= 0)
|
|
1315
|
+
return;
|
|
1316
|
+
// one backend call per arrow: shaft + two head strokes packed as three segments. Alpha varies
|
|
1317
|
+
// per arrow (it encodes magnitude), so arrows can't share one batch without quantizing — the
|
|
1318
|
+
// call count matches the previous per-arrow beginPath/stroke exactly.
|
|
1319
|
+
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.2 };
|
|
1320
|
+
const seg = new Float64Array(12);
|
|
1321
|
+
for (const s of samples) {
|
|
1322
|
+
const rel = absolute ? clamp(s.mag / olMaxSmoothed, 0, 1) : Math.sqrt(s.mag / olMaxSmoothed);
|
|
1323
|
+
const len = GRID * 0.5 * (0.25 + 0.75 * rel);
|
|
1324
|
+
const ex = s.gx + s.ux * len;
|
|
1325
|
+
const ey = s.gy + s.uy * len;
|
|
1326
|
+
const ah = 3.6;
|
|
1327
|
+
seg[0] = s.gx;
|
|
1328
|
+
seg[1] = s.gy;
|
|
1329
|
+
seg[2] = ex;
|
|
1330
|
+
seg[3] = ey;
|
|
1331
|
+
seg[4] = ex;
|
|
1332
|
+
seg[5] = ey;
|
|
1333
|
+
seg[6] = ex - s.ux * ah - s.uy * ah * 0.6;
|
|
1334
|
+
seg[7] = ey - s.uy * ah + s.ux * ah * 0.6;
|
|
1335
|
+
seg[8] = ex;
|
|
1336
|
+
seg[9] = ey;
|
|
1337
|
+
seg[10] = ex - s.ux * ah + s.uy * ah * 0.6;
|
|
1338
|
+
seg[11] = ey - s.uy * ah - s.ux * ah * 0.6;
|
|
1339
|
+
stroke.alpha = clamp(0.12 + rel * 0.55, 0, 0.8);
|
|
1340
|
+
out.segments(seg, stroke);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
// `field-lines` — the field STRUCTURE traced as real curves. Each field-bearing body is seeded
|
|
1344
|
+
// by its own geometry (a dipole's perpendicular bisector for a magnet, a core ring for a
|
|
1345
|
+
// monopole charge/gravity well; fieldline-seeds.ts), then `traceFieldLines` follows the NET
|
|
1346
|
+
// field through every seed — so the bar-magnet loops, the radial spokes, and the linkage
|
|
1347
|
+
// between two bodies all emerge from the math, never drawn by hand. Bodies that radiate nothing
|
|
1348
|
+
// (attract/sink/…) get no seeds, so the diagram stays the real structure, not a starburst.
|
|
1349
|
+
function drawOverlayFieldLines(out) {
|
|
1350
|
+
const seeds = fieldLineSeeds(bodies);
|
|
1351
|
+
if (!seeds.length)
|
|
1352
|
+
return;
|
|
1353
|
+
const lines = traceFieldLines((x, y) => netField(bodies, reg.forces, x, y), seeds, {
|
|
1354
|
+
step: 6,
|
|
1355
|
+
maxSteps: 200,
|
|
1356
|
+
bounds: { w: W, h: H },
|
|
1357
|
+
loopDist: 8,
|
|
1358
|
+
});
|
|
1359
|
+
const acc = hexToRgb(cfg.accent);
|
|
1360
|
+
// one polyline per traced curve through the backend seam (#373) — same shared stroke style.
|
|
1361
|
+
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0.42, width: 1.1 };
|
|
1362
|
+
for (const line of lines) {
|
|
1363
|
+
if (line.length < 2)
|
|
1364
|
+
continue;
|
|
1365
|
+
const pts = new Float32Array(line.length * 2);
|
|
1366
|
+
for (let i = 0; i < line.length; i++) {
|
|
1367
|
+
pts[i * 2] = line[i].x;
|
|
1368
|
+
pts[i * 2 + 1] = line[i].y;
|
|
1369
|
+
}
|
|
1370
|
+
out.polyline(pts, stroke);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
// `grid` — a reference lattice whose vertices are displaced along the felt field; the page's
|
|
1374
|
+
// space itself made visible, bending where the field is strong. Reads deformation.
|
|
1375
|
+
function drawOverlayGrid(out) {
|
|
1376
|
+
const STEP = 56;
|
|
1377
|
+
const MAXD = 11; // px displacement at the strongest sample — legible, never chaotic
|
|
1378
|
+
const cols = Math.floor(W / STEP) + 2;
|
|
1379
|
+
const rows = Math.floor(H / STEP) + 2;
|
|
1380
|
+
const dx = new Float32Array(cols * rows);
|
|
1381
|
+
const dy = new Float32Array(cols * rows);
|
|
1382
|
+
let maxMag = 0;
|
|
1383
|
+
const mags = new Float32Array(cols * rows);
|
|
1384
|
+
for (let gy = 0; gy < rows; gy++) {
|
|
1385
|
+
for (let gx = 0; gx < cols; gx++) {
|
|
1386
|
+
const { fx, fy } = forceAt(bodies, reg.forces, env, gx * STEP, gy * STEP);
|
|
1387
|
+
const mag = Math.hypot(fx, fy);
|
|
1388
|
+
const i = gy * cols + gx;
|
|
1389
|
+
if (mag > 1e-9) {
|
|
1390
|
+
dx[i] = fx / mag;
|
|
1391
|
+
dy[i] = fy / mag;
|
|
1392
|
+
mags[i] = mag;
|
|
1393
|
+
if (mag > maxMag)
|
|
1394
|
+
maxMag = mag;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
const acc = hexToRgb(cfg.accent);
|
|
1399
|
+
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0.16, width: 1 };
|
|
1400
|
+
const px = (gx, gy) => {
|
|
1401
|
+
const i = gy * cols + gx;
|
|
1402
|
+
const rel = maxMag > 0 ? Math.sqrt(mags[i] / maxMag) : 0;
|
|
1403
|
+
return [gx * STEP + dx[i] * rel * MAXD, gy * STEP + dy[i] * rel * MAXD];
|
|
1404
|
+
};
|
|
1405
|
+
const row = [];
|
|
1406
|
+
for (let gy = 0; gy < rows; gy++) {
|
|
1407
|
+
row.length = 0;
|
|
1408
|
+
for (let gx = 0; gx < cols; gx++)
|
|
1409
|
+
row.push(...px(gx, gy));
|
|
1410
|
+
out.polyline(row, stroke);
|
|
1411
|
+
}
|
|
1412
|
+
for (let gx = 0; gx < cols; gx++) {
|
|
1413
|
+
row.length = 0;
|
|
1414
|
+
for (let gy = 0; gy < rows; gy++)
|
|
1415
|
+
row.push(...px(gx, gy));
|
|
1416
|
+
out.polyline(row, stroke);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
// shared scalar-contour pass for `temperature` and `energy` — splat a per-particle scalar onto a
|
|
1420
|
+
// coarse grid, then trace marching-squares iso-lines at fractions of the frame's max. Contours,
|
|
1421
|
+
// not washes: the overlay must never paint area over content.
|
|
1422
|
+
let oscalar = null;
|
|
1423
|
+
function drawOverlayContours(out, weigh, alphaBase) {
|
|
1424
|
+
const STEP = 24;
|
|
1425
|
+
const RAD = 42;
|
|
1426
|
+
const cols = Math.ceil(W / STEP) + 1;
|
|
1427
|
+
const rows = Math.ceil(H / STEP) + 1;
|
|
1428
|
+
if (!oscalar || oscalar.length !== cols * rows)
|
|
1429
|
+
oscalar = new Float32Array(cols * rows);
|
|
1430
|
+
else
|
|
1431
|
+
oscalar.fill(0);
|
|
1432
|
+
let any = false;
|
|
1433
|
+
for (const p of store.particles) {
|
|
1434
|
+
if (p.cap)
|
|
1435
|
+
continue;
|
|
1436
|
+
const w = weigh(p);
|
|
1437
|
+
if (w <= 0)
|
|
1438
|
+
continue;
|
|
1439
|
+
any = true;
|
|
1440
|
+
splatDensity(oscalar, cols, rows, STEP, p.x, p.y, RAD, w);
|
|
1441
|
+
}
|
|
1442
|
+
if (!any)
|
|
1443
|
+
return;
|
|
1444
|
+
let max = 0;
|
|
1445
|
+
for (let i = 0; i < oscalar.length; i++)
|
|
1446
|
+
if (oscalar[i] > max)
|
|
1447
|
+
max = oscalar[i];
|
|
1448
|
+
if (max <= 0)
|
|
1449
|
+
return;
|
|
1450
|
+
const acc = hexToRgb(cfg.accent);
|
|
1451
|
+
const LEVELS = [0.25, 0.5, 0.78]; // nested iso-rings: faint outer shell → bright core
|
|
1452
|
+
const packed = [];
|
|
1453
|
+
for (let li = 0; li < LEVELS.length; li++) {
|
|
1454
|
+
const level = LEVELS[li] * max;
|
|
1455
|
+
packed.length = 0;
|
|
1456
|
+
for (let gy = 0; gy < rows - 1; gy++) {
|
|
1457
|
+
for (let gx = 0; gx < cols - 1; gx++) {
|
|
1458
|
+
const tl = oscalar[gy * cols + gx];
|
|
1459
|
+
const tr = oscalar[gy * cols + gx + 1];
|
|
1460
|
+
const br = oscalar[(gy + 1) * cols + gx + 1];
|
|
1461
|
+
const bl = oscalar[(gy + 1) * cols + gx];
|
|
1462
|
+
const segs = marchingCell(tl, tr, br, bl, level);
|
|
1463
|
+
if (!segs.length)
|
|
1464
|
+
continue;
|
|
1465
|
+
const ox = gx * STEP;
|
|
1466
|
+
const oy = gy * STEP;
|
|
1467
|
+
for (const sg of segs)
|
|
1468
|
+
packed.push(ox + sg.x1 * STEP, oy + sg.y1 * STEP, ox + sg.x2 * STEP, oy + sg.y2 * STEP);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
if (packed.length)
|
|
1472
|
+
out.segments(packed, {
|
|
1473
|
+
r: acc[0],
|
|
1474
|
+
g: acc[1],
|
|
1475
|
+
b: acc[2],
|
|
1476
|
+
alpha: alphaBase * (0.45 + 0.55 * (li / (LEVELS.length - 1))),
|
|
1477
|
+
width: 1 + li * 0.3,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
// `path` — true streamlines: from a coarse lattice of seeds, integrate the felt field direction
|
|
1482
|
+
// step by step and draw each traced curve, fading toward the tail. Where `streamlines` shows the
|
|
1483
|
+
// instantaneous push per cell, `path` shows where that push CARRIES a probe over distance.
|
|
1484
|
+
function drawOverlayPaths(out) {
|
|
1485
|
+
const SEED = 104; // seed lattice spacing (px)
|
|
1486
|
+
const STEPPX = 9; // integration step (px)
|
|
1487
|
+
const STEPS = 24; // max steps per path
|
|
1488
|
+
const acc = hexToRgb(cfg.accent);
|
|
1489
|
+
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.1 };
|
|
1490
|
+
const seg = new Float64Array(4);
|
|
1491
|
+
for (let sx = SEED / 2; sx < W; sx += SEED) {
|
|
1492
|
+
for (let sy = SEED / 2; sy < H; sy += SEED) {
|
|
1493
|
+
let x = sx;
|
|
1494
|
+
let y = sy;
|
|
1495
|
+
for (let i = 0; i < STEPS; i++) {
|
|
1496
|
+
let { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
|
|
1497
|
+
if (flow) {
|
|
1498
|
+
const b = flowBias(x, y, flow, 0.04);
|
|
1499
|
+
fx += b.x;
|
|
1500
|
+
fy += b.y;
|
|
1501
|
+
}
|
|
1502
|
+
const mag = Math.hypot(fx, fy);
|
|
1503
|
+
if (!(mag > 1e-9))
|
|
1504
|
+
break; // dead zone — the path ends
|
|
1505
|
+
const nx = x + (fx / mag) * STEPPX;
|
|
1506
|
+
const ny = y + (fy / mag) * STEPPX;
|
|
1507
|
+
if (nx < 0 || ny < 0 || nx > W || ny > H)
|
|
1508
|
+
break;
|
|
1509
|
+
// per-step segment: the alpha fades toward the tail, so each step is its own stroke
|
|
1510
|
+
seg[0] = x;
|
|
1511
|
+
seg[1] = y;
|
|
1512
|
+
seg[2] = nx;
|
|
1513
|
+
seg[3] = ny;
|
|
1514
|
+
stroke.alpha = 0.34 * (1 - i / STEPS);
|
|
1515
|
+
out.segments(seg, stroke);
|
|
1516
|
+
x = nx;
|
|
1517
|
+
y = ny;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
// `data` — the measurement made legible: each measuring body's eased local density d ∈ [0,1]
|
|
1523
|
+
// (§8, the same number the platform mirrors to `--d`) printed beside the body. Feedback bodies
|
|
1524
|
+
// lead (they asked to be measured); non-feedback bodies are skipped — no reading, no chip.
|
|
1525
|
+
function drawOverlayData(out) {
|
|
1526
|
+
const acc = hexToRgb(cfg.accent);
|
|
1527
|
+
for (const b of bodies) {
|
|
1528
|
+
if (!b.vis || !b.feedback)
|
|
1529
|
+
continue;
|
|
1530
|
+
const label = `d ${b.d.toFixed(2)}`;
|
|
1531
|
+
const tx = b.cx + b.hw + 8;
|
|
1532
|
+
const ty = b.cy;
|
|
1533
|
+
out.rect(tx - 3, ty - 7, out.measureText(label) + 6, 14, acc[0], acc[1], acc[2], clamp(0.3 + b.d * 0.55, 0, 0.85));
|
|
1534
|
+
out.text(label, tx, ty + 0.5, 5, 6, 11, 0.92);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
function renderOverlay(out, stack) {
|
|
1538
|
+
out.clear();
|
|
1539
|
+
if (!stack.length || W === 0 || H === 0)
|
|
1540
|
+
return;
|
|
1541
|
+
for (const mode of stack) {
|
|
1542
|
+
if (mode === 'streamlines')
|
|
1543
|
+
drawOverlayArrows(out, false, false);
|
|
1544
|
+
else if (mode === 'force-vectors')
|
|
1545
|
+
drawOverlayArrows(out, false, true);
|
|
1546
|
+
else if (mode === 'field-lines')
|
|
1547
|
+
drawOverlayFieldLines(out);
|
|
1548
|
+
else if (mode === 'grid')
|
|
1549
|
+
drawOverlayGrid(out);
|
|
1550
|
+
else if (mode === 'temperature')
|
|
1551
|
+
drawOverlayContours(out, (p) => p.heat, 0.5);
|
|
1552
|
+
else if (mode === 'energy')
|
|
1553
|
+
drawOverlayContours(out, (p) => 0.5 * p.m * (p.vx * p.vx + p.vy * p.vy), 0.42);
|
|
1554
|
+
else if (mode === 'path')
|
|
1555
|
+
drawOverlayPaths(out);
|
|
1556
|
+
else if (mode === 'data')
|
|
1557
|
+
drawOverlayData(out);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
function frame(now) {
|
|
1561
|
+
frameN++;
|
|
1562
|
+
env.t = (now - t0) / 1000;
|
|
1563
|
+
env.frameN = frameN;
|
|
1564
|
+
env.dt = reduceMotion ? 0 : 1;
|
|
1565
|
+
if (boot < 1)
|
|
1566
|
+
boot = Math.min(1, boot + 0.012);
|
|
1567
|
+
easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
|
|
1568
|
+
const scrollY = host.scrollY();
|
|
1569
|
+
// eased page-scroll speed for the `scrolling` data-when gate (§5).
|
|
1570
|
+
env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(scrollY - lastScrollY) * 0.3;
|
|
1571
|
+
lastScrollY = scrollY;
|
|
1572
|
+
for (const w of waves) {
|
|
1573
|
+
const target = scrollY * (0.025 + w.depth * 0.08); // wave parallax (§24)
|
|
1574
|
+
w.offsetY += (target - w.offsetY) * 0.04;
|
|
1575
|
+
}
|
|
1576
|
+
if (bodies.length && frameN % 6 === 0) {
|
|
1577
|
+
measureBodies(bodies, W, H);
|
|
1578
|
+
// attention-gated discharge (#365): an engagement-gated sink releases on the falling
|
|
1579
|
+
// edge of engagement — the same conserved supernova ritual as saturation.
|
|
1580
|
+
dischargeDisengaged(bodies, env.supernova);
|
|
1581
|
+
}
|
|
1582
|
+
// spine: ease the wave-bend toward the flow focus (if set) or the engaged element (§24). A live
|
|
1583
|
+
// flow focus (field.flowTo) takes priority, so the streamline spine curves to the moving target.
|
|
1584
|
+
let engaged = null;
|
|
1585
|
+
for (const b of bodies) {
|
|
1586
|
+
if (b.on && b.vis) {
|
|
1587
|
+
engaged = b;
|
|
1588
|
+
break;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
const spineTo = flow ?? engaged;
|
|
1592
|
+
pull.k += ((spineTo ? 1 : 0) - pull.k) * 0.07;
|
|
1593
|
+
if (spineTo) {
|
|
1594
|
+
const tx = flow ? flow.x : engaged.cx;
|
|
1595
|
+
const ty = flow ? flow.y : engaged.cy;
|
|
1596
|
+
pull.x = pull.x ? pull.x + (tx - pull.x) * 0.16 : tx;
|
|
1597
|
+
pull.y = pull.y ? pull.y + (ty - pull.y) * 0.16 : ty;
|
|
1598
|
+
}
|
|
1599
|
+
// accent journey (§9): scroll travels the palette; a hovered element overrides.
|
|
1600
|
+
// maxScroll is cached (scrollHeight forces a reflow); resample it twice a second.
|
|
1601
|
+
if (frameN % 30 === 0)
|
|
1602
|
+
maxScroll = host.scrollHeight() - H || 1;
|
|
1603
|
+
const targetAcc = hoverAccent ? hexToRgb(hoverAccent) : sampleStops(JOURNEY, scrollY / maxScroll);
|
|
1604
|
+
curAccent = [
|
|
1605
|
+
curAccent[0] + (targetAcc[0] - curAccent[0]) * 0.08,
|
|
1606
|
+
curAccent[1] + (targetAcc[1] - curAccent[1]) * 0.08,
|
|
1607
|
+
curAccent[2] + (targetAcc[2] - curAccent[2]) * 0.08,
|
|
1608
|
+
];
|
|
1609
|
+
cfg.accent = rgbToHex(curAccent);
|
|
1610
|
+
store.reindex();
|
|
1611
|
+
applyAttention();
|
|
1612
|
+
if (env.dt)
|
|
1613
|
+
induceCharges(bodies, store.particles); // polarize neutral matter near charge/magnetism bodies (§20.10)
|
|
1614
|
+
// flow focus (field.flowTo): nudge free matter toward the moving target before integration, so
|
|
1615
|
+
// it visibly streams in. The streamline render bends toward it too (see drawField).
|
|
1616
|
+
if (flow && env.dt) {
|
|
1617
|
+
for (const p of store.particles) {
|
|
1618
|
+
if (p.cap)
|
|
1619
|
+
continue;
|
|
1620
|
+
const b = flowBias(p.x, p.y, flow, 0.6);
|
|
1621
|
+
p.vx += b.x;
|
|
1622
|
+
p.vy += b.y;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
updateWarpTargets(); // refresh warp relocate targets from paired bodies (§22.3) before the step
|
|
1626
|
+
step({ store, bodies, env, forces: reg.forces, conditions: reg.conditions, waves });
|
|
1627
|
+
// hover-focus (field.focusAt): hold the focused particle still and light it up — the dwell
|
|
1628
|
+
// affordance ("it stops and does something") before a click opens its record.
|
|
1629
|
+
if (focusP) {
|
|
1630
|
+
focusP.x = focusX;
|
|
1631
|
+
focusP.y = focusY;
|
|
1632
|
+
focusP.vx = 0;
|
|
1633
|
+
focusP.vy = 0;
|
|
1634
|
+
focusP.heat = Math.min(1, focusP.heat + 0.2);
|
|
1635
|
+
}
|
|
1636
|
+
if (env.dt) {
|
|
1637
|
+
for (const g of grids.values())
|
|
1638
|
+
g.step(); // advance field buffers (§20.1 [C])
|
|
1639
|
+
if (heatmap)
|
|
1640
|
+
heatmap.update(store.particles); // density heatmap buffer (H1)
|
|
1641
|
+
healWaves(store, bound, boundTarget, waves, W, H, env.t, rng);
|
|
1642
|
+
tearBoundByForces(bound, waves, bodies, reg.forces, W, H, env.t, (p) => void store.add(newParticle(p)));
|
|
1643
|
+
updateMovers();
|
|
1644
|
+
updateEmitters(); // element emit (§22.3): clone decorative templates, budgeted by data-max
|
|
1645
|
+
}
|
|
1646
|
+
writeFeedback();
|
|
1647
|
+
applyCausality();
|
|
1648
|
+
updateEvents();
|
|
1649
|
+
updateCaptureEvents();
|
|
1650
|
+
// Draw only when there is a surface to draw to AND the canvas can be seen. Under the
|
|
1651
|
+
// signals-only mode (`render: 'none'`, §13.7 / #297) the engine never draws — neither the
|
|
1652
|
+
// underlay nor the overlay — and `ctx` may not even exist. Under reduced motion the scene is
|
|
1653
|
+
// static (dt = 0), so a quarter-rate redraw is visually identical at a quarter of the cost.
|
|
1654
|
+
if (ctx && cfg.render !== 'none' && canvasVisible && (!reduceMotion || frameN % 4 === 0)) {
|
|
1655
|
+
render();
|
|
1656
|
+
if (overlayBackend) {
|
|
1657
|
+
const stack = overlayStack(cfg.overlay);
|
|
1658
|
+
if (stack.length)
|
|
1659
|
+
renderOverlay(overlayBackend, stack);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
raf = host.raf(frame);
|
|
1663
|
+
}
|
|
1664
|
+
function setFormation(name) {
|
|
1665
|
+
const f = FORMATION_BY[name];
|
|
1666
|
+
if (f)
|
|
1667
|
+
formTarget = { ...f.preset };
|
|
1668
|
+
}
|
|
1669
|
+
// conductor (§7.1): as a section crosses mid-viewport, ease to its formation
|
|
1670
|
+
// (declare with `data-formation="wells"`); after ~6 s of no input, drift back
|
|
1671
|
+
// to calm `ambient`. Inert on pages with no `[data-formation]` sections.
|
|
1672
|
+
let activeForm = '';
|
|
1673
|
+
let lastInput = t0;
|
|
1674
|
+
function onScroll() {
|
|
1675
|
+
const mid = host.viewport().height * 0.5;
|
|
1676
|
+
let next = '';
|
|
1677
|
+
host.root.querySelectorAll('[data-formation]').forEach((node) => {
|
|
1678
|
+
const r = node.getBoundingClientRect();
|
|
1679
|
+
if (r.top <= mid && r.bottom >= mid)
|
|
1680
|
+
next = node.dataset.formation ?? '';
|
|
1681
|
+
});
|
|
1682
|
+
if (next && next !== activeForm) {
|
|
1683
|
+
activeForm = next;
|
|
1684
|
+
setFormation(next);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
const markInput = () => void (lastInput = wallNow());
|
|
1688
|
+
const scrollHandler = () => {
|
|
1689
|
+
markInput();
|
|
1690
|
+
onScroll();
|
|
1691
|
+
};
|
|
1692
|
+
const idleTimer = setInterval(() => {
|
|
1693
|
+
if (wallNow() - lastInput > 6000 && activeForm !== 'ambient') {
|
|
1694
|
+
activeForm = 'ambient';
|
|
1695
|
+
setFormation('ambient');
|
|
1696
|
+
}
|
|
1697
|
+
}, 1200);
|
|
1698
|
+
const onResize = () => resize();
|
|
1699
|
+
resize();
|
|
1700
|
+
// pause all work while the tab is backgrounded — stop the loop and the idle timer,
|
|
1701
|
+
// resume cleanly when it returns (browsers throttle rAF in the background, but this
|
|
1702
|
+
// guarantees zero work and avoids drift on return).
|
|
1703
|
+
const onVisibility = () => {
|
|
1704
|
+
if (host.hidden()) {
|
|
1705
|
+
host.cancelRaf(raf);
|
|
1706
|
+
raf = 0;
|
|
1707
|
+
}
|
|
1708
|
+
else if (!raf) {
|
|
1709
|
+
raf = host.raf(frame);
|
|
1710
|
+
}
|
|
1711
|
+
};
|
|
1712
|
+
teardowns.push(host.onResize(onResize));
|
|
1713
|
+
teardowns.push(host.onScroll(scrollHandler));
|
|
1714
|
+
teardowns.push(host.onVisibility(onVisibility));
|
|
1715
|
+
teardowns.push(host.onInput(markInput));
|
|
1716
|
+
// shadow-DOM body events: forces:* + field:* aliases share the same idempotent handlers, so a body
|
|
1717
|
+
// registers under either namespace; the controller dispatches both, the engine listens to both.
|
|
1718
|
+
teardowns.push(host.onBodyEvent(REGISTER_BODY, onRegister));
|
|
1719
|
+
teardowns.push(host.onBodyEvent(UNREGISTER_BODY, onUnregister));
|
|
1720
|
+
teardowns.push(host.onBodyEvent(UPDATE_BODY, onUpdateBody));
|
|
1721
|
+
onScroll();
|
|
1722
|
+
raf = host.raf(frame);
|
|
1723
|
+
return {
|
|
1724
|
+
scan,
|
|
1725
|
+
rescan: scan,
|
|
1726
|
+
setAccent: (hex) => {
|
|
1727
|
+
cfg.accent = hex;
|
|
1728
|
+
curAccent = hexToRgb(hex);
|
|
1729
|
+
},
|
|
1730
|
+
setPalette: (p) => {
|
|
1731
|
+
// swap the travelling-accent stops (§9) and snap the accent to the first.
|
|
1732
|
+
const stops = resolvePalette(p);
|
|
1733
|
+
JOURNEY = stops.map(hexToRgb);
|
|
1734
|
+
const first = stops[0];
|
|
1735
|
+
if (first) {
|
|
1736
|
+
cfg.accent = first;
|
|
1737
|
+
curAccent = hexToRgb(first);
|
|
1738
|
+
}
|
|
1739
|
+
},
|
|
1740
|
+
setFormation,
|
|
1741
|
+
setAttention: (on) => {
|
|
1742
|
+
cfg.attention = on;
|
|
1743
|
+
if (!on)
|
|
1744
|
+
for (const b of bodies)
|
|
1745
|
+
b.attn = 1; // release the budget → neutral
|
|
1746
|
+
},
|
|
1747
|
+
setCausality: (on) => {
|
|
1748
|
+
cfg.causality = on;
|
|
1749
|
+
if (!on)
|
|
1750
|
+
for (const b of bodies) {
|
|
1751
|
+
b.el.style.removeProperty('--lit');
|
|
1752
|
+
b.el.dataset.fxLit = '0';
|
|
1753
|
+
}
|
|
1754
|
+
},
|
|
1755
|
+
setRender: (mode) => {
|
|
1756
|
+
// Signals-only mode (§13.7 / #297). Switching FROM 'none' on a field created with
|
|
1757
|
+
// `render: 'none'` acquires the 2d context lazily and sizes the backing store NOW — the
|
|
1758
|
+
// first and only allocation. Switching TO 'none' at runtime just stops drawing from the
|
|
1759
|
+
// next frame: an already-acquired context and backing store are kept (the no-allocation
|
|
1760
|
+
// guarantee belongs to fields created with 'none'), and the last drawn frame stays on the
|
|
1761
|
+
// canvas — hide or clear it with CSS if it is on screen, exactly as with setVisible(false).
|
|
1762
|
+
if (mode !== 'none' && !ctx) {
|
|
1763
|
+
ctx = canvas.getContext('2d');
|
|
1764
|
+
if (!ctx) {
|
|
1765
|
+
// context acquisition can genuinely fail (lost GPU process, too many contexts) —
|
|
1766
|
+
// warn and stay signals-only rather than crash the live simulation.
|
|
1767
|
+
console.warn(`field-ui: setRender('${mode}') could not acquire a 2d context; staying in render 'none'`);
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (overlayCanvas && !overlayCtx) {
|
|
1771
|
+
overlayCtx = overlayCanvas.getContext('2d');
|
|
1772
|
+
if (overlayCtx && !overlayBackend)
|
|
1773
|
+
overlayBackend = opts.overlayBackend ?? canvas2dBackend(overlayCanvas, overlayCtx);
|
|
1774
|
+
}
|
|
1775
|
+
sizeSurfaces(host.viewport().dpr); // the one deferred resize the lazy path needs
|
|
1776
|
+
}
|
|
1777
|
+
cfg.render = mode;
|
|
1778
|
+
},
|
|
1779
|
+
setOverlay: (mode) => {
|
|
1780
|
+
cfg.overlay = mode;
|
|
1781
|
+
if (!overlayStack(mode).length)
|
|
1782
|
+
overlayBackend?.clear(); // empty stack → clear the front surface
|
|
1783
|
+
},
|
|
1784
|
+
setHeatmap: (on) => {
|
|
1785
|
+
cfg.heatmap = on;
|
|
1786
|
+
if (on) {
|
|
1787
|
+
// Re-enabling always starts with a fresh buffer so mid-accumulation state from a prior
|
|
1788
|
+
// active period (or a paused field frozen mid-frame) never bleeds into the new session.
|
|
1789
|
+
if (!heatmap && W > 0)
|
|
1790
|
+
heatmap = new Heatmap(W, H);
|
|
1791
|
+
}
|
|
1792
|
+
else if (heatmap) {
|
|
1793
|
+
// Clear accumulated data before releasing the buffer — a paused field can hold a
|
|
1794
|
+
// non-zero grid that would persist visually if the buffer were recycled. Explicit clear
|
|
1795
|
+
// makes the exit symmetrical with the fresh-start re-enable above.
|
|
1796
|
+
heatmap.clear();
|
|
1797
|
+
heatmap = null;
|
|
1798
|
+
for (const b of bodies) {
|
|
1799
|
+
const el = b.writeTarget ?? b.el;
|
|
1800
|
+
el.style.removeProperty('--field-heatmap-density');
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
},
|
|
1804
|
+
threads: setThreads,
|
|
1805
|
+
burst: (x, y, hex) => {
|
|
1806
|
+
// discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
|
|
1807
|
+
const R = 160;
|
|
1808
|
+
for (const q of store.particles) {
|
|
1809
|
+
// the blast point sits on the page plane (z = 0): matter off-plane is shoved
|
|
1810
|
+
// deeper as well as outward — the 3D leg is 0 in a flat field (z-axis.md).
|
|
1811
|
+
const imp = burstImpulse(q.x - x, q.y - y, R, 6, q.z ?? 0);
|
|
1812
|
+
if (imp.heat === 0)
|
|
1813
|
+
continue;
|
|
1814
|
+
q.vx += imp.vx;
|
|
1815
|
+
q.vy += imp.vy;
|
|
1816
|
+
if (imp.vz)
|
|
1817
|
+
q.vz = (q.vz ?? 0) + imp.vz;
|
|
1818
|
+
q.heat = Math.max(q.heat, imp.heat);
|
|
1819
|
+
if (hex)
|
|
1820
|
+
q.color = hex; // carried pigment (§20.8)
|
|
1821
|
+
}
|
|
1822
|
+
// detach nearby bound matter so the shock is actually felt (§2.4, like supernova)
|
|
1823
|
+
tearBoundNear(bound, waves, x, y, R, W, H, env.t, (p) => void store.add(newParticle(p)));
|
|
1824
|
+
spawnSpark(x, y, 2, hex); // a visible pop at the blast point (§23)
|
|
1825
|
+
},
|
|
1826
|
+
flowTo: (x, y, opts) => {
|
|
1827
|
+
// place/move the flow focus; the frame loop eases the spine + pulls matter toward it, and the
|
|
1828
|
+
// streamline render bends to it. Called repeatedly (e.g. on pointermove) for dynamic targeting.
|
|
1829
|
+
flow = makeFlowFocus(x, y, opts);
|
|
1830
|
+
},
|
|
1831
|
+
clearFlow: () => {
|
|
1832
|
+
flow = null;
|
|
1833
|
+
},
|
|
1834
|
+
seed: (atoms) => {
|
|
1835
|
+
seeded = atoms;
|
|
1836
|
+
build(); // respawn fresh, then re-bind + re-scale (no compounding)
|
|
1837
|
+
},
|
|
1838
|
+
atomAt: (x, y) => {
|
|
1839
|
+
let best = null;
|
|
1840
|
+
let bd = Infinity;
|
|
1841
|
+
// the pointer lives on the page plane: depth counts against pickability, so a
|
|
1842
|
+
// dot deep in the volume is harder to pick than one at the surface (z-axis.md).
|
|
1843
|
+
for (const p of store.near(x, y, 24)) {
|
|
1844
|
+
if (p.atom == null)
|
|
1845
|
+
continue;
|
|
1846
|
+
const d = (p.x - x) ** 2 + (p.y - y) ** 2 + (p.z ?? 0) ** 2;
|
|
1847
|
+
if (d < bd) {
|
|
1848
|
+
bd = d;
|
|
1849
|
+
best = p.atom;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
return best;
|
|
1853
|
+
},
|
|
1854
|
+
focusAt: (x, y) => {
|
|
1855
|
+
let best = null;
|
|
1856
|
+
let bd = Infinity;
|
|
1857
|
+
for (const p of store.near(x, y, 24)) {
|
|
1858
|
+
if (p.atom == null)
|
|
1859
|
+
continue;
|
|
1860
|
+
const d = (p.x - x) ** 2 + (p.y - y) ** 2 + (p.z ?? 0) ** 2;
|
|
1861
|
+
if (d < bd) {
|
|
1862
|
+
bd = d;
|
|
1863
|
+
best = p;
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
focusP = best;
|
|
1867
|
+
if (best) {
|
|
1868
|
+
focusX = best.x;
|
|
1869
|
+
focusY = best.y;
|
|
1870
|
+
return best.atom ?? null;
|
|
1871
|
+
}
|
|
1872
|
+
return null;
|
|
1873
|
+
},
|
|
1874
|
+
clearFocus: () => {
|
|
1875
|
+
focusP = null;
|
|
1876
|
+
},
|
|
1877
|
+
particleCount: () => store.size,
|
|
1878
|
+
readParticles: (out) => {
|
|
1879
|
+
const ps = store.particles;
|
|
1880
|
+
const n = Math.min(store.size, Math.floor(out.length / 5)); // stride 5
|
|
1881
|
+
for (let i = 0; i < n; i++) {
|
|
1882
|
+
const p = ps[i];
|
|
1883
|
+
const o = i * 5;
|
|
1884
|
+
out[o] = p.x;
|
|
1885
|
+
out[o + 1] = p.y;
|
|
1886
|
+
out[o + 2] = p.z ?? 0; // optional z lane (z-axis.md); 0 in a flat field
|
|
1887
|
+
out[o + 3] = p.heat;
|
|
1888
|
+
out[o + 4] = p.size;
|
|
1889
|
+
}
|
|
1890
|
+
return n;
|
|
1891
|
+
},
|
|
1892
|
+
energy: () => energyReport(store.particles),
|
|
1893
|
+
sample: (x, y) => {
|
|
1894
|
+
const { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
|
|
1895
|
+
return { x: fx, y: fy };
|
|
1896
|
+
},
|
|
1897
|
+
scrollV: () => env.scrollV ?? 0,
|
|
1898
|
+
setVisible: (on) => {
|
|
1899
|
+
canvasVisible = on;
|
|
1900
|
+
},
|
|
1901
|
+
setBackground: (mode) => {
|
|
1902
|
+
cfg.background = mode;
|
|
1903
|
+
// a one-time clear on the way INTO transparent wipes the last opaque substrate frame, so
|
|
1904
|
+
// the surface goes clear immediately rather than holding the old near-black until redrawn.
|
|
1905
|
+
if (mode === 'transparent' && ctx)
|
|
1906
|
+
ctx.clearRect(0, 0, W, H);
|
|
1907
|
+
},
|
|
1908
|
+
destroy: () => {
|
|
1909
|
+
host.cancelRaf(raf);
|
|
1910
|
+
clearInterval(idleTimer);
|
|
1911
|
+
for (const off of teardowns)
|
|
1912
|
+
off(); // release every host event subscription
|
|
1913
|
+
// release the per-element [data-hot] engagement listeners, so repeated create/destroy
|
|
1914
|
+
// on the same DOM doesn't accumulate handlers (§18 teardown).
|
|
1915
|
+
for (const e of engaged) {
|
|
1916
|
+
e.el.removeEventListener('pointerenter', e.enter);
|
|
1917
|
+
e.el.removeEventListener('pointerleave', e.leave);
|
|
1918
|
+
e.el.removeEventListener('focus', e.enter);
|
|
1919
|
+
e.el.removeEventListener('blur', e.leave);
|
|
1920
|
+
delete e.el.dataset.fxEngaged;
|
|
1921
|
+
}
|
|
1922
|
+
engaged = [];
|
|
1923
|
+
// restore any docked elements so teardown never leaves content collapsed / aria-hidden / inert.
|
|
1924
|
+
for (const mv of movers) {
|
|
1925
|
+
if (mv.docked || mv.dock.dock > 0) {
|
|
1926
|
+
mv.docked = null;
|
|
1927
|
+
mv.dock.dock = 0;
|
|
1928
|
+
mv.el.style.opacity = '';
|
|
1929
|
+
if (mv.el.getAttribute('aria-hidden') === 'true')
|
|
1930
|
+
mv.el.removeAttribute('aria-hidden');
|
|
1931
|
+
mv.el.removeAttribute('inert');
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
// remove any DOM nodes emitted by element-emit bodies (§22.3), so teardown leaves no clones.
|
|
1935
|
+
for (const em of emitters)
|
|
1936
|
+
for (const clone of em.emitted)
|
|
1937
|
+
clone.remove();
|
|
1938
|
+
emitters = [];
|
|
1939
|
+
store.clear();
|
|
1940
|
+
},
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
//# sourceMappingURL=field.js.map
|