diff --git a/.claude-plugin/skill-assets.sha256 b/.claude-plugin/skill-assets.sha256 index cbd00041..2f565d53 100644 --- a/.claude-plugin/skill-assets.sha256 +++ b/.claude-plugin/skill-assets.sha256 @@ -2,5 +2,5 @@ 94bfa06317a8fe6a6a7e204bb70c5abdc9e4bbc34d79dd6f8447a30140bc8b85 .claude-plugin/plugin.json 055655db84af07561d002f0c69744313d8413c39f3e873f941f0fa0b1e76dc66 skills/engraphis-memory/references/CONVENTIONS.md 62019760766ff472a76a0f81437898f39e3c1fe2631732b7b7733e50c1ad837f skills/engraphis-memory/references/SCOPING.md -4ce83a2768680ec84488a767fc3bd6cd62688d785010a0abd1d4b3edbf14d03a skills/engraphis-memory/references/TOOLS.md -605181000a20a808e570b00cd2e852561f21234f041db03ade73f533b0e8bdaf skills/engraphis-memory/SKILL.md +96c8e9b9cee1b3cb43c4bef9e48c57ed92af707f7f7a5d28d73b1ac247d2f0c6 skills/engraphis-memory/references/TOOLS.md +0f98098df695b9a00dc78402911124ebf09a4a058f6c8bec2c6234ec61fac13a skills/engraphis-memory/SKILL.md diff --git a/.dockerignore b/.dockerignore index 7848fc73..8616b6df 100644 --- a/.dockerignore +++ b/.dockerignore @@ -63,3 +63,6 @@ playwright-report *.log *.whl *.tar.gz +# Test suites and evaluation harnesses — not needed in production images. +tests/ +eval/ diff --git a/.env.example b/.env.example index 8ace22b2..d009335b 100644 --- a/.env.example +++ b/.env.example @@ -321,9 +321,6 @@ ENGRAPHIS_LLM_MODEL=gpt-4o-mini # rerankers when ENGRAPHIS_REQUIRE_IMMUTABLE_MODELS=1. # ENGRAPHIS_RERANK_REVISION= -# Workspace allow-list: comma-separated names. Empty = all allowed. -# ENGRAPHIS_WORKSPACES=acme,personal - # Cloud Sync relay: endpoint and optional token for self-hosted relay. # ENGRAPHIS_RELAY_URL=https://relay.example.com # ENGRAPHIS_SYNC_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4407534..d18fc4ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ jobs: test: name: test + lint (full offline stack) runs-on: ubuntu-latest + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -51,6 +52,7 @@ jobs: typecheck: name: core + backends typecheck (Python 3.11) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -68,6 +70,7 @@ jobs: encryption: name: encryption driver gate (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -94,6 +97,7 @@ jobs: core-py39: name: core floor (numpy-only, Python 3.9) runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -161,6 +165,7 @@ jobs: coverage: name: coverage gate (Python 3.11) runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -178,6 +183,7 @@ jobs: hygiene: name: repo hygiene gate (no stray DBs/logs) runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Reject stray runtime artifacts at repo root @@ -192,6 +198,7 @@ jobs: pi-extension: name: Pi extension (${{ matrix.os }}, Python ${{ matrix.python-version }}, Node ${{ matrix.node-version }}) runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -229,6 +236,7 @@ jobs: browser-accessibility: name: browser accessibility smoke runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -254,6 +262,7 @@ jobs: # pyproject.toml, or this workflow). No third-party actions — plain git diff. name: docker smoke — path gate runs-on: ubuntu-latest + timeout-minutes: 5 outputs: run: ${{ steps.decide.outputs.run }} steps: @@ -274,6 +283,7 @@ jobs: docker-smoke: name: docker build + health smoke runs-on: ubuntu-latest + timeout-minutes: 20 needs: docker-gate if: needs.docker-gate.outputs.run == 'true' steps: @@ -312,7 +322,8 @@ jobs: trap cleanup EXIT python -m pip install --disable-pip-version-check --no-cache-dir pip-audit==2.10.1 docker create --name "$container" engraphis:ci >/dev/null - docker cp "$container":/usr/local/lib/python3.11/site-packages/. "$audit_dir" + site_packages=$(docker run --rm engraphis:ci python3 -c "import sysconfig; print(sysconfig.get_path('purelib'))") + docker cp "$container:$site_packages/." "$audit_dir" python -m pip_audit --path "$audit_dir" - name: Run container (offline deterministic embedder — no model downloads) run: | @@ -343,6 +354,7 @@ jobs: build: name: build + install wheel runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f9acac3..1da7d229 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: build: name: Build distributions runs-on: ubuntu-latest + timeout-minutes: 30 if: >- github.event_name == 'push' || inputs.release_tag == '' @@ -151,6 +152,7 @@ jobs: reproducibility-build: name: Independent distribution builder ${{ matrix.builder }} runs-on: ubuntu-latest + timeout-minutes: 30 container: python:3.11-slim@sha256:90744cff8f32887f075c47d747a173ff333e9e98801667af93c357fa9f5e28ff if: >- github.event_name == 'push' || @@ -188,6 +190,7 @@ jobs: name: Compare independent distribution builders needs: [build, reproducibility-build] runs-on: ubuntu-latest + timeout-minutes: 10 if: >- github.event_name == 'push' || inputs.release_tag == '' @@ -279,6 +282,7 @@ jobs: python-matrix: name: Python ${{ matrix.python-version }} release gate runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -320,6 +324,7 @@ jobs: github.event_name == 'push' || inputs.release_tag == '' runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -372,6 +377,7 @@ jobs: installed-artifact-platform-smoke: name: Installed wheel smoke (${{ matrix.os }}) needs: build + timeout-minutes: 20 runs-on: ${{ matrix.os }} if: >- github.event_name == 'push' || @@ -423,6 +429,7 @@ jobs: encryption: name: Encryption driver release gate (Python ${{ matrix.python-version }}) + timeout-minutes: 15 runs-on: ubuntu-latest strategy: fail-fast: false @@ -447,6 +454,7 @@ jobs: browser-accessibility: name: Browser accessibility release gate + timeout-minutes: 20 runs-on: ubuntu-latest if: >- github.event_name == 'push' || @@ -472,6 +480,7 @@ jobs: pi-extension: name: Pi extension release gate + timeout-minutes: 20 runs-on: ubuntu-latest if: >- github.event_name == 'push' || @@ -503,6 +512,7 @@ jobs: docker-smoke: name: Production image release gate runs-on: ubuntu-latest + timeout-minutes: 20 if: >- github.event_name == 'push' || inputs.release_tag == '' @@ -609,7 +619,8 @@ jobs: trap cleanup EXIT python -m pip install --disable-pip-version-check --no-cache-dir pip-audit==2.10.1 docker create --name "$container" engraphis:release >/dev/null - docker cp "$container":/usr/local/lib/python3.11/site-packages/. "$audit_dir" + site_packages=$(docker run --rm engraphis:release python3 -c "import sysconfig; print(sysconfig.get_path('purelib'))") + docker cp "$container:$site_packages/." "$audit_dir" python -m pip_audit --path "$audit_dir" - name: Run customer-mode readiness smoke shell: bash @@ -618,6 +629,7 @@ jobs: -e ENGRAPHIS_EMBED_MODEL= \ -e ENGRAPHIS_LOOP_INTERVAL=0 \ -e ENGRAPHIS_HOST=0.0.0.0 \ + -e ENGRAPHIS_SERVICE_MODE=customer \ engraphis:release for i in $(seq 1 60); do if curl -fsS http://127.0.0.1:8700/api/ready; then @@ -642,6 +654,7 @@ jobs: github.event_name == 'push' || inputs.release_tag == '' runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read env: @@ -675,6 +688,7 @@ jobs: needs: [build, reproducibility-check, python-matrix, artifact-core-py39, installed-artifact-platform-smoke, encryption, browser-accessibility, pi-extension, docker-smoke, code-security] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read @@ -752,6 +766,7 @@ jobs: # semver tag, whose value was matched to pyproject.toml in the build job above. if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + timeout-minutes: 10 permissions: id-token: write contents: read @@ -798,6 +813,7 @@ jobs: needs: publish if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write @@ -843,6 +859,7 @@ jobs: github.ref == 'refs/heads/main' && inputs.release_tag != '' runs-on: ubuntu-latest + timeout-minutes: 10 permissions: actions: read contents: write diff --git a/.gitignore b/.gitignore index 1d45ea37..9f8de4aa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ __pycache__/ *.db-shm *.db-journal *.db.*.bak +*.sqlite +*.sqlite3 +*.sqlite-wal +*.sqlite-shm +*.sqlite-journal # Webhook fulfillment runtime state — dedup DB + the undelivered-key fallback, # which can contain live license keys. Must never be committed. @@ -28,6 +33,9 @@ build/ /engraphis-[0-9]*/ .pytest_cache/ /.pytest-*-tmp/ +# Focused UI/API test runs use named repository-local base directories. +/.pytest-tmp-ui-*/ +/.codex-pytest-tmp/ .ruff_cache/ .coverage node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 163a5231..5ed35ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,6 +244,26 @@ These are pure, unit-tested functions — change them only with a corresponding re-verify with `wc -l`/`grep` before trusting a test run against it; clearing `__pycache__` alone does not fix this (the staleness is in the source, not in cached bytecode). +### PR delivery protocol for automated maintenance + +When an agent is maintaining an open pull request, the matching PR branch or an isolated +worktree is the delivery boundary: + +1. Implement every attributable, safe, scoped review or CI fix in that matching branch or + worktree. After tests and lint pass, inspect `git status` and the complete diff, then commit + and push every clean, attributable fix and PR worktree file with an ordinary non-force push. + A verified fix must not be left only in a local checkout. +2. Keep unrelated user edits, ambiguous files, credentials, generated databases, logs, and + secrets out of the PR. Preserve ambiguous work in its original checkout or a separate + worktree and report the exact separation needed; never mix it into an otherwise clean PR. +3. After each push, recheck the remote CI/workflow results, logs, and current review threads. + Continue with safe, attributable iterations until the PR is merge-ready, and report exact + files, commits, tests, remaining review items, and blockers. +4. Never force-push, merge, deploy, publish, delete branches/files, change credentials, rerun + workflows, post GitHub comments, resolve review threads, or send external messages without + explicit approval. Merge remains prohibited unless the user explicitly approves it, even + when checks are green. + --- ## 7. Source-of-truth docs diff --git a/CHANGELOG.md b/CHANGELOG.md index f8549389..b6f57d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,189 @@ All notable changes to Engraphis are documented here. Format loosely follows ## [Unreleased] +### Changed + +- Direct black-hole children now receive compact, deterministic orbital lanes near the black + hole instead of inheriting the farthest authored radius. Each lane keeps phase and painted + clearance, while community-child planets remain in their local moving frame; oversized Galaxy + scenes seed the same lanes before their kinematic clock starts. +- Complete Galaxy packing now uses a 4% painted-envelope clearance instead of a blanket 15% + radial allowance, keeping solar-system carriers materially denser around the black-hole + interior while preserving non-overlap. +- Explicit `orbits` links from the black hole now promote community anchors and their declared + stellar children into the central orbital carrier group, so the Orbital speed control moves + the connected nodes in both live and oversized Galaxy paths. +- Every Galaxy body now receives both motion frames: its top-level system carrier orbits the + black hole, while the body follows its immediate star/planet carrier with cached local phase; + legacy community metadata and nested moons use the same hierarchy without phase rewinds. +- Complete graph capacity is doubled to 40,000 entity nodes and 200,000 raw relationships, + with matching evidence, connector, payload, and full-loader ceilings; live-render safety + thresholds remain unchanged so oversized scenes stay on the static/kinematic path. + +- Galaxy admission now uses a tighter default carrier gap and calibrated orbital slack, keeping + more complete solar systems in the black-hole interior without sacrificing painted clearance. + +- Galaxy mode now exposes normalized controls for gravitational constant, compact black-hole + mass, independent local-solar gravity, space friction, edge-spring stiffness, and orbit + pause/play. The fixed-step Velocity Verlet field superposes black-hole carrier motion with + softened dominant-star orbits, adds bounded near-horizon frame dragging, differential tidal + stretching, and carrier-only orbital decay, preserves Hooke tethers and short-range + repulsion, and captures sub-escape drag releases into their authored star system while high + velocity releases escape. A bounded canvas layer renders the central gravity well, lens halo, + short trails, and up to 24 shallow local-star wells without adding simulation bodies. +- The dashboard Galaxy graph now caches its outer safety radius at 2× the initial painted + extent; escaped nodes are confined to that fixed envelope instead of expanding it. +- The Galaxy gravity slider now spans `0..400` while retaining the release-stable default + black-hole field of `240` and local field of `120`. Independent community stars run on a 2.5× + orbital clock and retain the calibrated default stellar well when Gravity is zero. An explicit + black hole now retains a smaller `24`-setting floor at the loose endpoint, so neither solar + systems nor their planets silently stop while the displayed control remains at zero. +- The Galaxy default orbital separation is now `60`, a 25% increase from `48`. Link and contact + projections remain contractive and correction-capped so dense layouts cannot overshoot or + ping-pong. Same-system contacts project along each declared stellar orbit so they preserve + radius and relative velocity while the dominant star remains fixed in the local system frame. +- Galaxy's `Orbital speed` control now scales local stellar rotation and whole-system rotation + around the central galaxy anchor in both live and oversized kinematic layouts. Its faster + endpoint also gives planets a modest 6% larger local orbital radius while the midpoint remains + unchanged; saved views continue using `repel`. +- Direct black-hole graph connections now classify their non-anchor nodes as black-hole + satellites, including legacy payloads without `system_anchor_id`, so those nodes rotate with + the same Orbital speed phase. +- Carrier orbit support now adopts a node's post-contact phase before advancing it, preventing + collision or boundary corrections from snapping nodes back to a stale lane angle and producing + visible jitter. +- Oversized Galaxy fallback layouts now use the complete gravity range instead of saturating near + the lower end of the slider. +- Complete Galaxy overview scenes remain expanded and physically live through 1,000 nodes and + 2,000 relations; larger Galaxy scenes and non-Galaxy full views retain the deterministic + fallback. +- Historical graph views now keep at least one ghost relation's endpoints together under + undersized node caps, and ghost evidence drilldowns resolve invalidated supporting memories + instead of a colliding live canonical alias. + +### Fixed + +- Galaxy layout now packs each complete solar-system envelope before orbital seeding and keeps + those envelopes separated with rigid carrier translations during live motion. Compact server + targets can no longer stack large systems near the black hole, while local planet positions, + velocities, event-horizon clearance, and the finite outer boundary remain intact. +- Galaxy hierarchy authority is now label-independent: an authored `anchor_role="global"` + selects the central mass regardless of its display name or evidence mass, while unannotated + compatibility scenes fall back deterministically through mass, rank, degree, and stable ID. +- The central black-hole adornment now advances a visible spin phase with the Galaxy physics + clock, so an otherwise satellite-free core no longer appears frozen while remaining the fixed + origin for the surrounding galaxy. +- Near-horizon curvature is now measured from each system's dominant-star carrier through a + bounded black-hole-scale band. A wide solar system can no longer be misclassified as already + inside the gravity well and have its ordinary galactic angular momentum drained. +- Galaxy systems revealed after the initial render, restored with zeroed velocity, or shown as + singletons now receive their own black-hole-frame tangential admission instead of being marked + seeded while stationary. Oversized Complete views use a bounded node-only hierarchical orbit + clock, and visible historical ghosts move as massless test particles without entering gravity, + contacts, or momentum. +- Galaxy members that appear before their eventual star, arrive through a later reveal, change + parent systems, or return with a zeroed local phase now receive one star-relative circular seed + without recoiling the dominant node. Existing healthy stellar orbits remain untouched. +- Dominant community stars now remain inertial at the centre of their moving solar-system frame. + Local gravity, stellar contact, dense separation, seeding, speed limiting, and the oversized + kinematic fallback move planets around that star instead of wobbling the star with its planets. +- Galaxy Reheat now wakes the persistent fixed-step clock without injecting bonus physics slices, + and cross-system separation is bounded so it cannot kick entire solar systems into a visible + fast-forward, ping-pong, or speed-cap pulse. +- Ledger graph reloads now retire and cache-bust a renderer that fetched successfully but failed + to register, instead of replaying the same broken asset response. +- Existing Galaxy preferences migrate only the retired `48` orbital-separation default to `60`; + deliberate custom values, including Gravity `0`, remain unchanged. + ## [1.6] - 2026-08-08 -Minor release advancing the v2 engine through schema 16 with deterministic sync state, trusted +Minor release advancing the v2 engine through schema 16 with deterministic sync state, trusted local document and Obsidian import, tighter trust boundaries, synchronized agent guidance, and stronger release and evaluation evidence. +### Changed + +- The Ledger knowledge graph now defaults to evidence-mass Galaxy gravity. The `galaxy-v6` + scene contract retains the magnitude of degree, PageRank, support, and repository evidence; + one mass value determines both visibly distinct star radius and gravitational pull. Deterministic + mass-ranked cores and orbital bands form local solar systems. The highest-evidence node becomes + the central black hole, rendered at least twice the ordinary evidence radius so its event horizon + remains visible at minimum Node size. Deterministic logarithmic arms seed a non-uniform disk, and + a fixed-step leapfrog clock advances eccentric, differential system orbits through an + evidence-derived core-plus-halo potential. Gravity now treats the dominant evidence node as + the explicit black-hole source: its field is `240` at the default slider and `864` at maximum, + while local solar-system, bridge, and drag gravity receives exactly half (`120` and `432`). The + smooth response remains true-zero and monotonic, and the rest of the core community contributes + through the softened halo rather than silently inflating the black-hole node's mass. External + solar systems also exert a weaker softened mutual field on one another: nearby evidence-heavy + systems perturb each other without requiring a relation edge, while the black hole remains the + dominant galaxy-wide potential. + The controlled centre pull is also doubled, retaining an immediate radial response rather than + hiding the stronger field behind a slower projector. Galaxy dynamics no + longer depend on D3 alpha decay, render cadence, or + force-directed settling. Galactic and local-system motion now uses a `0.021328125` fixed timestep, + another 30% slower than the preceding `0.03046875` cadence, while direct pointer movement remains responsive. + Every live seed coordinate and local orbit begins another 20% inward, putting + system centers at 40% of the original Galaxy radius. While live, the black-hole frame follows a + controlled inward spiral: Gravity 0 holds the loose seeded radius, and default/maximum convergence + now advances the same inward trajectory at 70% of its immediately preceding speed. Gravity slider input also + applies an immediate, reversible system-center response without changing local geometry or velocity: + its full range spans 40% radius contraction, and default-to-maximum visibly contracts about 31% + synchronously while maximum gravity retains its 3.6x field; + outward attempts still receive a 110% radial counter-projection and can never increase their + radius. Link distance now drives same-system evidence springs with twice the prior response and + a squared scale curve. Its default is now `8`, giving connected nodes a 0.25x rest length, 75% + tighter than the preceding default, while the full range still spans 1/16x tight orbits through + 25x loose orbits without allowing + cross-system relations to collapse the galaxy. A bounded mass-weighted positional relation + constraint makes Link distance respond immediately while preserving each solar system's centre + of mass. Orbital separation now owns an explicit same-system safety envelope instead of relying + on an imperceptible softening side effect: both its positional response and cushion scale are + doubled, spanning zero added space through 30 world units while preserving evidence-mass centre + of mass and removing closing energy. Dense projections retain the requested + compact radius and report unavoidable projected overlap instead of silently expanding the disk. + Near the core, the direct close-encounter term is 25% lower and its weight moves into the smooth + halo, reducing ejection without weakening the total evidence-mass field. Legacy layouts and + `/api/graph` remain available. + +### Fixed + +- Replace the packed-disk Galaxy regression with persistent softened-Newtonian dynamics. Galaxy + phase space is isolated from Compact and other legacy layouts, angular momentum is preserved + across layout changes, and large stars are visibly distinct. A smooth evidence-mass field keeps + each solar system bound while direct star-to-star gravity supplies smaller organic perturbations; + evidence bridges remain visible provenance without injecting non-central orbital energy or + relation springs compressing the scene into a graph blob. Dragging now leaves the fixed-step + Galaxy clock live without alpha changes, global reheats, reseeding, or detaching any global force. + The pointer owns exactly one moving mass source while every live body follows its softened + inverse-square gravity, whether linked or unlinked; distance and evidence mass determine the + response, and explicit relations only strengthen it. A bounded once-per-physics-slice projection + makes nearby unlinked bodies visibly follow without teleporting, freezing the rest of the graph, + or depending on pointer-event frequency. Pointer events update only the source position and + field membership--the gravitational response is sampled by the 30 Hz physics clock. The selected + Link orbit supplies a safe periapsis, + tangential momentum is retained, and release adds no wake or impulse. Freeze remains the sole + explicit motion gate. The explicit **Reheat layout** action now gives Galaxy a finite custom- + solver relaxation burst (30 extra steps, or 12 for large live scenes) instead of merely ensuring + its already-running clock exists; repeated clicks coalesce, current orbital phase is preserved, + and no D3 alpha, random kick, or orbital reseed is introduced. +- Eliminate false Galaxy "reheating" caused by two local solvers fighting each other every tick. + Link distance and Orbital separation now share the same lower-bound target, the redundant live + velocity spring no longer injects energy alongside the positional constraint, and close-range + separation dissipates closing radial motion. Correction-distance diagnostics expose whether a + system is genuinely settling without changing its orbital phase or waking D3. +- Stabilize dense solar systems and high-degree hubs without weakening their gravity. Link and + Orbital-separation constraints now sample one immutable phase and apply one simultaneous, + mass-balanced update per node instead of stacking an update for every incident edge. Aggregate + position and contact-velocity caps prevent a hub slingshot, while a system-relative speed fuse + damps only anomalous member motion and preserves each free system's center-of-mass orbit. +- Show unlinked entities in new Ledger and Classic graph views by default so isolated evidence is + not silently omitted. The toolbar still switches to a linked-only view, and persisted user or + saved-view preferences remain authoritative. +- Keep large Galaxy scenes interactive by replacing quadratic entity-visibility scans with + set-wise privacy pruning, driving evidence lookups from the requested relation IDs, and making + Ledger retries cancel and supersede stale scene requests safely. + ### Added - A dependency-free, source-neutral local document importer for Markdown, plain text, @@ -33,7 +210,7 @@ stronger release and evaluation evidence. - Fail closed on new `user`-scope memory writes until records carry an immutable owner identity; preserve historical reads and the existing promotion rejection instead of presenting workspace-bound rows as private personal memory. -- Parse bounded dotenv-style configuration without an optional runtime dependency, and load it only from the owner-private +- Parse bounded dotenv-style configuration without an optional runtime dependency, and load it only from the owner-private `~/.engraphis/config.env` or an absolute owner-private file selected by `ENGRAPHIS_ENV_FILE`; arbitrary working-directory `.env` files are not a trust boundary. - Clarify Cloud Sync credential-origin binding, secret-manager-only unattended credentials, @@ -225,7 +402,7 @@ tombstones remain global. ### Upgrade notes -- `engraphis-mcp` now exposes nine Smart tools instead of 33 direct tools. Clients that depend on +- `engraphis-mcp` now exposes nine Smart tools instead of 34 direct tools. Clients that depend on the former names should switch their server command to `engraphis-mcp-classic`; HTTP clients can use `engraphis-mcp-http --classic`. - Existing v2 databases migrate automatically to schema 9 on first open; the change is additive @@ -237,7 +414,7 @@ tombstones remain global. - Smart MCP is now the zero-configuration `engraphis-mcp` default. It exposes nine compact tools: sessions, prompt-ready recall, durable memory, discovery, validated read/action execution, and - governed record read/update plus conflict review. `engraphis-mcp-classic` preserves the former 33 + governed record read/update plus conflict review. `engraphis-mcp-classic` preserves the former 34 direct tool names and legacy alias response shapes for pinned integrations. - The first-party `@engraphis/pi` package under `integrations/pi` exposes that Smart MCP surface as native Pi tools, verifies the Engraphis 1.4.x handshake, and ships with independent npm @@ -1302,4 +1479,4 @@ and safe hosted deployment. --- -**Security reporting:** Email **security@engraphis.dev** for vulnerability disclosure. +**Security reporting:** Email **security@engraphis.dev** for vulnerability disclosure. diff --git a/README.md b/README.md index b7b63d25..016f268a 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ Engraphis Knowledge Graph tab: force-directed entity-relation network
Knowledge Graph · run engraphis-dashboard to see it live -

- ---- +

+ +**Grounded, not guessed.** Memory with receipts. Local by default. [Explore the proof gallery](https://github.com/Coding-Dev-Tools/engraphis/tree/main/docs/advertising) or [read the campaign guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/advertising/campaign.md). + +--- > **Open-core boundary:** this repository contains the free local engine, dashboard, MCP server, > and customer-side clients. Hosted sync, analytics, automation, and team services run on the @@ -37,11 +39,12 @@ context Engraphis actually emitted, keeps token counters and release versions se labels adaptive history reductions separately from packing savings. Receipts without estimator metadata remain historical/unclassified. This measures estimated prompt-context reduction; it does not measure provider billing. The `/context-savings` API and -`engraphis_context_savings` MCP tool accept optional `from_ts`, `to_ts`, and `release_version` -filters. +`engraphis_context_savings` MCP tool aggregate the complete history across all visible workspaces +by default, or accept an explicit workspace plus optional `from_ts`, `to_ts`, and +`release_version` filters.

- Dark chart showing three deterministic offline comparisons. Structure-aware chunks reduce mean retrieved content from 740.3 to 214.3 tokens and the smallest evidence-holding memory from 162.2 to 42.4 tokens while Recall at 5 remains 1.000. A compact recall JSON-shape proxy uses 10,202 rather than 23,810 tokens. Evidence artifact SHA-256: c3a74f1770ad3f868f55261ba11680e2dadca30167082ac2cb6669f9e3bdfad2. + Dark chart of local measurements and deterministic fixtures, including a local LoCoMo diagnostic marked with an asterisk. Cross-session handoff satisfaction rises from 3 of 15 queries with the last memories to 15 of 15 with proactive ranking or a consolidated summary. Intent-layered graph routing rises from 0 of 3 to 3 of 3 correct top-1 targets, and two-hop graph recall rises from 0 of 3 with one-hop expansion to 3 of 3 with Personalized PageRank. Consolidation-aware ranking selects the expected digest in 2 of 2 summary cases instead of 0 of 2 for the baseline. Structure-aware chunks reduce context from 740.3 to 214.3 tokens and the smallest evidence-holding memory from 162.2 to 42.4 tokens. A compact JSON-shape proxy uses 10,202 rather than 23,810 tokens. Grounded recall makes 10 of 10 correct decisions and packed context averages 85.38 tokens under a 1,500-token cap.
Less repeated history means more room for the task, tools, and useful evidence.

@@ -154,7 +157,7 @@ selection, set `ENGRAPHIS_UPDATE_EXTRAS` to a comma-separated list (for example `server,mcp`), or set it to `none` for the base package only. > **Upgrading to 1.4:** `engraphis-mcp` now exposes the nine-tool Smart gateway. Integrations that -> require the former 33 direct tool names should run `engraphis-mcp-classic`. The SQLite schema +> require the former 34 direct tool names should run `engraphis-mcp-classic`. The SQLite schema > in the 1.4.0 release was version 9. Existing v7-to-v8 databases already contain `confidence` > and `pinned_at`/`unpinned_at`; v9 adds the `memory_tombstones` repository-scope column/table > and performs a one-time entity-canonicalization repair, then migrates automatically on first @@ -566,7 +569,7 @@ when you are ready to evaluate the service boundary and billing options. | Memory engine + Smart MCP (Classic 34-tool compatibility) | ✓ | ✓ | ✓ | | Version-chain diffs, offline knowledge graph | ✓ | ✓ | ✓ | | Manual local consolidation (dry-run by default) | ✓ | ✓ | ✓ | -| Local workspace export (JSON: memories, sessions, audit) | ✓ | ✓ | ✓ | +| Local workspace export (portable v2 JSON: memories, source manifests, graph/code evidence, sessions, audit, and receipts) | ✓ | ✓ | ✓ | | Hosted Cloud Sync | | ✓ | ✓ | | Hosted Analytics | | ✓ | ✓ | | Hosted Auto Consolidation + retention policy | | ✓ | ✓ | @@ -693,14 +696,13 @@ file. It never searches the working directory for `.env`, and explicit process v | Env Var | Default | Description | |---------|---------|-------------| | `ENGRAPHIS_ENV_FILE` | `~/.engraphis/config.env` | Optional trusted config leaf selected before trusted values load. Its bounded dependency-free parser performs no interpolation. An explicit value must be an absolute path to an owner-private regular file; arbitrary working-directory `.env` files are ignored. | -| `ENGRAPHIS_DB_PATH` | Source: `/engraphis.db`; installed: platform user-data directory | SQLite database file. Installed defaults are `%LOCALAPPDATA%\engraphis\engraphis.db` (Windows), `~/Library/Application Support/engraphis/engraphis.db` (macOS), and `$XDG_DATA_HOME/engraphis/engraphis.db` or `~/.local/share/engraphis/engraphis.db` (Linux). The environment variable overrides every default. | +| `ENGRAPHIS_DB_PATH` | Source: `/engraphis.db`; installed: platform user-data directory | SQLite database file. Installed defaults are `%LOCALAPPDATA%\engraphis\engraphis.db` (Windows), `~/Library/Application Support/engraphis/engraphis.db` (macOS), and `$XDG_DATA_HOME/engraphis/engraphis.db` or `~/.local/share/engraphis/engraphis.db` (Linux). The environment variable overrides every default; a relative value is resolved from the trusted `~/.engraphis/config.env` directory so launch CWD cannot select a different workspace database. | | `ENGRAPHIS_HOST` | `127.0.0.1` | Server bind address | | `ENGRAPHIS_PORT` | `8700` | Dashboard port | | `ENGRAPHIS_SERVICE_MODE` | `customer` | The public package supports only `customer`; hosted vendor, relay, compute, and worker roles are not distributed here | | `ENGRAPHIS_API_TOKEN` | Not set | Optional bearer credential for this single-user local customer node; never reuse a hosted credential | | `ENGRAPHIS_CORS_ORIGINS` | loopback on `ENGRAPHIS_PORT` | Comma-separated REST CORS allow-list; defaults to `127.0.0.1` and `localhost` on the configured port | -| `ENGRAPHIS_WORKSPACES` | Not set | Optional comma-separated server-side workspace allow-list | -| `ENGRAPHIS_INDEX_ROOTS` | Working, home, and temporary directories | Optional path-separator-delimited absolute-path allow-list that replaces the default roots accepted by local code indexing | +| `ENGRAPHIS_INDEX_ROOTS` | Working, home, and temporary directories | Optional path-separator-delimited absolute-path allow-list that replaces the default roots accepted by local code indexing | | `ENGRAPHIS_HTTP_INDEX_ROOT` | First `ENGRAPHIS_INDEX_ROOTS` entry, or current directory | Single root for dashboard and REST `POST /api/code/index`; submitted paths resolve beneath it. An explicit root (or fallback entry) must be absolute; an explicit HTTP root is included in the engine-approved set. MCP and CLI indexing continue to use `ENGRAPHIS_INDEX_ROOTS`. | | `ENGRAPHIS_DB_KEY` | Not set | Encrypt the database at rest (SQLCipher). Or use `ENGRAPHIS_DB_KEY_FILE` | | `ENGRAPHIS_EMBED_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | sentence-transformers model | diff --git a/SECURITY.md b/SECURITY.md index 91c80424..76e989f1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -67,8 +67,9 @@ DOMPurify at all render sites. Verified against payloads with `onerror` handlers ### 3. Scope isolation - Every read takes a `SearchFilter`; tools only return memories within requested `workspace`/`repo` - Every write targeting a memory by ID re-validates scope membership -- **Hard workspace binding** (`ENGRAPHIS_WORKSPACES`): comma-separated allow-list makes - workspace a hard boundary; requests outside the list are refused before touching the store +- Workspace creation is not controlled by a process-wide allow-list; each operation still + carries its explicit workspace/repo/session scope and authenticated personal workspaces + enforce their owner boundary ### 4. Secrets & data at rest - `.env`, `*.db`, `*.db-wal`, `*.db-shm` are git-ignored and must never be logged. Gitignore is diff --git a/docs/MCP_TOOLS.md b/docs/MCP_TOOLS.md index cec272b9..8526a0af 100644 --- a/docs/MCP_TOOLS.md +++ b/docs/MCP_TOOLS.md @@ -98,7 +98,7 @@ the [memory write trust model](WRITE_REVIEW.md) and [recall recovery guide](RECA | Code | `engraphis_export_code_graph` | Exports graph JSON, Markdown, and HTML. | | Code | `engraphis_link_symbol` | Manually links a code symbol to a memory (idempotent). | | Audit | `engraphis_receipts` | Lists content-free hashed operation receipts. | -| Audit | `engraphis_context_savings` | Reports receipt-backed estimated context tokens saved, eligible/excluded deliveries, basis, confidence, and token-counter identity; optional `from_ts`, `to_ts`, and `release_version` filters are supported. This is estimated prompt-context reduction, not provider billing. | +| Audit | `engraphis_context_savings` | Reports receipt-backed estimated context tokens saved across all visible workspaces by default, or one workspace when supplied; optional `from_ts`, `to_ts`, and `release_version` filters are supported. This is estimated prompt-context reduction, not provider billing. | | Audit | `engraphis_verify_receipts` | Verifies the receipt chain, local tail anchor, and an optional saved head/count. | | Audit | `engraphis_export_receipts` | Exports a shareable receipt-only audit bundle. | | Governance | `engraphis_retire` | Retires a memory by closing its validity window. It does not delete history. | @@ -111,12 +111,14 @@ the [memory write trust model](WRITE_REVIEW.md) and [recall recovery guide](RECA | Operations | `engraphis_stats` | Returns memory counts for health checks. | | Operations | `engraphis_check_update` | Refreshes the release cache and reports whether a newer version is available. | -All four recall tools (`engraphis_recall`, `engraphis_recall_context`, +The classic recall, grounded, and answer tools (`engraphis_recall`, `engraphis_recall_grounded`, and the `engraphis_answer` alias) accept `planning="off"|"auto"`, optional `mtype_limits` such as `{"working": 1, "semantic": 3}`, and optional -`max_response_tokens` from `1` through `1000000`. `response_mode="full"` returns the classic +`max_response_tokens` from `2` through `1000000`. `response_mode="full"` returns the classic response; `"compact"` removes packed context and citation/memory bodies from the end while -preserving source/citation references. Responses include a stable `context_revision`. Planner +preserving source/citation references. `engraphis_recall_context` is always compact and does +not accept `response_mode`; it shares the same `max_response_tokens` floor. Responses include +a stable `context_revision`. Planner details, per-query rankings, type-limit drops, and fallback reasons are returned only when `diagnostics=true`. Type limits are post-rank maxima and can intentionally return fewer than `k`; they do not raise a memory type's relevance. Every planned query remains inside the caller's scope, diff --git a/docs/advertising/campaign.md b/docs/advertising/campaign.md new file mode 100644 index 00000000..9c0015d1 --- /dev/null +++ b/docs/advertising/campaign.md @@ -0,0 +1,186 @@ +# Engraphis proof-first campaign + +Engraphis should be marketed as a memory system that can show its work. + +> Grounded, not guessed. Memory with receipts. Local by default. + +This guide turns existing demos, benchmark artifacts, and product surfaces into a repeatable campaign. It does not introduce new product claims. + +## Message architecture + +### The flagship promise + +**Engraphis carries project knowledge forward without treating old information as current truth.** + +Use this promise when the audience needs the whole story: local storage, scoped recall, temporal validity, provenance, and context packing. + +### Four proof stories + +| Story | Hook | Existing proof | Primary action | +| --- | --- | --- | --- | +| Local | No account. No API key. Still remembers. | Dashboard quickstart and local-first README copy | Install free | +| Grounded | No support, no answer. | Grounded recall and evidence-backed behavior fixture | Inspect grounded recall | +| Temporal | Facts change. History stays visible. | Continuity demo, invalidation, supersession, and timeline | Watch the continuity demo | +| Efficient | Fewer tokens, better evidence. | Registered `offline-chunking` and `offline-performance` fixtures | Read the benchmark | + +Keep one story per asset. Do not combine every feature into one graphic. + +## Four-week distribution sequence + +### Week 1: Local memory + +Lead with the relief of not starting from zero and the control of keeping memory on the machine. + +- Short screen clip: open the local dashboard and show the graph, provenance, and receipts. +- Static post: “Your agent’s memory can stay on your machine.” +- Setup post: `pip install "engraphis[mcp]"`, `engraphis-init`, and the Smart MCP command. +- README placement: link the proof gallery directly below the existing knowledge graph image. + +### Week 2: Grounded recall + +Lead with a cited answer and an explicit abstain. The contrast is more memorable than another generic retrieval diagram. + +- Carousel: “Cited answer” beside “No support, no answer.” +- Short clip: ask one supported question, then one off-topic question. +- Technical post: explain why the grounded gate uses absolute support instead of the normalized recall score. +- CTA: “Try grounded recall.” + +### Week 3: Temporal memory + +Lead with one changing repository decision. Show the old fact, the new fact, and the reason the old record remains queryable. + +- Timeline graphic: old validity window closes, new fact becomes current. +- Continuity reel: use the existing 56-second demo artifact. +- Blog post: “Engraphis does not just remember. It remembers what changed.” +- CTA: “Inspect the why and timeline.” + +### Week 4: Context economy + +Lead with the smallest useful evidence, not a vague claim about speed or cost. + +- Stat card: `740.3 -> 214.3` tokens with Recall@5 `1.000` in the registered fixture. +- Evidence card: `162.2 -> 42.4` tokens to the smallest evidence-holding memory. +- Technical post: clarify that the compact payload proxy is separate from chunking and must not be added to it. +- CTA: “Read the benchmark definitions.” + +## Reusable post hooks + +1. Stop replaying the whole chat. +2. Memory with receipts. +3. No support, no answer. +4. Facts change. History stays visible. +5. Some answers live in the graph, not the note. +6. Your agent does not need more history. It needs the right evidence. +7. Local memory should not require a trust fall. +8. Find the symbol. Explain the decision. +9. Nine Smart MCP tools first. Discover advanced actions only when needed. +10. Bring your memory stack. Publish one immutable run. + +## Public benchmark challenge + +### Campaign idea + +Invite memory-tool builders to run a fixed, public-safe fixture and publish the artifact digest, command, configuration, and result summary. + +### Public copy + +> Bring your memory stack. Publish one immutable run. +> +> Use the locked fixture, keep the comparison boundary explicit, and share the result without raw questions, answers, prompts, or private records. + +### Launch requirements + +- Anchor every published number to an evidence ID in `BENCHMARKS.md`. +- Use the public runbook in `docs/PUBLIC_BENCHMARK_RUNBOOK.md` as the execution contract. +- Publish the whole-input and source-file digests required by the runbook. +- Keep raw questions, answers, prompts, context, and per-record content fingerprints out of public artifacts. +- Present the challenge as a reproducibility standard, not as a self-selected victory lap. + +### Embed-ready result format + +```text +Memory benchmark +Stack: +Fixture: +Command: +Artifact digest: +Result summary: +Limitations: +``` + +## Remixable diagram set + +Create five self-contained HTML or SVG artifacts. Each should have one headline, one visual claim, one source link, and one CTA. + +1. **Memory flow:** source material -> scoped memory -> hybrid recall -> task-ready evidence. +2. **Supersession chain:** old fact -> invalidation -> current fact -> why and timeline. +3. **Benchmark flow:** locked fixture -> exact command -> digest -> public result. +4. **Scope hierarchy:** workspace -> repo -> session -> memory. +5. **MCP integration:** install -> initialize -> connect -> recall and remember. + +Use the gallery in `docs/advertising/index.html` as the visual reference. Keep the page free of external font, image, and JavaScript dependencies. Existing evidence files remain the canonical source views: + +- `docs/images/context-efficiency.svg` +- `docs/images/evidence-backed-agent-examples.svg` +- `docs/images/knowledge-graph.png` +- `demo/engraphis_screen_demo.html` + +The linked `diagram-design` project is a useful reference for self-contained HTML/SVG, brand tokens, gallery navigation, and exportable variants: . + +## Setup recipes + +### Smart MCP + +```bash +pip install "engraphis[mcp]" +engraphis-init +codex mcp add engraphis -- engraphis-mcp +``` + +### Dashboard + +```bash +pip install "engraphis[server]" +engraphis-dashboard +``` + +### Offline Python library + +```python +from engraphis.service import MemoryService + +memory = MemoryService.create("engraphis.db") +``` + +Keep hosted sync and Pro trial messaging below the free local path. The local engine is the first conversion step. Hosted services are a separate trust and pricing decision. + +## Measurement plan + +Track these events without changing existing button labels or URL structure: + +| Event | Meaning | +| --- | --- | +| `advertising_gallery_open` | A visitor opened the proof gallery | +| `advertising_demo_open` | A visitor opened the continuity demo | +| `advertising_install_click` | A visitor selected the free install path | +| `advertising_mcp_click` | A visitor selected the MCP path | +| `advertising_graph_click` | A visitor opened the graph quickstart | +| `advertising_trial_click` | A visitor selected an existing Pro or Team trial CTA | +| `advertising_benchmark_click` | A visitor opened the public benchmark material | + +Run two headline comparisons: + +- “Grounded, not guessed.” versus “Memory that knows where it came from.” +- “Install free” versus “Connect MCP.” + +Judge the result by demo opens, install completion, MCP setup completion, graph export usage, and trial clicks. Do not call an experiment successful without a defined denominator and time window. + +## Claim guardrails + +- Keep `740.3 -> 214.3`, `162.2 -> 42.4`, and `23,810 -> 10,202` as separate measurements with their existing definitions. +- These measurements must not be added together. +- Do not describe deterministic fixtures as official LoCoMo or LongMemEval leaderboard results. +- Do not turn a synthetic fallback screen into customer evidence. +- Do not claim provider billing, universal latency, or customer productivity from the current offline fixtures. +- Keep code `query`, memory-backed `explain`, graph `path`, and graph `impact` as distinct actions. +- Preserve the local versus hosted boundary in every pricing and privacy asset. diff --git a/docs/advertising/index.html b/docs/advertising/index.html new file mode 100644 index 00000000..7320d750 --- /dev/null +++ b/docs/advertising/index.html @@ -0,0 +1,473 @@ + + + + + + + Engraphis | Grounded, not guessed + + + +
+ + +
+
+
+

Local-first memory for agents

+

Grounded, not guessed.

+

Memory with receipts. Keep project history local, retrieve evidence, and carry the right context into every session.

+ +
+
+ + A memory receipt from source to grounded answer + Project history enters scoped memory, retrieval finds supporting evidence, and a cited answer leaves the system. + + + + + + + + + + + + SRCSCOPEMEM + ASKRANKCITE + OLDNEWWHY + + + SOURCE + HISTORY + RETRIEVAL + SUPPORT + CHANGE + PROVENANCE + one local record, many inspectable paths + + +
Built from the repository's memory, retrieval, provenance, and temporal contracts.
+
+
+ +
+
+

Proof you can inspect.

+

Every headline below maps to a registered fixture, an existing demo, or a visible local product surface.

+
+
+
+
740.3 → 214.3
+

Structure-aware chunks returned the relevant passage instead of the whole document, with Recall@5 held at 1.000 in the registered fixture.

+ Open context efficiency evidence +
+ +
+
+ +
+
+

Memory has a timeline.

+

Use a living fact, not a feature list, to show what makes Engraphis different from a chat replay or a flat note store.

+
+
+
+ + A supersession timeline + An old repository decision is superseded by a newer decision while both remain visible. + + + + + + + SESSION 01SESSION 02WHY + + + old decisionsupersedestimeline + + +

A correction closes the old validity window. It does not erase the record.

+
+
+

Facts change. History stays useful.

+

Show one subject moving from an earlier decision to a current one. The audience sees the old fact, the new fact, and the evidence chain that explains the transition.

+ Open the continuity demo +
+
+
+ +
+
+
+

Search the code. Explain the decision.

+

Make the repository graph the bridge between a symbol, the related path, and the memory that explains why the system looks that way.

+ Open the graph quickstart +
+
+ + From code search to explanation + A named symbol leads to a connected path, then to a memory-backed explanation and a standalone export. + + + + + + + SYMBOLPATHEXPLAINHTML + searchimpactmemoryexport + + +

The code graph is best-effort. The explanation remains memory-backed and inspectable.

+
+
+
+ +
+
+

Pick your path.

+

Keep the first action concrete. Different audiences can enter through the local dashboard, Smart MCP, or the Python library.

+
+
+ +
+

Open dashboard

+

Inspect memories, graph relationships, provenance, receipts, and temporal changes locally.

+ Open the dashboard path +
+
+

Use Python

+

Start with the offline library when you need a memory engine without a hosted dependency.

+ Use the library +
+
+
+ +
+
+
+

Turn proof into a campaign.

+

A four-week sequence turns continuity, grounded recall, temporal memory, and context economy into one repeatable story.

+ Open campaign guide +
+
    +
  • Week 1: LocalNo account. No API key. Still remembers.
  • +
  • Week 2: GroundedCited answer or explicit abstain.
  • +
  • Week 3: TemporalFacts change. History stays visible.
  • +
  • Week 4: EfficientFewer tokens, better evidence.
  • +
+
+
+ +
+
+

Keep the proof honest.

+

Trust is part of the product story. These boundaries keep the gallery useful to technical readers.

+
+
+ What the numbers mean +

The 740.3 to 214.3 comparison is retrieved memory content in a deterministic fixture. It is not provider billing, latency, or a universal customer outcome.

+
+
+ What the demo means +

The continuity demo is generated from a real in-memory MemoryService run. Any sample fallback is labeled and must not be presented as customer evidence.

+
+
+ What the graph means +

Symbol and file search, memory-backed explanation, path, impact, and export are distinct actions. The gallery keeps those terms separate.

+
+
+
+ + +
+ + diff --git a/docs/images/context-efficiency.png b/docs/images/context-efficiency.png index 3734cb64..6624ac61 100644 Binary files a/docs/images/context-efficiency.png and b/docs/images/context-efficiency.png differ diff --git a/docs/images/context-efficiency.svg b/docs/images/context-efficiency.svg index e808da02..0e5c1156 100644 --- a/docs/images/context-efficiency.svg +++ b/docs/images/context-efficiency.svg @@ -1,99 +1,41 @@ - - Engraphis measured token and context savings - A dark-mode chart with three deterministic offline comparisons. Structure-aware chunks reduce mean retrieved content from 740.3 to 214.3 tokens and the smallest evidence-holding memory from 162.2 to 42.4 tokens while Recall at 5 remains 1.000. A compact JSON-shape recall payload proxy uses 10,202 rather than 23,810 tokens, 57.15 percent less; this is not an MCP transport or provider-billing measurement. Exact commands, suite digest 4d7e40607319cd4bf8caee3897f1e416dbe5b81998b37a7e4839409ee2923537, and config digests are recorded in BENCHMARKS.md. Public-safe artifact SHA-256: c3a74f1770ad3f868f55261ba11680e2dadca30167082ac2cb6669f9e3bdfad2. External, model-dependent, latency, consolidation, and productivity numbers remain unpublished until equivalent evidence exists. - - - - - - - - - - - - - - - - - - Give your agent more room to think - Three deterministic offline comparisons with checksum-bound public evidence. - - - Baseline - - Engraphis - Each row has its own baseline - - - - Public evidence is checksum-bound - Artifact, fixture-suite digest, exact commands, and per-command config digests - No external or model-dependent number is published without the same evidence - offline-fixtures-v1.json - 3 registered runs + + What the memory system changes + A compact dark telemetry chart of local measurements and deterministic fixtures across continuity, graph reasoning, retrieval quality, and context economy. A local LoCoMo diagnostic reduces replayed context from 49,915,394 to 891,857 tokens, 98.21% lower, while preserving the measured retrieval result for that diagnostic. Cross-session handoff satisfaction rises from 3 of 15 queries with the last memories to 15 of 15 with proactive ranking or a consolidated summary. Intent-layered graph routing rises from 0 of 3 to 3 of 3 correct top-1 targets. Two-hop graph recall rises from 0 of 3 with one-hop expansion to 3 of 3 with Personalized PageRank. Consolidation-aware ranking selects the expected digest in 2 of 2 summary cases instead of 0 of 2 for the baseline while preserving raw and source evidence. Structure-aware chunks reduce retrieved context from 740.3 to 214.3 tokens and the smallest evidence-holding memory from 162.2 to 42.4 tokens, both with Recall at 5 of 1.000. A compact JSON-shape proxy, not an MCP transport response, uses 10,202 rather than 23,810 tokens, with Recall at 5, hit at 5, and answer-token recall all 1.000. Grounded recall makes 10 of 10 correct decisions, 8 of 8 memory-security checks pass, and packed context averages 85.38 tokens under a 1,500-token cap. This measures estimated prompt-context reduction, does not measure provider billing, and is backed by public fixture SHA-256 c3a74f1770ad3f868f55261ba11680e2dadca30167082ac2cb6669f9e3bdfad2. + + - - - - Retrieved memory content per question - Long-document test · 18 questions · Recall@5 1.000 - Whole documents · 740.3 tokens - - Focused chunks · 214.3 tokens - - 71.1% less - - - - - Smallest useful memory returned - The same 18 questions found supporting evidence - Whole document · 162.2 tokens - - Useful chunk · 42.4 tokens - - 73.9% less - - - - - Recall payload proxy - 26 payload samples · 260 timed recalls - JSON shape · not MCP transport - Full proxy · 23,810 tokens - - Compact proxy · 10,202 tokens - - 57.15% less - - - - - External and model-dependent results - Run the locked public protocol and publish a redacted immutable artifact first - No public score, latency, consolidation, or productivity number is currently claimed - Evidence pending - - - - - ARTIFACT SHA-256 - c3a74f1770ad3f868f55261ba11680e2 - dadca30167082ac2cb6669f9e3bdfad2 - - - FIXTURE-SUITE SHA-256 - 4d7e40607319cd4bf8caee3897f1e416 - dbe5b81998b37a7e4839409ee2923537 - - - HARD CONTEXT CAP: 1,500 - 85.38 average · 108 max - observed context tokens in CodeMem - - - Separate fixture measurements; percentages are not additive. Exact commands and boundaries: BENCHMARKS.md diff --git a/engraphis/backends/sync_relay.py b/engraphis/backends/sync_relay.py index 8080b3e2..cd4b3458 100644 --- a/engraphis/backends/sync_relay.py +++ b/engraphis/backends/sync_relay.py @@ -17,6 +17,7 @@ import hmac import ipaddress import json +import logging import math import os import re @@ -29,6 +30,8 @@ from engraphis.hosted_client import build_pinned_https_opener from engraphis.private_state import UnsafeStateFile, atomic_private_text, read_private_text +logger = logging.getLogger(__name__) + MAX_RELAY_BUNDLE_BYTES = 64 * 1024 * 1024 MAX_RELAY_NAMES_BYTES = 1024 * 1024 # A 48 MiB raw compatibility response expands to roughly 64 MiB in base64. Keep a @@ -529,6 +532,12 @@ def __init__(self, base_url: str, workspace_id: str, *, if not math.isfinite(timeout_value) or timeout_value <= 0: raise ValueError("relay timeout must be a positive finite number") self.timeout = min(timeout_value, 300.0) + if timeout_value > 300.0: + logger.warning( + "relay timeout capped at 300s (requested %.0fs); " + "increase server-side limit if syncs time out", + timeout_value, + ) # ── HTTP plumbing ──────────────────────────────────────────────────────────────── def _url(self, suffix: str) -> str: diff --git a/engraphis/classic_assets/dashboard.css b/engraphis/classic_assets/dashboard.css index 6e85bc52..8961ed3d 100644 --- a/engraphis/classic_assets/dashboard.css +++ b/engraphis/classic_assets/dashboard.css @@ -270,9 +270,9 @@ body{ .update-banner .ub-dismiss:hover{color:var(--color-text)} .empty{padding:var(--space-6) 0;color:var(--color-text-dim);line-height:1.5;text-align:left} .empty .btn{margin-top:var(--space-3)} -.upgrade-panel{max-width:720px;padding:var(--space-6);border:var(--rule-strong) solid var(--color-accent);background:linear-gradient(135deg,var(--color-accent-bg),transparent 68%)}.upgrade-panel-kicker,.upgrade-panel-benefits-title{color:var(--color-accent);font-family:var(--mono);font-size:var(--text-micro);font-weight:600;letter-spacing:.08em}.upgrade-panel h2{margin:var(--space-2) 0;font-size:var(--text-xl);letter-spacing:-.02em;color:var(--color-text)}.upgrade-panel-lede{max-width:620px;margin:0;color:var(--color-text-dim)}.upgrade-panel-price{margin-top:var(--space-4);color:var(--color-text);font-size:var(--text-lg);font-weight:600}.upgrade-panel-benefits{margin-top:var(--space-4);padding-top:var(--space-4);border-top:var(--rule) solid var(--color-border)}.upgrade-panel-benefits ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--space-2) var(--space-5);margin:var(--space-3) 0 0;padding:0;list-style:none}.upgrade-panel-benefits li{display:flex;gap:var(--space-2);color:var(--color-text-muted);font-size:var(--text-sm);line-height:1.35}.upgrade-panel-benefits li::before{color:var(--color-accent);content:"✓"}.upgrade-panel-trial{margin:var(--space-4) 0 0;color:var(--color-text-dim);font-size:var(--text-sm)}.upgrade-panel-actions{display:flex;gap:var(--space-2);margin-top:var(--space-4);flex-wrap:wrap}.upgrade-panel-actions .btn{margin-top:0}@media(max-width:640px){.upgrade-panel{padding:var(--space-4)}.upgrade-panel-benefits ul{grid-template-columns:1fr}} +.upgrade-panel{max-width:720px;padding:calc(var(--space-6) * .6);border:var(--rule-strong) solid var(--color-accent);background:linear-gradient(135deg,var(--color-accent-bg),transparent 68%)}.upgrade-panel-kicker,.upgrade-panel-benefits-title{color:var(--color-accent);font-family:var(--mono);font-size:var(--text-micro);font-weight:600;letter-spacing:.08em}.upgrade-panel h2{margin:calc(var(--space-2) * .6) 0;font-size:var(--text-xl);letter-spacing:-.02em;color:var(--color-text)}.upgrade-panel-lede{max-width:620px;margin:0;color:var(--color-text-dim)}.upgrade-panel-price{margin-top:calc(var(--space-4) * .6);color:var(--color-text);font-size:var(--text-lg);font-weight:600}.upgrade-panel-benefits{margin-top:calc(var(--space-4) * .6);padding-top:calc(var(--space-4) * .6);border-top:var(--rule) solid var(--color-border)}.upgrade-panel-benefits ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:calc(var(--space-2) * .6) calc(var(--space-5) * .6);margin:calc(var(--space-3) * .6) 0 0;padding:0;list-style:none}.upgrade-panel-benefits li{display:flex;gap:calc(var(--space-2) * .6);color:var(--color-text-muted);font-size:var(--text-sm);line-height:1.35}.upgrade-panel-benefits li::before{color:var(--color-accent);content:"✓"}.upgrade-panel-trial{margin:calc(var(--space-4) * .6) 0 0;color:var(--color-text-dim);font-size:var(--text-sm)}.upgrade-panel-actions{display:flex;gap:calc(var(--space-2) * .6);margin-top:calc(var(--space-4) * .6);flex-wrap:wrap}.upgrade-panel-actions .btn{margin-top:0}@media(max-width:640px){.upgrade-panel{padding:calc(var(--space-4) * .6)}.upgrade-panel-benefits ul{grid-template-columns:1fr}} .pro-support-copy{margin-top:10px;padding:10px 12px;border-left:2px solid var(--color-accent);background:var(--color-accent-bg);color:var(--color-text-dim);font-size:var(--text-sm);line-height:1.45}.pro-support-copy strong{color:var(--color-text)} -.hosted-opportunity{display:grid;max-width:900px;grid-template-columns:minmax(0,1.2fr) minmax(260px,.8fr);gap:var(--space-5);padding:var(--space-6);border:var(--rule-strong) solid var(--color-accent);background:linear-gradient(135deg,var(--color-accent-bg),transparent 65%)}.hosted-opportunity-kicker,.hosted-opportunity-preview-label,.hosted-opportunity-card span{color:var(--color-accent);font-family:var(--mono);font-size:var(--text-micro);font-weight:600;letter-spacing:.08em}.hosted-opportunity-kicker span{color:var(--color-text-dim)}.hosted-opportunity h2{max-width:14ch;margin:var(--space-2) 0 var(--space-3);color:var(--color-text);font-family:var(--font-display);font-size:var(--text-xl);letter-spacing:-.02em;line-height:1.05}.hosted-opportunity-lede{max-width:58ch;margin:0;color:var(--color-text-muted);font-size:var(--text-sm);line-height:1.55}.hosted-opportunity-next{margin:var(--space-4) 0 0;color:var(--color-text);font-size:var(--text-sm);font-weight:600;line-height:1.45}.hosted-opportunity-actions{display:flex;gap:var(--space-2);margin-top:var(--space-4);flex-wrap:wrap}.hosted-opportunity-preview{display:flex;min-width:0;flex-direction:column;gap:var(--space-2);padding:var(--space-4);border:var(--rule) solid var(--color-border);background:color-mix(in srgb,var(--color-raised) 72%,transparent)}.hosted-opportunity-preview-label{margin-bottom:var(--space-1);color:var(--color-text-dim)}.hosted-opportunity-card{padding:var(--space-3);border-left:var(--rule-strong) solid var(--color-accent);background:var(--color-panel)}.hosted-opportunity-card p{margin:var(--space-2) 0 0;color:var(--color-text-muted);font-size:var(--text-xs);line-height:1.45}.hosted-opportunity-privacy{grid-column:1/-1;padding-top:var(--space-3);border-top:var(--rule) solid var(--color-border);color:var(--color-text-dim);font-size:var(--text-xs);line-height:1.5}.hosted-opportunity-privacy strong{color:var(--color-text-muted)}@media(max-width:720px){.hosted-opportunity{grid-template-columns:1fr;padding:var(--space-4)}.hosted-opportunity h2{max-width:none}.hosted-opportunity-privacy{grid-column:auto}} +.hosted-opportunity{display:grid;max-width:900px;grid-template-columns:minmax(0,1.2fr) minmax(260px,.8fr);gap:calc(var(--space-5) * .6);padding:calc(var(--space-6) * .6);border:var(--rule-strong) solid var(--color-accent);background:linear-gradient(135deg,var(--color-accent-bg),transparent 65%)}.hosted-opportunity-kicker,.hosted-opportunity-preview-label,.hosted-opportunity-card span{color:var(--color-accent);font-family:var(--mono);font-size:var(--text-micro);font-weight:600;letter-spacing:.08em}.hosted-opportunity-kicker span{color:var(--color-text-dim)}.hosted-opportunity h2{max-width:14ch;margin:calc(var(--space-2) * .6) 0 calc(var(--space-3) * .6);color:var(--color-text);font-family:var(--font-display);font-size:var(--text-xl);letter-spacing:-.02em;line-height:1.05}.hosted-opportunity-lede{max-width:58ch;margin:0;color:var(--color-text-muted);font-size:var(--text-sm);line-height:1.55}.hosted-opportunity-next{margin:calc(var(--space-4) * .6) 0 0;color:var(--color-text);font-size:var(--text-sm);font-weight:600;line-height:1.45}.hosted-opportunity-actions{display:flex;gap:calc(var(--space-2) * .6);margin-top:calc(var(--space-4) * .6);flex-wrap:wrap}.hosted-opportunity-preview{display:flex;min-width:0;flex-direction:column;gap:calc(var(--space-2) * .6);padding:calc(var(--space-4) * .6);border:var(--rule) solid var(--color-border);background:color-mix(in srgb,var(--color-raised) 72%,transparent)}.hosted-opportunity-preview-label{margin-bottom:calc(var(--space-1) * .6);color:var(--color-text-dim)}.hosted-opportunity-card{padding:calc(var(--space-3) * .6);border-left:var(--rule-strong) solid var(--color-accent);background:var(--color-panel)}.hosted-opportunity-card p{margin:calc(var(--space-2) * .6) 0 0;color:var(--color-text-muted);font-size:var(--text-xs);line-height:1.45}.hosted-opportunity-privacy{grid-column:1/-1;padding-top:calc(var(--space-3) * .6);border-top:var(--rule) solid var(--color-border);color:var(--color-text-dim);font-size:var(--text-xs);line-height:1.5}.hosted-opportunity-privacy strong{color:var(--color-text-muted)}@media(max-width:720px){.hosted-opportunity{grid-template-columns:1fr;padding:calc(var(--space-4) * .6)}.hosted-opportunity h2{max-width:none}.hosted-opportunity-privacy{grid-column:auto}} /* Route compositions inherit the same ledger geometry. */ #view-overview{padding-top:calc(var(--space-6) / 2)} @@ -716,6 +716,9 @@ body{ [data-csp-style="s197"]{width:100%;justify-content:flex-start;border-radius:7px;margin-bottom:2px} [data-csp-style="s198"]{background:var(--color-text-dim,#888)} .is-hidden{display:none!important}.is-flex{display:flex!important}.is-block{display:block!important}.is-inline-flex{display:inline-flex!important}.theme-menu.is-open{display:block}.health-ok{background:var(--green)!important}.health-error{background:var(--red)!important}.cursor-pointer{cursor:pointer!important}.cursor-grab{cursor:grab!important}.tone-red{color:var(--red)!important}.tone-green{color:var(--green)!important}.tone-muted{color:var(--text-muted)!important}.is-disabled-visual{opacity:.7} +.deployment-mode{display:inline-block;padding:1px 5px;border-radius:3px;font-size:9px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;margin-left:4px} +.deployment-mode.mode-local{background:#166534;color:#bbf7d0;border:1px solid #22c55e} +.deployment-mode.mode-hosted{background:#1e3a5f;color:#93c5fd;border:1px solid #3b82f6} .graph-network.graph-style-galaxy{background:radial-gradient(58% 50% at 24% 22%,rgba(126,64,208,.30),transparent 66%),radial-gradient(52% 58% at 82% 78%,rgba(220,72,164,.20),transparent 68%),radial-gradient(46% 52% at 62% 42%,rgba(58,120,224,.16),transparent 70%),#06040f}.graph-network.graph-style-solar{background:radial-gradient(38% 46% at 50% 50%,rgba(255,155,58,.17),transparent 60%),radial-gradient(88% 88% at 50% 50%,rgba(112,48,15,.18),transparent 82%),#080504}.graph-network.graph-style-cyber{background:linear-gradient(rgba(34,224,255,.055) 1px,transparent 1px) 0 0/30px 30px,linear-gradient(90deg,rgba(34,224,255,.055) 1px,transparent 1px) 0 0/30px 30px,radial-gradient(72% 60% at 50% 0%,rgba(255,62,165,.12),transparent 72%),#050810} [data-graph-node-type="person_or_concept"]{background:var(--entity-concept);color:var(--entity-concept)}[data-graph-node-type="mention"]{background:var(--entity-mention);color:var(--entity-mention)}[data-graph-node-type="hashtag"]{background:var(--entity-hashtag);color:var(--entity-hashtag)}[data-graph-node-type="email"]{background:var(--entity-email);color:var(--entity-email)}[data-graph-node-type="organization"]{background:var(--entity-organization);color:var(--entity-organization)}[data-graph-node-type="location"]{background:var(--entity-location);color:var(--entity-location)} .graph-tone-blue{color:var(--blue)}.graph-tone-cyan{color:var(--cyan)}.graph-tone-green{color:var(--green)}.graph-tone-dim{color:var(--text-dim)} @@ -756,8 +759,4 @@ progress.graph-degree[data-graph-node-type="person_or_concept"]::-webkit-progres #graph-net[data-graph-style="cyber"]{background:linear-gradient(rgba(34,224,255,.055) 1px,transparent 1px) 0 0/30px 30px,linear-gradient(90deg,rgba(34,224,255,.055) 1px,transparent 1px) 0 0/30px 30px,radial-gradient(72% 60% at 50% 0%,rgba(255,62,165,.12),transparent 72%),#050810} #graph-net.engraphis-graph-node-hover{cursor:pointer} #graph-net:not(.engraphis-graph-node-hover){cursor:grab} -.savings-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin:8px 0}.savings-number{margin:0;font-variant-numeric:tabular-nums}.savings-unit{color:var(--text-dim);font-size:12px}.savings-rate{display:flex;flex-direction:column;align-items:flex-end;gap:2px;text-align:right}.savings-rate strong{color:var(--green);font-size:20px;line-height:1;font-variant-numeric:tabular-nums}.savings-rate span{color:var(--text-dim);font-size:11px}.savings-progress{display:block;width:100%;height:7px;margin:0 0 8px;appearance:none;border:0;border-radius:999px;background:var(--surface2)}.savings-progress::-webkit-progress-bar{border-radius:999px;background:var(--surface2)}.savings-progress::-webkit-progress-value{border-radius:999px;background:var(--green)}.savings-progress::-moz-progress-bar{border-radius:999px;background:var(--green)}.savings-summary{margin:0;color:var(--text-muted);font-size:12px} .skip-link{position:fixed;top:8px;left:8px;z-index:1000;padding:8px 12px;border-radius:4px;background:var(--accent);color:var(--bg);transform:translateY(-150%)}.skip-link:focus{transform:translateY(0)} -#ov-savings .savings-hero{flex-wrap:wrap} -#ov-savings .cfg-row>span:last-child{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px} -@media(max-width:520px){#ov-savings .savings-hero{align-items:flex-start;flex-direction:column;gap:8px}#ov-savings .savings-rate{align-items:flex-start;text-align:left}#ov-savings .cfg-row{align-items:flex-start;flex-direction:column}#ov-savings .cfg-row>span:last-child{justify-content:flex-start}} diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index 282692a5..4c845af3 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -1,5 +1,5 @@ const API=location.origin+'/api',TRIAL_DAYS=3; -let WS=null, WORKSPACES=[], LIC=null; +let WS=null, WORKSPACES=[], LIC=null, RELEASE_VERSION=''; const TITLES={overview:'Overview',recall:'Recall',memories:'Memories','mem-editor':'Memory',proactive:'Proactive recall',why:'Why',timeline:'Timeline',audit:'Audit trail',graph:'Knowledge Graph',analytics:'Hosted Analytics',health:'Memory Health',consolidate:'Consolidate',automation:'Hosted Automation',workspaces:'Workspaces',team:'Team Cloud',settings:'Settings'}; const ROUTE_SECTIONS={overview:'Operate',recall:'Operate',memories:'Operate','mem-editor':'Operate',proactive:'Operate',why:'History',timeline:'History',audit:'History',graph:'Relations',analytics:'Relations',health:'Relations',consolidate:'Engine',automation:'Engine',workspaces:'Operate',team:'Engine',settings:'Engine'}; /* Per-view subtitle rendered in the topbar next to the view name. The body no longer @@ -133,22 +133,25 @@ async function loadWorkspaceList(){const d=await api('/workspaces');WORKSPACES=d /* overview */ function formatTokenCount(value){return Math.max(0,Math.round(Number(value)||0)).toLocaleString()} -function savingsPercent(value){return Math.max(0,Math.min(100,Number(value)*100||0))} -function savingsCount(value){return Math.max(0,Math.round(Number(value)||0))} -function savingsNode(tag,cls,text){const n=document.createElement(tag);if(cls)n.className=cls;if(text!=null)n.textContent=text;return n} -function renderOverviewSavings(data,error){ - const el=document.getElementById('ov-savings'); - if(!el)return; - el.replaceChildren(); - if(error){el.append(savingsNode('div','empty','Savings estimate unavailable.'));return} - const e=(data&&data.estimated)||{},eligible=savingsCount(e.eligible_receipt_count),excluded=savingsCount(e.excluded_receipt_count)+savingsCount(e.unclassified_receipt_count)+savingsCount(e.invalid_estimate_count),saved=Number(e.saved_tokens)||0,ratio=Number(e.savings_ratio)||0,counters=Array.isArray(e.by_token_counter)?e.by_token_counter:[]; - if(!eligible){el.append(savingsNode('div','empty','No receipt-backed context savings yet.'),savingsNode('div','field-hint','Eligible deliveries will appear after adaptive context or context-delivery calls. '+excluded+' excluded or unclassified '+(excluded===1?'delivery':'deliveries')+'.'),savingsNode('div','field-hint','Measures estimated prompt-context reduction; it does not measure provider billing.'));return} - const counter=counters.length===1?'Counter: '+(counters[0].token_counter||'unknown'):counters.length?counters.length+' token counters (kept separate)':'Counter: unknown',pct=savingsPercent(ratio),pctLabel=pct.toFixed(1); - const hero=savingsNode('div','savings-hero'),total=savingsNode('div'),rate=savingsNode('div','savings-rate'),progress=document.createElement('progress'); - total.append(savingsNode('div','stat-val savings-number',formatTokenCount(saved)),savingsNode('div','savings-unit','tokens avoided'));rate.append(savingsNode('strong','',pctLabel+'%'),savingsNode('span','','estimated reduction'));hero.append(total,rate);progress.className='savings-progress';progress.max=100;progress.value=pct;progress.setAttribute('aria-label',pctLabel+'% estimated context reduction'); - el.append(hero,progress,savingsNode('div','savings-summary','Across '+eligible+' eligible context deliveries'),savingsNode('div','field-hint','Baseline '+formatTokenCount(e.baseline_tokens)+' → emitted '+formatTokenCount(e.emitted_tokens)+' · confidence: '+(e.confidence||'unknown')),savingsNode('div','field-hint',counter+(excluded?' · '+excluded+' excluded/unclassified':'')),savingsNode('div','field-hint','Measures estimated prompt-context reduction; it does not measure provider billing.')); -} -async function loadOverview(){try{const st=await api('/stats?workspace='+encodeURIComponent(WS||''));setViewDesc('overview',(st.memories||0)+' memories · '+(st.workspaces||0)+' workspaces');const cards=[['Memories',st.memories],['Live rows',st.total_rows],['Workspaces',st.workspaces],['Sessions',st.sessions]];document.getElementById('stat-grid').innerHTML=cards.map(c=>`
${c[1]!=null?c[1]:'—'}
${c[0]}
`).join('');document.getElementById('nav-mem-count').textContent=st.memories||'';const bt=st.by_type||{};const tot=Object.values(bt).reduce((a,b)=>a+b,0)||1;document.getElementById('ov-types').innerHTML=Object.keys(bt).length?Object.entries(bt).map(([k,v])=>`
${esc(k)}
${v}
`).join(''):'
No memories
';try{renderOverviewSavings(await api('/context-savings?workspace='+encodeURIComponent(WS||'')))}catch(_err){renderOverviewSavings(null,true)}loadOverviewAnalytics()}catch(e){const msg='Overview unavailable: '+e.message;setViewDesc('overview',msg);document.getElementById('stat-grid').innerHTML='
'+esc(msg)+'
';document.getElementById('ov-types').innerHTML='
Memory types could not be loaded.
';document.getElementById('ov-savings').innerHTML='
Savings estimate could not be loaded.
';document.getElementById('ov-analytics').innerHTML='
Analytics could not be loaded.
';toast(msg,'err')}} +async function loadOverview(){ + try{ + const st=await api('/stats?workspace='+encodeURIComponent(WS||'')); + setViewDesc('overview',(st.memories||0)+' memories · '+(st.workspaces||0)+' workspaces'); + const cards=[['Memories',st.memories],['Live rows',st.total_rows],['Workspaces',st.workspaces],['Sessions',st.sessions]]; + document.getElementById('stat-grid').innerHTML=cards.map(c=>`
${c[1]!=null?c[1]:'—'}
${c[0]}
`).join(''); + document.getElementById('nav-mem-count').textContent=st.memories||''; + const bt=st.by_type||{},tot=Object.values(bt).reduce((a,b)=>a+b,0)||1; + document.getElementById('ov-types').innerHTML=Object.keys(bt).length?Object.entries(bt).map(([k,v])=>`
${esc(k)}
${v}
`).join(''):'
No memories
'; + loadOverviewAnalytics(); + }catch(e){ + const msg='Overview unavailable: '+e.message; + setViewDesc('overview',msg); + document.getElementById('stat-grid').innerHTML='
'+esc(msg)+'
'; + document.getElementById('ov-types').innerHTML='
Memory types could not be loaded.
'; + document.getElementById('ov-analytics').innerHTML='
Analytics could not be loaded.
'; + toast(msg,'err'); + } +} async function loadOverviewAnalytics(){ const el=document.getElementById('ov-analytics'),lock=document.getElementById('ov-lock'); try{ @@ -423,11 +426,10 @@ async function doTimeline(){ /* audit */ async function loadAudit(){const el=document.getElementById('audit-body');el.innerHTML='
';try{const d=await api('/audit?workspace='+encodeURIComponent(WS||'')+'&limit=200');const rows=d.entries||d.audit||[];if(!rows.length){el.innerHTML='
No governance actions recorded.
';return}el.innerHTML='
'+rows.map(r=>`
${esc(r.action||r.op||r.kind||'edit')}${esc(r.memory_id||r.target||r.detail||'')}${esc(r.actor||'')}${r.ts||r.at?fmtRel(r.ts||r.at):''}
`).join('')+'
'}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} -async function loadReceipts(){const el=document.getElementById('audit-body');el.innerHTML='
';try{const q='workspace='+encodeURIComponent(WS||'');const [d,v,s]=await Promise.all([api('/receipts?'+q+'&limit=500'),api('/receipts/verify?'+q),api('/context-savings?'+q)]);const rows=d.entries||[],counters=s.by_token_counter||[];const savings=counters.map(x=>`
${esc(x.token_counter||'unknown')}${x.context_tokens||0} packed / ${x.source_tokens||0} retrieved-source tokens; ${x.saved_tokens||0} not injected (${((x.savings_ratio||0)*100).toFixed(1)}%)
`).join('');const savingCard=`
Packed context efficiency
${s.savings_receipt_count||0} packed recalls; this measures retrieved source versus injected context, grouped by token counter.
${savings||'
No complete context-usage receipts yet.
'}
`;el.innerHTML=savingCard+`
Receipt chain ${v.valid?'verified':'invalid'}
${v.count||0} receipts · head ${esc((v.head||'').slice(0,24))}
`+(rows.length?'
'+rows.map(r=>`
${esc(r.operation||'operation')}${esc((r.hash||'').slice(0,20))} · ${esc(r.status||'ok')} · ${r.target_count||0} target(s)${r.ts_ms?fmtRel(r.ts_ms/1000):''}
`).join('')+'
':'
No receipts yet.
')}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} async function downloadReceipts(){try{const d=await api('/receipts/export?workspace='+encodeURIComponent(WS||''));const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='engraphis-receipts-'+(WS||'workspace')+'.json';a.click();URL.revokeObjectURL(a.href);toast('Privacy-safe receipts exported','ok')}catch(e){toast(e.message,'err')}} let SAVINGS_PRESET='all'; -function savingsPresetQuery(){const p=new URLSearchParams({workspace:WS||''});if(SAVINGS_PRESET==='current')p.set('release_version','1.6');if(SAVINGS_PRESET==='7d')p.set('from_ts',String(Date.now()/1000-604800));return p.toString()} +function savingsPresetQuery(){const p=new URLSearchParams({workspace:WS||''});if(SAVINGS_PRESET==='current'&&RELEASE_VERSION)p.set('release_version',RELEASE_VERSION);if(SAVINGS_PRESET==='7d')p.set('from_ts',String(Date.now()/1000-604800));return p.toString()} function renderSavingsDetail(s){const e=(s&&s.estimated)||{},eligible=Number(e.eligible_receipt_count)||0,excluded=(Number(e.excluded_receipt_count)||0)+(Number(e.unclassified_receipt_count)||0)+(Number(e.invalid_estimate_count)||0),basisRows=(e.by_basis||[]).map(x=>'
'+esc((x.basis||'unclassified').replaceAll('_',' '))+' · '+esc(x.confidence||'unknown')+''+formatTokenCount(x.baseline_tokens)+' → '+formatTokenCount(x.emitted_tokens)+' · '+formatTokenCount(x.saved_tokens)+' saved ('+(x.receipt_count||0)+' delivery)
').join(''),counterRows=(e.by_token_counter||[]).map(x=>'
'+esc(x.token_counter||'unknown')+''+formatTokenCount(x.saved_tokens)+' saved · '+(x.receipt_count||0)+' eligible delivery
').join(''),preset=SAVINGS_PRESET==='current'?'Current release':SAVINGS_PRESET==='7d'?'Last 7 days':SAVINGS_PRESET==='since'?'Since tracking started':'All time';const buttons=['since','current','7d','all'].map(x=>'').join('');return '
Estimated context saved
View'+buttons+'
'+(eligible?'
'+formatTokenCount(e.saved_tokens)+' tokens
Baseline '+formatTokenCount(e.baseline_tokens)+' → emitted '+formatTokenCount(e.emitted_tokens)+' · '+(Number(e.savings_ratio||0)*100).toFixed(1)+'% estimated reduction
'+eligible+' eligible deliveries · confidence: '+esc(e.confidence||'unknown')+' · range: '+preset+'
'+(basisRows||'
No basis breakdown available.
')+(counterRows?'
Token counters
'+counterRows:''):'
No eligible estimates in this range.
')+'
'+excluded+' excluded or unclassified delivery(s). Measures estimated prompt-context reduction; it does not measure provider billing.
'} async function loadReceipts(){const el=document.getElementById('audit-body');el.innerHTML='
';try{if(!window.__savingsPresetBound){window.__savingsPresetBound=true;document.addEventListener('click',function(ev){const button=ev.target.closest('[data-savings-preset]');if(!button)return;SAVINGS_PRESET=button.getAttribute('data-savings-preset')||'all';loadReceipts()})}const q='workspace='+encodeURIComponent(WS||''),sq=savingsPresetQuery();const [d,v,s]=await Promise.all([api('/receipts?'+q+'&limit=500'),api('/receipts/verify?'+q),api('/context-savings?'+sq)]);const rows=d.entries||[],packed=(s.by_token_counter||[]).map(x=>'
'+esc(x.token_counter||'unknown')+''+formatTokenCount(x.context_tokens)+' packed / '+formatTokenCount(x.source_tokens)+' source · '+formatTokenCount(x.saved_tokens)+' legacy saved
').join('');const packedCard='
Packed context accounting
Packing savings compare retrieved source tokens with emitted context. They are not added again to adaptive history savings.
'+(packed||'
No complete context-usage receipts yet.
')+'
';el.innerHTML=renderSavingsDetail(s)+packedCard+'
Receipt chain '+(v.valid?'verified':'invalid')+'
'+(v.count||0)+' receipts · head '+esc((v.head||'').slice(0,24))+'
'+(rows.length?'
'+rows.map(r=>'
'+esc(r.operation||'operation')+''+esc((r.hash||'').slice(0,20))+' · '+esc(r.status||'ok')+' · '+(r.target_count||0)+' target(s)'+(r.ts_ms?fmtRel(r.ts_ms/1000):'')+'
').join('')+'
':'
No receipts yet.
')}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} @@ -567,7 +569,7 @@ async function exportWorkspace(){try{const d=await api('/export?workspace='+enco async function loadTeam(){const el=document.getElementById('team-body'),teamCta=hostedCta('team','team_tab');try{const st=await api('/auth/state');if(teamCta.href==='#'&&st&&st.cloud_url)teamCta.href=safeUrl(st.cloud_url)}catch(e){}el.innerHTML=`
Engraphis Team Cloud HOSTED
Organizations, invitations, roles, named seats, scoped device credentials, and team audit run on the private hosted service. This local dashboard is intentionally single-user.
${esc(teamTeaserNote())} Private-service account grace is capped at 24 hours, never extends Team access, and never restricts the free local core.
${ctaLinkHtml(teamCta,'btn btn-primary btn-sm','team_tab')}
`} /* health + settings */ function connectionContext(){const host=(location.hostname||'').toLowerCase();return host==='localhost'||host==='127.0.0.1'||host==='::1'||host.endsWith('.localhost')?'Local engine':'Remote customer node'} -async function checkHealth(){const label=connectionContext();try{await api('/health');const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-ok');d.classList.remove('health-error')}if(t)t.textContent=label+' connected'}catch(e){const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-error');d.classList.remove('health-ok')}if(t)t.textContent=label+' unavailable'}} +async function checkHealth(){const label=connectionContext();try{await api('/health');const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-ok');d.classList.remove('health-error')}if(t)t.textContent=label+' connected'}catch(e){const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-error');d.classList.remove('health-ok')}if(t)t.textContent=label+' unavailable'}try{const auth=await api('/auth/state');const m=document.getElementById('deployment-mode-indicator');if(m){const isLocal=auth.deployment_mode==='local';m.textContent=isLocal?'LOCAL':'HOSTED';m.title=isLocal?'Local mode: no hosted cloud configured':'Hosted mode: connected to Engraphis Cloud';m.className='deployment-mode '+(isLocal?'mode-local':'mode-hosted');m.hidden=false}}catch(e){}} function loadSettings(){loadLicense();loadSyncStatus();loadHostedAgentAccess();loadLlmStatus();const s=document.getElementById('cfg-store');if(s)s.textContent=location.host;api('/info').then(function(d){var v=document.getElementById('cfg-version');if(v&&d&&d.version)v.textContent=d.version}).catch(function(){})} async function loadLlmStatus(){const el=document.getElementById('llm-body');if(!el)return;try{const st=await api('/llm/status');const ok=st.configured;const badge=ok?'configured':'not configured';const keyLine=st.key_set?'API key set ✓':'No API key set';let modelSel='';let provSel='';el.innerHTML=`
Provider · Model${badge}
${provSel}${modelSel}
${keyLine} · extractor: ${esc(st.extractor)}
Add this to your .env and restart Engraphis:
LLM extraction${st.extractor_enabled?'ON':'OFF'}
While ON, ingested memory content is sent to your LLM provider for schema-validated extraction. OFF disables extraction transfers only; retention supervision is configured separately.
`}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} @@ -805,7 +807,7 @@ async function loadLegacyGraph(){ GRESIZEFRAME=requestAnimationFrame(()=>{GRESIZEFRAME=0;const element=document.getElementById('graph-net');if(GRAPH_ENGINE)GRAPH_ENGINE.resize();else if(FG&&element)FG.width(element.clientWidth).height(element.clientHeight)}); }); } - const layerInputs=Array.from(document.querySelectorAll('#graph-layer-filters input')),selectedLayers=layerInputs.filter(input=>input.checked).map(input=>input.value),layerFilter=selectedLayers.length===layerInputs.length?'':'&layers='+encodeURIComponent(selectedLayers.join(',')),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim(),showUnlinked=GRAPH_FULL||!!document.getElementById('graph-show-iso').checked,graphLimit=GRAPH_FULL?20000:320,graphScope=GRAPH_FULL?'&full=true':(showUnlinked?'':'&connected_only=true'); + const layerInputs=Array.from(document.querySelectorAll('#graph-layer-filters input')),selectedLayers=layerInputs.filter(input=>input.checked).map(input=>input.value),layerFilter=selectedLayers.length===layerInputs.length?'':'&layers='+encodeURIComponent(selectedLayers.join(',')),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim(),showUnlinked=GRAPH_FULL||!!document.getElementById('graph-show-iso').checked,graphLimit=GRAPH_FULL?40000:320,graphScope=GRAPH_FULL?'&full=true':(showUnlinked?'':'&connected_only=true'); try{ GRAPH=await api('/graph?workspace='+encodeURIComponent(WS||'')+layerFilter+'&include_code='+(includeCode?'true':'false')+'&limit='+graphLimit+graphScope+(repo?'&repo='+encodeURIComponent(repo):'')); renderGraphSide();graphRender(); @@ -1197,7 +1199,7 @@ function loadGraphEngine(){ if(GRAPH_ENGINE_LOADING)return GRAPH_ENGINE_LOADING; GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ const script=document.createElement('script'); - script.src='/v2-assets/engraphis-graph.js?v=20260809-physics-guard'; + script.src='/v2-assets/engraphis-graph.js?v=20260812-hierarchical-black-hole-orbits-1'; /* A 200 that never registers the global is a corrupt/truncated asset, not a success — resolving there would hand graphRenderEngine() an undefined EngraphisGraph. */ script.onload=()=>{typeof EngraphisGraph==='undefined'?reject(new Error('Graph engine asset loaded without registering EngraphisGraph')):resolve()}; @@ -1622,7 +1624,7 @@ function renderUpdateBanner(u){ const btn=el.querySelector('.ub-dismiss'); if(btn)btn.addEventListener('click',function(){try{localStorage.setItem('engraphis-update-dismissed',u.latest)}catch(e){}el.hidden=true;el.textContent=''}); } -async function boot(){try{const b=await api('/bootstrap');LIC=b.license;renderSemBanner(b.embedder);renderUpdateBanner(b.update);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth()}catch(e){if(e.status===401&&await authenticateBrowser()){window.location.reload();return}const msg=e.status===401?'Local API token required.':'Boot failed: '+e.message;document.getElementById('stat-grid').innerHTML='
'+esc(msg)+'
';document.getElementById('ov-types').innerHTML='
Dashboard data could not be loaded.
';document.getElementById('ov-analytics').innerHTML='
Dashboard data could not be loaded.
';toast(msg,'err')}} +async function boot(){try{const b=await api('/bootstrap');LIC=b.license;RELEASE_VERSION=typeof b.version==='string'?b.version.trim():'';renderSemBanner(b.embedder);renderUpdateBanner(b.update);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth()}catch(e){if(e.status===401&&await authenticateBrowser()){window.location.reload();return}const msg=e.status===401?'Local API token required.':'Boot failed: '+e.message;document.getElementById('stat-grid').innerHTML='
'+esc(msg)+'
';document.getElementById('ov-types').innerHTML='
Dashboard data could not be loaded.
';document.getElementById('ov-analytics').innerHTML='
Dashboard data could not be loaded.
';toast(msg,'err')}} initTheme(); initDashboard(); boot(); @@ -1754,4 +1756,4 @@ h143:function(event){graphExplorerMore('nodes')}, h144:function(event){graphExplorerMore('edges')}, h145:function(event){boot()}, }); -for(const type of ['click','keydown','input','change','dragover','dragleave','drop','dragstart','dragend']){document.addEventListener(type,function(event){const target=event.target instanceof Element?event.target.closest('[data-on'+type+']'):null;if(!target||!document.documentElement.contains(target))return;const handler=CSP_EVENT_HANDLERS[target.getAttribute('data-on'+type)];if(!handler)return;const result=handler.call(target,event);if(result===false){event.preventDefault();event.stopPropagation()}},false)} +for(const type of ['click','keydown','input','change','dragover','dragleave','drop','dragstart','dragend']){document.addEventListener(type,function(event){const target=event.target instanceof Element?event.target.closest('[data-on'+type+']'):null;if(!target||!document.documentElement.contains(target))return;const handler=CSP_EVENT_HANDLERS[target.getAttribute('data-on'+type)];if(!handler)return;const result=handler.call(target,event);if(result===false){event.preventDefault();event.stopPropagation()}},false)} diff --git a/engraphis/classic_assets/index.html b/engraphis/classic_assets/index.html index 87d6adf2..64da53ee 100644 --- a/engraphis/classic_assets/index.html +++ b/engraphis/classic_assets/index.html @@ -6,7 +6,7 @@ Engraphis - + @@ -49,7 +49,7 @@ - + @@ -70,7 +70,6 @@
Memory types
-
Estimated context saved
Loading receipt-backed estimate…
Analytics
Loading…
@@ -154,7 +153,7 @@
- +
Layout
@@ -351,6 +350,6 @@ graph view. dashboard.js fetches both on demand from graphRender(); see loadForceGraph() and loadGraphEngine(). scripts/externalize_dashboard_assets.py enforces both halves: they stay out of this file, and the lazy references still have to resolve. --> - + diff --git a/engraphis/cloud_authz.py b/engraphis/cloud_authz.py new file mode 100644 index 00000000..a834518a --- /dev/null +++ b/engraphis/cloud_authz.py @@ -0,0 +1,138 @@ +"""Client-side interpretation of Engraphis Cloud authorization denials. + +The control plane returns structured JSON bodies on 401/402/403 with a +``reason`` field. This module maps those reasons to user-facing messages +and UI actions (e.g. showing an upgrade prompt). All actual authorization +decisions are made server-side; this module only translates the verdict for +the local dashboard's presentation layer. + +Never trust these values for access control. They are UX hints derived from +an authoritative server response that has already been validated by +``cloud_session`` and ``cloud_features``. +""" +from __future__ import annotations + +import logging +from typing import Any, Optional + +logger = logging.getLogger("engraphis.cloud_authz") + +# Machine-readable reason codes the control plane may return. +# Keep in sync with engraphis_cloud/services/team_authorization.py and +# engraphis_cloud/api/control/_helpers.py. +REASON_ORG_SUSPENDED = "organization_suspended" +REASON_ENTITLEMENT_EXPIRED = "entitlement_expired" +REASON_SEAT_LIMIT_EXCEEDED = "seat_limit_exceeded" +REASON_CROSS_ORG_DENIED = "cross_org_access_denied" +REASON_DEPLOYMENT_TOKEN_ESCALATION = "deployment_token_cannot_escalate" +REASON_INSUFFICIENT_ROLE = "insufficient_role" +REASON_MEMBER_DISABLED = "member_disabled" +REASON_TOKEN_STALE = "token_stale" + +_DENIAL_MESSAGES: dict[str, str] = { + REASON_ORG_SUSPENDED: ( + "This organization has been suspended. Contact support to restore access." + ), + REASON_ENTITLEMENT_EXPIRED: ( + "Your subscription has expired. Renew your plan to continue using Team features." + ), + REASON_SEAT_LIMIT_EXCEEDED: ( + "All named seats are in use. Upgrade your plan to add more team members." + ), + REASON_CROSS_ORG_DENIED: ( + "Access denied: this credential belongs to a different organization." + ), + REASON_DEPLOYMENT_TOKEN_ESCALATION: ( + "Deployment tokens cannot perform administrative actions. " + "Sign in with a member account instead." + ), + REASON_INSUFFICIENT_ROLE: ( + "You do not have permission to perform this action. " + "Ask an organization owner or administrator." + ), + REASON_MEMBER_DISABLED: ( + "Your account has been disabled. Contact your organization administrator." + ), + REASON_TOKEN_STALE: ( + "Your session is out of date. Please sign in again." + ), +} + +_UPGRADE_REASONS = frozenset({ + REASON_ENTITLEMENT_EXPIRED, + REASON_SEAT_LIMIT_EXCEEDED, +}) + + +def interpret_denial( + status_code: int, + body: Optional[dict[str, Any]], +) -> dict[str, Any]: + """Translate a cloud denial into a dashboard-friendly structure. + + Returns a dict with: + - ``message``: human-readable explanation + - ``reason``: machine-readable reason code (or ``"unknown"``) + - ``upgrade_url``: billing URL when applicable, else ``None`` + - ``retryable``: whether the caller should retry (only for stale tokens) + + Never raises. Malformed or unexpected inputs degrade to a generic denial. + """ + if not isinstance(body, dict): + return _generic_denial(status_code) + + reason = str(body.get("reason") or "").strip() + if not reason: + # Fall back to legacy string-detail format. + detail = body.get("detail") or body.get("error") or "" + if isinstance(detail, dict): + reason = str(detail.get("reason") or "") + elif isinstance(detail, str): + reason = detail + + message = _DENIAL_MESSAGES.get(reason) + if message is None: + logger.warning( + "unrecognized cloud denial reason=%r status=%s", + reason, status_code, + ) + return _generic_denial(status_code) + + result: dict[str, Any] = { + "message": message, + "reason": reason, + "upgrade_url": None, + "retryable": reason == REASON_TOKEN_STALE, + } + # Prefer the server-provided upgrade_url; fall back to the known billing path. + upgrade_url = body.get("upgrade_url") + if upgrade_url and isinstance(upgrade_url, str): + result["upgrade_url"] = upgrade_url + elif reason in _UPGRADE_REASONS: + result["upgrade_url"] = "/billing/upgrade" + return result + + +def _generic_denial(status_code: int) -> dict[str, Any]: + """Fallback for unrecognized or malformed denial responses.""" + if status_code == 402: + message = "A paid subscription is required for this feature." + elif status_code == 401: + message = "Your session has expired. Please sign in again." + else: + message = "Access denied. Contact your organization administrator." + return { + "message": message, + "reason": "unknown", + "upgrade_url": None, + "retryable": False, + } + + +def is_authoritative_denial(status_code: int) -> bool: + """Return whether *status_code* represents a definitive cloud authorization verdict. + + These statuses settle the local entitlement cache immediately rather than + being treated as transient failures. + """ + return status_code in {401, 402, 403} diff --git a/engraphis/config.py b/engraphis/config.py index d0c92531..3fb3570c 100644 --- a/engraphis/config.py +++ b/engraphis/config.py @@ -16,6 +16,7 @@ from dataclasses import dataclass, field from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Optional +from urllib.parse import parse_qsl from engraphis.private_state import ( UnsafeStateFile, @@ -360,14 +361,62 @@ def _lock_windows_migration_file(handle, msvcrt) -> None: time.sleep(_WINDOWS_LOCK_RETRY_SECONDS) +def _normalize_sqlite_lock_path(path_str: str) -> Optional[Path]: + """Return the physical path a SQLite target uses, or ``None`` for memory databases.""" + raw = str(path_str or "") + if not raw or raw == ":memory:": + return None + if not raw.startswith("file:"): + return Path(raw).expanduser().resolve() + + from urllib.parse import parse_qs, unquote, urlsplit + from urllib.request import url2pathname + + parsed = urlsplit(raw.replace("\\", "/")) + query = parse_qs(parsed.query) + uri_path = unquote(parsed.path) + if uri_path == ":memory:" or "memory" in query.get("mode", []): + return None + if not uri_path: + return None + physical = url2pathname(uri_path) + if parsed.netloc and parsed.netloc != "localhost": + physical = "//%s%s" % (parsed.netloc, physical) + return Path(physical).expanduser().resolve() + + @contextmanager def _migration_lock(target: Path): """Serialize first-run migration across processes without a third-party lock.""" - target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + resolved = _normalize_sqlite_lock_path(str(target)) + if resolved is None: + # In-memory or empty target: nothing to serialize on disk. + yield + return + target = resolved + # Only apply private permissions to a directory created for this database. + # Existing parents belong to the caller and may intentionally be shared with + # other databases or processes; changing them here is an unexpected mutation. try: - os.chmod(target.parent, 0o700) - except OSError: - pass + target.parent.mkdir(parents=True, exist_ok=False, mode=0o700) + except FileExistsError: + if not target.parent.is_dir(): + raise + else: + try: + # Use fd-based chmod to avoid TOCTOU symlink race: open the directory + # we just created with O_NOFOLLOW and fchmod the descriptor. + parent_fd = os.open( + str(target.parent), + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), + ) + except OSError: + pass + else: + try: + os.fchmod(parent_fd, 0o700) + finally: + os.close(parent_fd) lock_path = target.with_name(".%s.migration.lock" % target.name) expected = private_file_stat(lock_path, allow_missing=True) flags = os.O_RDWR | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) @@ -571,7 +620,49 @@ def _configured_db_path(root: Path = _PROJECT_ROOT) -> str: """Resolve an explicit override or prepare the safe installed default.""" configured = _env("ENGRAPHIS_DB_PATH", "") if configured: - return configured + # A relative path in the owner-private config must not follow whichever CWD + # happened to launch the dashboard, MCP server, or a desktop shortcut. Anchor + # it to the trusted config directory so every entrypoint opens the same file. + if configured in {":memory:", ""}: + return configured + if configured.startswith("file:"): + # A relative file URI such as file:data/engraphis.db must be anchored to + # the trusted config directory. Otherwise SQLite resolves it against the + # process CWD and different entry points can open different databases. + # Split query parameters before path resolution so Path does not treat + # ? as a literal filename character. + remainder = configured[len("file:"):] + if remainder.startswith(":memory:"): + return configured + path_part, sep, query_part = remainder.partition("?") + if sep and dict(parse_qsl(query_part, keep_blank_values=True)).get("mode") == "memory": + # Named shared-memory URIs are identities, not filesystem paths. Anchoring + # their relative-looking name to the config directory would make separate + # connections open different databases and break SQLite's shared cache. + return configured + if ( + not path_part + or path_part.startswith("/") + or path_part.startswith("\\") + or PureWindowsPath(path_part).is_absolute() + ): + return configured + anchor = str((_CONFIG_ENV_PATH.parent / path_part).resolve()) + return f"file:{anchor}" + (sep + query_part if sep else "") + configured_path = Path(configured).expanduser() + # Drive-relative Windows paths (e.g. C:data/foo.db) are neither absolute + # nor relative to the config directory; they resolve against the drive's + # current working directory, which is the expected behaviour. + if PureWindowsPath(configured).anchor and not PureWindowsPath(configured).is_absolute(): + return configured + + if (configured_path.is_absolute() + or PurePosixPath(configured).is_absolute() + or PureWindowsPath(configured).is_absolute()): + # Preserve explicit absolute spelling for compatibility with callers that + # intentionally use a POSIX-style path on Windows or a symlinked path. + return configured + return str((_CONFIG_ENV_PATH.parent / configured_path).resolve()) target = Path(_default_db_path(root)) parts = {p.lower() for p in root.parts} if "site-packages" in parts or "dist-packages" in parts: @@ -737,12 +828,12 @@ class Settings: default_factory=lambda: _parse_origins(_env("ENGRAPHIS_CORS_ORIGINS", ""), _env_int("ENGRAPHIS_PORT", 8700)) ) - # Optional server-side workspace binding — the hard multi-tenant isolation boundary. - # When non-empty, MemoryService refuses any read or write whose - # workspace is not in this comma-separated allow-list, so knowing or guessing a - # workspace name is not enough to reach it. Empty = unrestricted (single-tenant local). + # Kept as a compatibility attribute for callers that inspect Settings. Public + # entrypoints no longer read a process-wide workspace allow-list: workspace creation + # and selection are unrestricted by configuration. Deliberate tenant-bound services + # may still pass an allow-list directly to their service/store constructor. allowed_workspaces: list = field( - default_factory=lambda: _parse_csv(_env("ENGRAPHIS_WORKSPACES", "")) + default_factory=list ) # The public package is always the customer runtime. Hosted service roles are private. service_mode: str = field( @@ -776,7 +867,7 @@ class Settings: ) embed_dim: Optional[int] = field( default_factory=lambda: ( - _env_int("ENGRAPHIS_EMBED_DIM", 384) or None + None if _env("ENGRAPHIS_EMBED_DIM", "") == "0" else _env_int("ENGRAPHIS_EMBED_DIM", 384) ) ) @@ -915,13 +1006,71 @@ def _parse_origins(raw: str, port: int = 8700) -> list: def _parse_csv(raw: str) -> list: - """Generic comma-separated allow-list. Empty -> [] (no restriction).""" + """Parse a comma-separated compatibility value without enabling a global binding.""" return [item.strip() for item in raw.split(",") if item.strip()] settings = Settings() +#: Env vars whose presence indicates a hosted/cloud-connected deployment. +#: When ALL are absent, the installation is pure local mode. +_HOSTED_MODE_ENV_VARS = ( + "ENGRAPHIS_CLOUD_CONTROL_URL", + "ENGRAPHIS_CLOUD_COMPUTE_URL", + "ENGRAPHIS_CLOUD_ORGANIZATION_ID", + "ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL", + "ENGRAPHIS_CLOUD_ACCESS_TOKEN", + "ENGRAPHIS_CONTROL_PLANE_URL", + "ENGRAPHIS_HOSTED_MODE", +) + + +def deployment_mode() -> str: + """Return the current deployment mode: ``"local"`` or ``"hosted"``. + + Local mode is the default and activates when none of the hosted/cloud env vars + or validated persisted cloud session are present. Hosted mode activates when at + least one hosted env var is present, a saved cloud session is configured, or when + ``ENGRAPHIS_HOSTED_MODE=true`` is explicitly set. + + This function is the single authority for mode detection. All code that needs + to distinguish local from hosted installations MUST call this function rather + than checking env vars directly. + """ + hosted_override = os.environ.get("ENGRAPHIS_HOSTED_MODE", "").strip().lower() + if hosted_override in ("1", "true", "yes", "on"): + return "hosted" + if hosted_override in ("0", "false", "no", "off"): + return "local" + # A successful device connect persists the validated cloud session in the owner-only + # state directory. That session is intentionally usable without repeating bootstrap + # environment secrets, so deployment mode must recognize it on later process starts. + # State errors are handled as local mode here; cloud-session consumers surface the + # structured retryable error when they actually need the credential. + try: + from engraphis import cloud_session + + if cloud_session.configured(require_compute=False): + return "hosted" + except Exception: # noqa: BLE001 — mode detection must not break local startup + pass + for var in _HOSTED_MODE_ENV_VARS: + if os.environ.get(var, "").strip(): + return "hosted" + return "local" + + +def is_local_mode() -> bool: + """Return True when the installation is in pure local mode (no hosted features).""" + return deployment_mode() == "local" + + +def is_hosted_mode() -> bool: + """Return True when the installation has hosted/cloud features configured.""" + return deployment_mode() == "hosted" + + def canonicalize_relay_url(url: str) -> str: """Normalize a relay URL and migrate known retired vendor hosts.""" normalized = (url or "").strip().rstrip("/") diff --git a/engraphis/core/engine.py b/engraphis/core/engine.py index 55234721..5d2d9ad3 100644 --- a/engraphis/core/engine.py +++ b/engraphis/core/engine.py @@ -68,7 +68,12 @@ resolve, ) from engraphis.core.secrets import reject_secrets -from engraphis.core.store import Store, memory_matches_filter, now_ts +from engraphis.core.store import ( + Store, + _is_memory_database_path, + memory_matches_filter, + now_ts, +) from engraphis.core.textutil import estimate_tokens, jaccard, tokenize @@ -918,10 +923,7 @@ def remember_with_resolution(self, content: str, *, workspace_id: str, # pending evidence is stored passively and cannot reinforce or supersede it. trusted_write = prompt_eligible(provenance, write_metadata) text = f"{title}\n{content}" if title else content - persistent_store = ( - self.store.path != ":memory:" - and not self.store.path.startswith("file::memory:") - ) + persistent_store = not _is_memory_database_path(self.store.path) if not poisoning.quarantined and persistent_store: if not self.embedding_space: raise RuntimeError( @@ -2383,10 +2385,7 @@ def _relatedness(self, query: str, flt: SearchFilter, *, directly from ``Store.iter_vectors(..., include_invalid=True)`` instead. """ semantic_ready = bool(getattr(self.embedder, "supports_semantic_search", False)) - persistent_store = ( - self.store.path != ":memory:" - and not self.store.path.startswith("file::memory:") - ) + persistent_store = not _is_memory_database_path(self.store.path) if semantic_ready and persistent_store: # History helpers bypass RecallEngine's readiness gate and read the # portable vector mirror directly; never compare a query against a @@ -2532,18 +2531,65 @@ def secure_erase(self, memory_id: str, *, actor: str = "user") -> dict: but do not leave the local SQLite copy intact if that backend is unavailable; the returned status explicitly reports that incomplete external cleanup. """ - index_cleanup = "not_configured" - try: - self.index.delete([memory_id]) - index_cleanup = "deleted" - except Exception: # noqa: BLE001 - must still erase the authoritative local copy - index_cleanup = "failed" - result = self.store.secure_erase_memory(memory_id, actor=actor) + with self._write_lock: + target_ids = self.store.secure_erase_target_ids(memory_id) + index_cleanup = "not_configured" + + def delete_vectors(ids: list[str], *, in_store_transaction: bool = False) -> None: + if not ids: + return + if in_store_transaction and vector_index_shares_store_transaction( + self.index, self.store, + ): + self.index.delete(ids, commit=False) + else: + self.index.delete(ids) + + try: + delete_vectors(target_ids) + index_cleanup = "deleted" + except Exception: # noqa: BLE001 - must still erase the authoritative local copy + index_cleanup = "failed" + + # Serialize the final successor scan with the authoritative erase. Any + # successor that commits before BEGIN IMMEDIATE is acquired is included; + # a successor cannot commit between this scan and secure_erase_memory's + # destructive transaction. The external delete is intentionally performed + # while the Store transaction is held, so its final target set cannot go + # stale before the local rows are removed. + transaction_started = False + try: + if not self.store.conn.transaction_owned_by_current_thread(): + self.store.conn.execute("BEGIN IMMEDIATE") + transaction_started = True + refreshed = self.store.secure_erase_target_ids(memory_id) + new_ids = set(refreshed) - set(target_ids) + cleanup_ids = list(new_ids) if index_cleanup == "deleted" else [] + if cleanup_ids: + try: + delete_vectors(cleanup_ids, in_store_transaction=True) + except Exception: # noqa: BLE001 + index_cleanup = "partial" + target_ids = refreshed + result = self.store.secure_erase_memory( + memory_id, actor=actor, _target_ids=target_ids, + _defer_maintenance=transaction_started, + ) + if transaction_started and self.store.conn.transaction_owned_by_current_thread(): + self.store.conn.commit() + # Physical maintenance must run after the engine-owned transaction + # commits; VACUUM is invalid while the erase transaction is active. + result["maintenance"] = self.store.run_secure_erase_maintenance() + except BaseException: + if transaction_started and self.store.conn.transaction_owned_by_current_thread(): + self.store.conn.rollback() + raise result["vector_index_cleanup"] = index_cleanup - if index_cleanup == "failed": + if index_cleanup in {"failed", "partial"}: result["external_index_limitation"] = ( - "The configured vector index did not confirm deletion; remediate that backend " - "separately before treating the secret as fully erased." + "The configured vector index did not confirm deletion of every " + "successor; remediate that backend separately before treating the " + "secret as fully erased." ) return result diff --git a/engraphis/core/graph_scene.py b/engraphis/core/graph_scene.py index 112b9708..141aa2b7 100644 --- a/engraphis/core/graph_scene.py +++ b/engraphis/core/graph_scene.py @@ -11,15 +11,28 @@ import json import math import re -from bisect import bisect_left, bisect_right +from bisect import bisect_right from collections import Counter, defaultdict, deque from typing import Any, Iterable, Mapping, Optional, Sequence -ALGORITHM_VERSION = "galaxy-v2" +ALGORITHM_VERSION = "galaxy-v7-system-envelope-packing" PUBLIC_REFERENCE_ID_LIMIT = 200 PUBLIC_FACET_LIMIT = 100 +PUBLIC_REPO_NAME_LIMIT = 100 GOLDEN_ANGLE = math.pi * (3.0 - math.sqrt(5.0)) +# v6 begins every live star at 80% of its v5 radial placement. Community +# centres use the accumulated .4 scale (v5's .5 times this compactness) while +# local orbital bands apply the same .8 factor independently. That makes each +# emitted coordinate exactly .8 of the corresponding uncontracted seed rather +# than merely making the system anchors appear closer. +GALACTIC_INITIAL_COMPACTNESS = 0.8 +GALACTIC_RADIUS_SCALE = 0.5 * GALACTIC_INITIAL_COMPACTNESS +# Keep complete solar-system envelopes just outside one another while avoiding the +# large empty radial bands that made most systems appear beyond the black-hole interior. +# This matches the dashboard's default painted carrier gap (4 units) as a small +# proportional envelope allowance instead of adding a blanket 15% radial tax. +GALAXY_ENVELOPE_CLEARANCE_FACTOR = 1.04 _STOPWORDS = { "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "is", "it", "of", "on", "or", "that", "the", "this", "to", "was", "were", @@ -67,6 +80,31 @@ def _row(row: Mapping[str, Any]) -> dict[str, Any]: return dict(row) +def _temporal_fields(row: Mapping[str, Any]) -> dict[str, Any]: + """Return the stable, public bi-temporal fields carried by a scene row.""" + return { + key: row.get(key) + for key in ( + "valid_from", "valid_to", "valid_to_recorded_at", + "ingested_at", "expired_at", + ) + if key in row + } + + +def _hash_record(record: Mapping[str, Any]) -> dict[str, Any]: + """Return a deterministic hash view of an emitted scene record. + + Layout coordinates are derived from ``scene_hash`` and therefore must not be fed back + into it. All other fields are part of the public scene identity, including optional + repository and temporal metadata. + """ + return { + str(key): value for key, value in sorted(record.items()) + if key not in {"x", "y"} + } + + def _loads(raw: Any) -> dict[str, Any]: if isinstance(raw, dict): return raw @@ -132,21 +170,453 @@ def _percentile(value: float, ordered: Sequence[float]) -> float: return (bisect_right(ordered, value) - 1) / (len(ordered) - 1) -def _mass_percentile(value: float, ordered: Sequence[float]) -> float: - """Tie-aware percentile for non-negative mass inputs. +def _positive_p95(values: Iterable[float]) -> float: + """Return a robust global scale without letting zero-evidence nodes erase it.""" + positive = sorted(value for value in values if value > 0.0 and math.isfinite(value)) + return _quantile(positive, 0.95) + + +def _log_p95_signal(value: float, p95: float) -> float: + """Compress an evidence magnitude while retaining distinctions above its p95. - Zero means no evidence and must remain zero. Positive ties receive their mid-rank - instead of the top of the tie block; otherwise thousands of isolates all acquire a - near-maximal mass merely because they share the same zero degree/support values. + A hard p95 clamp makes a common one-support leaf and a hundred-support hub identical + whenever leaves comprise at least 95% of the graph. Soft saturation keeps the p95 as + the global scale but lets the evidence tail continue toward one deterministically. """ - if value <= 0.0 or not ordered: + if value <= 0.0 or p95 <= 0.0 or not math.isfinite(value) or not math.isfinite(p95): return 0.0 - if len(ordered) == 1: - return 1.0 - left = bisect_left(ordered, value) - right = bisect_right(ordered, value) - midpoint = (left + right - 1) / 2.0 - return _clamp(midpoint / (len(ordered) - 1)) + ratio = math.log1p(value) / math.log1p(p95) + return _clamp(1.0 - math.exp(-ratio)) + + +def _gravity_mass(mass_score: float) -> float: + """Map evidence score to the one physical mass used throughout Galaxy scenes.""" + score = _clamp(mass_score) + return 1.0 + 15.0 * score * score + + +def _visual_radius(gravity_mass: float) -> float: + """Derive appearance solely from mass with enough contrast to survive fit-to-view. + + A square-root mapping compressed ordinary live scenes to roughly a 2:1 painted range, + which made evidence-distinct stars read as uniform after the full galaxy was fitted. + The bounded mass contract (1..16) keeps this two-thirds-power view modest (3.5..14.2px) + while making the strongest observed stars about three times wider than light ones. + """ + return 1.5 + 2.0 * max(0.0, gravity_mass) ** (2.0 / 3.0) + + +def _public_mass_metrics(mass_score: float) -> tuple[float, float, float]: + """Return self-consistent six-decimal score, mass, and display radius fields.""" + public_score = round(_clamp(mass_score), 6) + public_mass = round(_gravity_mass(public_score), 6) + public_radius = round(_visual_radius(public_mass), 6) + return public_score, public_mass, public_radius + + +def _ghost_position(layout_seed: int, node_id: str, + base_radius: float) -> tuple[float, float]: + """Place presentation-only history without perturbing the live physics seed.""" + digest = hashlib.sha256( + f"{ALGORITHM_VERSION}:{layout_seed}:ghost:{node_id}".encode("utf-8") + ).digest() + angle = int.from_bytes(digest[:8], "big") / float(1 << 64) * math.tau + ring = 1.0 + 0.18 * (int.from_bytes(digest[8:10], "big") % 3) + radius = max(36.0, base_radius) * ring + return radius * math.cos(angle), radius * math.sin(angle) + + +def _dominant_member(nodes: Mapping[str, Mapping[str, Any]], + member_ids: Iterable[str]) -> str: + """Return the live evidence-mass core for one community. + + Physical mass is the primary and authoritative ordering. The remaining fields only + break genuine public-mass ties, keeping the result deterministic without manufacturing + visual mass for an otherwise ordinary node. + """ + live_ids = [ + node_id for node_id in member_ids + if node_id in nodes and not nodes[node_id].get("ghost") + ] + if not live_ids: + return "" + eligible_ids = [ + node_id for node_id in live_ids + if _finite_float(nodes[node_id].get("entity_quality"), 1.0) > 0.0 + ] + pool = eligible_ids or live_ids + return min(pool, key=lambda node_id: ( + -_finite_float(nodes[node_id].get("gravity_mass"), 0.0), + -_finite_float(nodes[node_id].get("scene_rank"), 0.0), + -_finite_float(nodes[node_id].get("weighted_degree"), 0.0), + node_id, + )) + + +def _hierarchy_anchors( + nodes: Mapping[str, Mapping[str, Any]], + community_members: Mapping[str, Sequence[str]], +) -> tuple[dict[str, str], str]: + """Choose explicit hierarchy authority first, then deterministic evidence cores. + + ``anchor_role`` is server-authored authority and survives filtering/reprojection. Labels + and names are deliberately absent from selection: renamed entities retain identical + physics. A malformed payload with several explicit candidates is resolved by the same + mass/structure/id ordering as an unannotated payload. + """ + anchors: dict[str, str] = {} + for community_id, member_ids in sorted(community_members.items()): + explicit = [ + node_id for node_id in member_ids + if node_id in nodes + and nodes[node_id].get("anchor_role") in {"global", "community"} + ] + anchor_id = _dominant_member(nodes, explicit or member_ids) + if anchor_id: + anchors[community_id] = anchor_id + explicit_global = [ + node_id for node_id, node in nodes.items() + if not node.get("ghost") and node.get("anchor_role") == "global" + ] + global_anchor = _dominant_member( + nodes, explicit_global or anchors.values() + ) + return anchors, global_anchor + + +def _assign_orbit_hierarchy( + nodes: dict[str, dict[str, Any]], + community_members: Mapping[str, Sequence[str]], + community_anchors: Mapping[str, str], + *, + radius_scale: Optional[float] = None, +) -> tuple[dict[str, dict[str, int | float]], dict[str, float]]: + """Assign deterministic, mass-ranked orbital bands without changing node mass. + + Four heavy satellites occupy the inner band, then band capacity doubles up to 32. + Radii account for the actual evidence-derived node radii before the uniform v6 + compactness factor is applied. This keeps the rank/band hierarchy stable while + making every local orbital offset an exact fraction of its uncontracted seed. + Dense systems may consequently overlap; compactness is deliberate and their + public system envelope remains derived from the emitted orbit radii. + """ + slots: dict[str, dict[str, int | float]] = {} + system_radii: dict[str, float] = {} + clean_radius_scale = _clamp( + _finite_float( + GALACTIC_INITIAL_COMPACTNESS if radius_scale is None else radius_scale, + GALACTIC_INITIAL_COMPACTNESS, + ), + 0.05, + 2.0, + ) + for node in nodes.values(): + node["system_anchor_id"] = "" + node["orbit_tier"] = -1 if node.get("ghost") else 0 + node["orbit_radius"] = 0.0 + + for community_id, member_ids in sorted(community_members.items()): + anchor_id = community_anchors.get(community_id, "") + if not anchor_id or anchor_id not in nodes or nodes[anchor_id].get("ghost"): + continue + live_ids = [ + node_id for node_id in member_ids + if node_id in nodes and not nodes[node_id].get("ghost") + ] + satellites = sorted( + (node_id for node_id in live_ids if node_id != anchor_id), + key=lambda node_id: ( + -_finite_float(nodes[node_id].get("gravity_mass"), 0.0), + -_finite_float(nodes[node_id].get("scene_rank"), 0.0), + -_finite_float(nodes[node_id].get("weighted_degree"), 0.0), + node_id, + ), + ) + anchor_radius = max( + 2.0, _finite_float(nodes[anchor_id].get("visual_radius"), 2.0) + ) + nodes[anchor_id].update({ + "system_anchor_id": anchor_id, + "orbit_tier": 0, + "orbit_radius": 0.0, + }) + slots[anchor_id] = {"tier": 0, "slot": 0, "count": 1, "radius": 0.0} + + previous_outer = anchor_radius + compact_outer = anchor_radius + offset = 0 + tier = 1 + while offset < len(satellites): + first_radius = max(2.0, _finite_float( + nodes[satellites[offset]].get("visual_radius"), 2.0 + )) + gap = max(8.0, 0.55 * anchor_radius) + nominal_radius = previous_outer + first_radius + gap + if tier <= 3: + capacity = 4 * (2 ** (tier - 1)) + else: + angular_footprint = max(8.0, 2.0 * first_radius + 0.5 * gap) + capacity = max(32, int(math.tau * nominal_radius / angular_footprint)) + ring_ids = satellites[offset:offset + capacity] + ring_max_radius = max( + max(2.0, _finite_float(nodes[node_id].get("visual_radius"), 2.0)) + for node_id in ring_ids + ) + nominal_radius = previous_outer + ring_max_radius + gap + compact_radius = nominal_radius * clean_radius_scale + for slot, node_id in enumerate(ring_ids): + nodes[node_id].update({ + "system_anchor_id": anchor_id, + "orbit_tier": tier, + "orbit_radius": round(compact_radius, 6), + }) + slots[node_id] = { + "tier": tier, + "slot": slot, + "count": len(ring_ids), + "radius": compact_radius, + } + previous_outer = nominal_radius + ring_max_radius + compact_outer = max(compact_outer, compact_radius + ring_max_radius) + offset += len(ring_ids) + tier += 1 + system_radii[community_id] = round( + _clamp(compact_outer + 6.0, 36.0, 10_000.0), 6 + ) + return slots, system_radii + + +def _orbit_position( + center_x: float, + center_y: float, + community_id: str, + slot: Mapping[str, int | float], + layout_seed: int, +) -> tuple[float, float]: + """Place one satellite on its deterministic, slightly elliptical orbital band.""" + tier = int(slot["tier"]) + if tier <= 0: + return center_x, center_y + count = max(1, int(slot["count"])) + ordinal = int(slot["slot"]) + digest = hashlib.sha256( + f"{ALGORITHM_VERSION}:{layout_seed}:{community_id}:{tier}".encode("utf-8") + ).digest() + phase = int.from_bytes(digest[:8], "big") / float(1 << 64) * math.tau + direction = -1.0 if digest[8] & 1 else 1.0 + eccentricity = 0.88 + (digest[9] / 255.0) * 0.08 + rotation = digest[10] / 255.0 * math.tau + angle = phase + direction * math.tau * ordinal / count + radius = float(slot["radius"]) + local_x = radius * math.cos(angle) + local_y = radius * eccentricity * math.sin(angle) + cos_rotation, sin_rotation = math.cos(rotation), math.sin(rotation) + return ( + center_x + local_x * cos_rotation - local_y * sin_rotation, + center_y + local_x * sin_rotation + local_y * cos_rotation, + ) + + +def _community_positions( + communities: Sequence[Mapping[str, Any]], + global_community_id: str, + layout_seed: int, + *, + spacing: float, + radius_scale: Optional[float] = None, +) -> tuple[ + dict[str, tuple[float, float]], + dict[str, dict[str, int | float | bool]], +]: + """Seed deterministic logarithmic arms, then pack complete system envelopes. + + ``radius_scale`` controls the preferred spiral target, not a post-layout geometric + contraction. Contracting already-packed centres was visually compact but invalidated the + very system radii used by the collision test: large communities consequently began life + intersecting the black-hole system or one another. The final pass starts from the scaled + targets and moves whole systems outward/along the arm until their painted envelopes clear. + """ + ordered = sorted(communities, key=lambda item: ( + 0 if str(item["id"]) == global_community_id else 1, + -_finite_float(item.get("mass"), 0.0), + str(item["id"]), + )) + clean_radius_scale = _clamp( + _finite_float( + GALACTIC_RADIUS_SCALE if radius_scale is None else radius_scale, + GALACTIC_RADIUS_SCALE, + ), + 0.05, + 2.0, + ) + morphology = hashlib.sha256( + f"{ALGORITHM_VERSION}:{layout_seed}:galaxy-morphology".encode("utf-8") + ).digest() + arm_count = 2 + (morphology[0] & 1) + arm_offset = morphology[1] % arm_count + direction = -1.0 if morphology[2] & 1 else 1.0 + disk_eccentricity = 0.84 + (morphology[3] / 255.0) * 0.08 + base_phase = int.from_bytes(morphology[4:12], "big") / float(1 << 64) * math.tau + arm_populations = [0 for _ in range(arm_count)] + specs: list[dict[str, int | float | str]] = [] + orbital_rank = 0 + for community in ordered: + community_id = str(community["id"]) + system_radius = _clamp( + _finite_float(community.get("radius"), 36.0), 36.0, 10_000.0 + ) + if community_id == global_community_id: + specs.append({ + "id": community_id, "system_radius": system_radius, + "arm": -1, "nominal_x": 0.0, "nominal_y": 0.0, + }) + continue + orbital_rank += 1 + arm = (orbital_rank - 1 + arm_offset) % arm_count + arm_rank = arm_populations[arm] + arm_populations[arm] += 1 + digest = hashlib.sha256( + f"{ALGORITHM_VERSION}:{layout_seed}:system:{community_id}".encode("utf-8") + ).digest() + angular_jitter = ( + int.from_bytes(digest[:4], "big") / float(1 << 32) - 0.5 + ) * 0.34 + radial_jitter = 0.91 + ( + int.from_bytes(digest[4:8], "big") / float(1 << 32) + ) * 0.18 + # r = a * exp(b * theta) is logarithmic. Parameterising theta with log(rank) + # keeps very large scenes finite while retaining visible arm winding. + spiral_phase = 3.10 * math.log1p(arm_rank) + arm_phase = base_phase + math.tau * arm / arm_count + angle = arm_phase + direction * spiral_phase + angular_jitter + baseline_radius = ( + spacing * 1.10 * math.exp(0.175 * spiral_phase) * radial_jitter + ) + specs.append({ + "id": community_id, + "system_radius": system_radius, + "arm": arm, + "nominal_x": baseline_radius * math.cos(angle), + "nominal_y": disk_eccentricity * baseline_radius * math.sin(angle), + }) + + def pack_with_radial_clearance( + targets: Mapping[str, tuple[float, float]], + ) -> tuple[dict[str, tuple[float, float]], set[str]]: + positions: dict[str, tuple[float, float]] = {} + # Radius-aware cells keep a pathological 10,000-unit community from scanning tens of + # thousands of empty 98-unit buckets on every attempt. + cell_size = max(36.0, spacing, max( + (float(spec["system_radius"]) for spec in specs), default=36.0 + )) + spatial_cells: dict[tuple[int, int], list[tuple[float, float, float]]] = ( + defaultdict(list) + ) + unresolved: set[str] = set() + maximum_placed_radius = 0.0 + + def place(x: float, y: float, system_radius: float) -> None: + nonlocal maximum_placed_radius + cell = (math.floor(x / cell_size), math.floor(y / cell_size)) + spatial_cells[cell].append((x, y, system_radius)) + maximum_placed_radius = max(maximum_placed_radius, system_radius) + + def collides(x: float, y: float, system_radius: float) -> bool: + reach = GALAXY_ENVELOPE_CLEARANCE_FACTOR * ( + system_radius + maximum_placed_radius + ) + cell_x, cell_y = math.floor(x / cell_size), math.floor(y / cell_size) + cell_reach = max(1, math.ceil(reach / cell_size)) + for grid_x in range(cell_x - cell_reach, cell_x + cell_reach + 1): + for grid_y in range(cell_y - cell_reach, cell_y + cell_reach + 1): + for other_x, other_y, other_radius in spatial_cells.get( + (grid_x, grid_y), () + ): + clearance = GALAXY_ENVELOPE_CLEARANCE_FACTOR * ( + system_radius + other_radius + ) + if math.hypot(x - other_x, y - other_y) < clearance: + return True + return False + + for spec in specs: + community_id = str(spec["id"]) + system_radius = float(spec["system_radius"]) + target_x, target_y = targets[community_id] + if community_id == global_community_id: + x, y = 0.0, 0.0 + else: + axis_radius = math.hypot(target_x, target_y / disk_eccentricity) + angle = math.atan2(target_y / disk_eccentricity, target_x) + # Moving only the system centre preserves every local star/planet offset. The + # logarithmic walk is deterministic and gives dense 500+ node scenes enough + # radial headroom without a quadratic all-node relaxation. + found = False + for attempt in range(256): + trial_angle = angle + direction * 0.045 * attempt + trial_radius = axis_radius * math.exp(0.018 * attempt) + x = trial_radius * math.cos(trial_angle) + y = disk_eccentricity * trial_radius * math.sin(trial_angle) + if not collides(x, y, system_radius): + found = True + break + if not found: + unresolved.add(community_id) + positions[community_id] = (x, y) + place(x, y, system_radius) + return positions, unresolved + + + nominal_targets = { + str(spec["id"]): (float(spec["nominal_x"]), float(spec["nominal_y"])) + for spec in specs + } + preferred_targets = { + community_id: ( + nominal_x * clean_radius_scale, + nominal_y * clean_radius_scale, + ) + for community_id, (nominal_x, nominal_y) in nominal_targets.items() + } + # Pack *after* applying compactness. This is the key invariant: compactness may choose a + # close preferred orbit, but it may never contract two complete solar-system envelopes + # through each other. The older fixed-radius angular search could only flag an impossible + # ring; this radial continuation always has a collision-free solution in open space. + positions, unresolved = pack_with_radial_clearance(preferred_targets) + placement_flags = { + community_id: { + "adjusted": math.hypot( + positions[community_id][0] - preferred_x, + positions[community_id][1] - preferred_y, + ) > 1e-9, + "overlap": community_id in unresolved, + } + for community_id, (preferred_x, preferred_y) in preferred_targets.items() + } + hints: dict[str, dict[str, int | float | bool]] = {} + for spec in specs: + community_id = str(spec["id"]) + x, y = positions[community_id] + target_x, target_y = preferred_targets[community_id] + actual_radius = math.hypot(x, y) + preferred_radius = math.hypot(target_x, target_y) + hints[community_id] = { + "galactic_radius": round(actual_radius, 6), + # Convergence follows this target every live slice. It must therefore be the + # clearance-adjusted carrier orbit, or it continually drags the freshly packed + # system back through its neighbours. Preserve the compact spiral preference as + # a diagnostic only; it is never a physical attractor after packing. + "galactic_target_radius": round(actual_radius, 6), + "galactic_preferred_radius": round(preferred_radius, 6), + "galactic_radius_scale": round(clean_radius_scale, 6), + "galactic_initial_compactness": GALACTIC_INITIAL_COMPACTNESS, + "galactic_clearance_adjusted": placement_flags[community_id]["adjusted"], + "galactic_overlap": placement_flags[community_id]["overlap"], + "galactic_arm": int(spec["arm"]), + "galactic_phase": round(math.atan2(y, x), 6), + "galactic_eccentricity": round(disk_eccentricity, 6), + } + return positions, hints def is_obvious_entity_noise(label: str, entity_type: str) -> bool: @@ -329,6 +799,9 @@ def build_canonical_graph( types = Counter(str(item.get("etype") or "person_or_concept") for item in group) entity_type = sorted(types, key=lambda item: (-types[item], item))[0] repo_ids = sorted({str(item["repo_id"]) for item in group if item.get("repo_id")}) + repo_names = sorted({ + str(item["repo_name"]) for item in group if item.get("repo_name") + }, key=lambda value: (value.casefold(), value))[:PUBLIC_REPO_NAME_LIMIT] nodes[canonical_id] = { "id": canonical_id, "canonical_id": canonical_id, @@ -337,6 +810,7 @@ def build_canonical_graph( "member_ids": sorted(str(item["id"]) for item in group), "member_count": len(group), "repo_ids": repo_ids, + "repo_names": repo_names, "aliases": sorted(labels, key=lambda item: (item.casefold(), item)), } @@ -348,6 +822,8 @@ def build_canonical_graph( bundled: dict[tuple[str, str, str, str, bool], dict] = {} for raw in edge_rows: edge = _row(raw) + if edge.get("ghost"): + continue source = member_to_canonical.get(str(edge.get("src") or "")) target = member_to_canonical.get(str(edge.get("dst") or "")) relation = str(edge.get("relation") or "related") @@ -363,7 +839,7 @@ def build_canonical_graph( source, target = target, source edge_id = str(edge.get("id") or _stable_id("edge_", source, target, relation, layer)) evidence = [dict(item) for item in supports_by_edge.get(edge_id, [])] - if not evidence: + if not evidence and not edge.get("_has_normalized_support"): source_kind, default_confidence = _source_default(relation, edge.get("provenance")) memory_ids = _memory_ids(edge.get("provenance")) evidence = [{ @@ -494,8 +970,11 @@ def build_canonical_graph( strength = edge["strength"] degree[source] += strength degree[target] += strength - node_supports[source].update(edge["_support_ids_all"]) - node_supports[target].update(edge["_support_ids_all"]) + # Stable memory ids deduplicate evidence reused across relations. Anonymous legacy + # rows use their deterministic edge/index key, so their magnitude still contributes + # without exposing a synthetic id in the public support-memory list. + node_supports[source].update(edge["_confidence_by_support"]) + node_supports[target].update(edge["_confidence_by_support"]) adjacency[source][target] = adjacency[source].get(target, 0.0) + strength adjacency[target][source] = adjacency[target].get(source, 0.0) + strength @@ -515,52 +994,52 @@ def build_canonical_graph( updated[target] += damping * pagerank[source] * weight / degree[source] pagerank = updated - degree_values = sorted(degree.values()) - pagerank_values = sorted(pagerank.values()) - support_values = sorted(float(len(value)) for value in node_supports.values()) - repo_values = sorted(float(len(node["repo_ids"])) for node in nodes.values()) + # These scales are computed over the complete canonical graph, before any overview cap. + # Unlike empirical ranks, log magnitudes retain the difference between one piece of + # evidence and a hundred while p95 scaling prevents one pathological hub from flattening + # every ordinary node. PageRank is evidence only for connected bodies: its uniform + # dangling-node base must not give isolates gravitational mass. + pagerank_evidence = { + node_id: pagerank[node_id] if degree[node_id] > 0.0 else 0.0 + for node_id in nodes + } + degree_p95 = _positive_p95(degree.values()) + pagerank_p95 = _positive_p95(pagerank_evidence.values()) + support_p95 = _positive_p95( + float(len(value)) for value in node_supports.values() + ) + repo_p95 = _positive_p95( + float(len(node["repo_ids"])) for node in nodes.values() + ) max_pagerank = max(pagerank.values(), default=1.0) or 1.0 for node_id, node in nodes.items(): obvious_noise = is_obvious_entity_noise(node["label"], node["type"]) quality = 0.0 if obvious_noise else 1.0 support_count = len(node_supports[node_id]) mass_score = quality * ( - 0.45 * _mass_percentile(degree[node_id], degree_values) - + 0.30 * _mass_percentile(pagerank[node_id], pagerank_values) - + 0.15 * _mass_percentile(float(support_count), support_values) - + 0.10 * _mass_percentile(float(len(node["repo_ids"])), repo_values) + 0.45 * _log_p95_signal(degree[node_id], degree_p95) + + 0.30 * _log_p95_signal(pagerank_evidence[node_id], pagerank_p95) + + 0.15 * _log_p95_signal(float(support_count), support_p95) + + 0.10 * _log_p95_signal(float(len(node["repo_ids"])), repo_p95) ) + public_score, gravity_mass, visual_radius = _public_mass_metrics(mass_score) node.update({ "weighted_degree": round(degree[node_id], 6), "pagerank": round(pagerank[node_id] / max_pagerank, 6), "support_count": support_count, "entity_quality": quality, - "mass_score": round(_clamp(mass_score), 6), - "gravity_mass": round(1.0 + 7.0 * (_clamp(mass_score) ** 1.35), 6), - "visual_radius": round(2.5 + 6.0 * math.sqrt(_clamp(mass_score)), 6), + "mass_score": public_score, + "gravity_mass": gravity_mass, + "visual_radius": visual_radius, "anchor_eligible": bool(quality), }) components = _components(sorted(nodes), edges) communities = _louvain(sorted(nodes), edges) - eligible = [node for node in nodes.values() if node["anchor_eligible"]] or list(nodes.values()) - global_anchor = min( - eligible, - key=lambda node: (-node["mass_score"], -node["weighted_degree"], node["canonical_id"]), - default=None, - ) - global_id = global_anchor["id"] if global_anchor else "" community_members: dict[str, list[str]] = defaultdict(list) for node_id in sorted(nodes): community_members[communities[node_id]].append(node_id) - community_anchors: dict[str, str] = {} - for community_id, ids in community_members.items(): - pool = [nodes[node_id] for node_id in ids if nodes[node_id]["anchor_eligible"]] - pool = pool or [nodes[node_id] for node_id in ids] - community_anchors[community_id] = min( - pool, - key=lambda node: (-node["mass_score"], -node["weighted_degree"], node["id"]), - )["id"] + community_anchors, global_id = _hierarchy_anchors(nodes, community_members) direct_core: dict[str, float] = defaultdict(float) for edge in edges: @@ -583,6 +1062,7 @@ def build_canonical_graph( "core_affinity": round(affinity, 6), "scene_rank": round(_clamp(0.75 * node["mass_score"] + 0.25 * affinity), 6), }) + _assign_orbit_hierarchy(nodes, community_members, community_anchors) for edge in edges: source_radius = nodes[edge["source"]]["visual_radius"] @@ -682,13 +1162,26 @@ def _community_summaries(graph: dict, community_ids: set[str], result = [] for community_id in community_ids: member_ids = graph["community_members"][community_id] - anchor_id = graph["community_anchors"][community_id] internal = [edge for edge in edges if edge["source"] in member_ids and edge["target"] in member_ids] external = [edge for edge in edges if (edge["source"] in member_ids) != (edge["target"] in member_ids)] - mass = _community_mass(graph, member_ids) - representatives = sorted(member_ids, key=lambda node_id: ( + active_member_ids = [ + node_id for node_id in member_ids + if not graph["nodes"][node_id].get("ghost") + ] + if not active_member_ids: + continue + anchor_id = graph["community_anchors"][community_id] + mass = _community_mass(graph, active_member_ids) + hierarchy_radius = max(( + _finite_float(graph["nodes"][node_id].get("orbit_radius"), 0.0) + + max(0.0, _finite_float( + graph["nodes"][node_id].get("visual_radius"), 0.0 + )) + for node_id in active_member_ids + ), default=0.0) + 6.0 + representatives = sorted(active_member_ids, key=lambda node_id: ( -graph["nodes"][node_id]["scene_rank"], node_id ))[:8] result.append({ @@ -696,9 +1189,12 @@ def _community_summaries(graph: dict, community_ids: set[str], "label": f"{graph['nodes'][anchor_id]['label']} System", "anchor_id": anchor_id, "mass": round(mass, 6), - "radius": round(_clamp(30.0 + 5.0 * math.sqrt(len(member_ids)), 36.0, 110.0), 6), - "member_count": len(member_ids), - "shown_member_count": len(set(member_ids).intersection(selected)), + "radius": round(_clamp(max( + hierarchy_radius, + 30.0 + 5.0 * math.sqrt(len(active_member_ids)), + ), 36.0, 10_000.0), 6), + "member_count": len(active_member_ids), + "shown_member_count": len(set(active_member_ids).intersection(selected)), "internal_strength": round(sum(edge["strength"] for edge in internal), 6), "external_strength": round(sum(edge["strength"] for edge in external), 6), "representative_ids": representatives, @@ -709,8 +1205,8 @@ def _community_summaries(graph: dict, community_ids: set[str], def _community_mass(graph: dict, member_ids: Iterable[str]) -> float: """Return the same aggregate mass used by the system-layout contract.""" return sum( - math.sqrt(max(1.0, float(graph["nodes"][node_id]["gravity_mass"]))) - for node_id in member_ids + max(0.0, float(graph["nodes"][node_id]["gravity_mass"])) + for node_id in member_ids if not graph["nodes"][node_id].get("ghost") ) @@ -860,6 +1356,7 @@ def _complete_relations( relations: Optional[set[str]], min_support: int, min_confidence: float, + memory_ghost_ids: Optional[set[str]] = None, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """Return every filtered physical relation and its explicit evidence links. @@ -869,6 +1366,7 @@ def _complete_relations( makes evidence selectable without replacing or hiding the factual relation. """ supports_by_edge: dict[str, list[dict[str, Any]]] = defaultdict(list) + memory_ghost_ids = memory_ghost_ids or set() for raw in support_rows: support = _row(raw) supports_by_edge[str(support.get("edge_id") or "")].append(support) @@ -891,8 +1389,9 @@ def _complete_relations( edge_id = str(edge.get("id") or _stable_id( "edge_", source, target, relation, layer )) + ghost = bool(edge.get("ghost")) evidence = [dict(item) for item in supports_by_edge.get(edge_id, [])] - if not evidence: + if not evidence and not edge.get("_has_normalized_support"): source_kind, default_confidence = _source_default( relation, edge.get("provenance") ) @@ -940,7 +1439,8 @@ def _complete_relations( raw_log = math.log1p( weight * confidence * support_boost * _relation_factor(layer, relation) ) - raw_logs.append(raw_log) + if not ghost: + raw_logs.append(raw_log) pending.append({ "id": edge_id, "source": source, @@ -957,6 +1457,8 @@ def _complete_relations( "tier": "raw", "visible_by_default": True, "connector_kind": "entity_relation", + "ghost": ghost, + **_temporal_fields(edge), "_raw_log": raw_log, }) for support in evidence: @@ -964,6 +1466,12 @@ def _complete_relations( if not memory_id or memory_id not in memory_ids: continue source_kind = str(support.get("source_kind") or "legacy_unknown") + evidence_ghost = bool( + ghost + or support.get("ghost") + or support.get("memory_ghost") + or memory_id in memory_ghost_ids + ) evidence_confidence = _clamp( _finite_float( support.get("confidence") @@ -992,6 +1500,8 @@ def _complete_relations( "tier": "evidence", "visible_by_default": True, "connector_kind": "evidence", + "ghost": evidence_ghost, + **_temporal_fields(support), "source_kind": source_kind, "strength": round(evidence_confidence, 6), "rest_length": round(12.0 + 10.0 * (1.0 - evidence_confidence), 6), @@ -1001,6 +1511,14 @@ def _complete_relations( low, high = _quantile(raw_logs, 0.05), _quantile(raw_logs, 0.95) relations_out = [] for edge in pending: + if edge["ghost"]: + edge["strength"] = 0.0 + edge["rest_length"] = 0.0 + edge["spring_strength"] = 0.0 + edge["visible_by_default"] = False + edge.pop("_raw_log", None) + relations_out.append(edge) + continue strength = ( 1.0 if high - low <= 1e-12 else _clamp((edge["_raw_log"] - low) / (high - low)) @@ -1015,6 +1533,12 @@ def _complete_relations( edge["spring_strength"] = round(0.035 + 0.17 * strength, 6) edge.pop("_raw_log", None) relations_out.append(edge) + for edge in evidence_pending: + if edge["ghost"]: + edge["strength"] = 0.0 + edge["rest_length"] = 0.0 + edge["spring_strength"] = 0.0 + edge["visible_by_default"] = False return ( sorted(relations_out, key=lambda item: ( -item["strength"], item["source"], item["target"], @@ -1032,6 +1556,8 @@ def _complete_bridges(nodes: Mapping[str, dict], edges: Sequence[dict]) -> list[ """ grouped: dict[tuple[str, str, str], list[dict]] = defaultdict(list) for edge in edges: + if edge.get("ghost"): + continue source_node = nodes.get(str(edge.get("source") or "")) target_node = nodes.get(str(edge.get("target") or "")) if not source_node or not target_node: @@ -1099,18 +1625,25 @@ def _build_complete_scene( relations: Optional[set[str]], min_support: int, min_confidence: float, + connected_only: bool, + include_history: bool, + include_memory_nodes: bool, filters: dict[str, Any], index_generation: int, ) -> dict[str, Any]: memory_rows_by_id = { str(row.get("id") or ""): _row(row) for row in memory_rows if row.get("id") - } + } if include_memory_nodes else {} memory_ids = set(memory_rows_by_id) raw_relations, evidence_edges = _complete_relations( graph, edge_rows, support_rows, memory_ids=memory_ids, include_weak_cooccurrence=include_weak_cooccurrence, layers=layers, relations=relations, min_support=min_support, min_confidence=min_confidence, + memory_ghost_ids={ + memory_id for memory_id, memory in memory_rows_by_id.items() + if memory.get("ghost") + }, ) entity_nodes = {node_id: dict(node) for node_id, node in graph["nodes"].items()} @@ -1121,6 +1654,8 @@ def _build_complete_scene( evidence_targets: dict[str, list[tuple[float, str]]] = defaultdict(list) for edge in evidence_edges: + if edge.get("ghost"): + continue evidence_targets[edge["source"]].append(( float(edge["strength"]), edge["target"] )) @@ -1140,7 +1675,8 @@ def _build_complete_scene( memory_degree = Counter() for edge in evidence_edges: - memory_degree[edge["source"]] += 1 + if not edge.get("ghost"): + memory_degree[edge["source"]] += 1 memory_link_edges = [] for raw in sorted(memory_link_rows, key=lambda item: ( str(item.get("a") or ""), str(item.get("b") or ""), @@ -1156,8 +1692,13 @@ def _build_complete_scene( continue if relations is not None and relation not in relations: continue - memory_degree[source] += 1 - memory_degree[target] += 1 + ghost = bool(row.get("ghost") or + memory_rows_by_id[source].get("ghost") + or memory_rows_by_id[target].get("ghost") + ) + if not ghost: + memory_degree[source] += 1 + memory_degree[target] += 1 memory_link_edges.append({ "id": _stable_id( "memlink_", source, target, relation, layer, @@ -1177,10 +1718,12 @@ def _build_complete_scene( "tier": "raw", "visible_by_default": True, "connector_kind": "memory_link", + "ghost": ghost, + **_temporal_fields(row), "reason": str(row.get("reason") or ""), - "strength": 0.72, - "rest_length": 22.0, - "spring_strength": 0.12, + "strength": 0.0 if ghost else 0.72, + "rest_length": 0.0 if ghost else 22.0, + "spring_strength": 0.0 if ghost else 0.12, }) code_memory_edges = [] @@ -1200,7 +1743,9 @@ def _build_complete_scene( 0.05, 1.0, ) - memory_degree[memory_id] += 1 + ghost = bool(row.get("ghost") or memory_rows_by_id[memory_id].get("ghost")) + if not ghost: + memory_degree[memory_id] += 1 code_memory_edges.append({ "id": str(row.get("id") or _stable_id( "code_memory_", memory_id, symbol_id, relation @@ -1219,13 +1764,19 @@ def _build_complete_scene( "tier": "raw", "visible_by_default": True, "connector_kind": "code_memory", - "strength": round(confidence, 6), - "rest_length": round(14.0 + 8.0 * (1.0 - confidence), 6), - "spring_strength": round(0.05 + 0.12 * confidence, 6), + "ghost": ghost, + **_temporal_fields(row), + "strength": 0.0 if ghost else round(confidence, 6), + "rest_length": (0.0 if ghost else + round(14.0 + 8.0 * (1.0 - confidence), 6)), + "spring_strength": (0.0 if ghost else + round(0.05 + 0.12 * confidence, 6)), }) memory_nodes: dict[str, dict[str, Any]] = {} - degree_values = sorted(float(memory_degree[memory_id]) for memory_id in memory_ids) + degree_p95 = _positive_p95( + float(memory_degree[memory_id]) for memory_id in memory_ids + ) for memory_id, memory in sorted(memory_rows_by_id.items()): title = str(memory.get("title") or "").strip() summary = str(memory.get("summary") or "").strip() @@ -1233,10 +1784,13 @@ def _build_complete_scene( label = title or summary or content or memory_id label = " ".join(label.split())[:160] importance = _clamp(_finite_float(memory.get("importance"), 0.0)) - degree_percentile = _mass_percentile( - float(memory_degree[memory_id]), degree_values + degree_signal = _log_p95_signal( + float(memory_degree[memory_id]), degree_p95 ) - mass_score = _clamp(0.08 + 0.34 * importance + 0.18 * degree_percentile, 0.08, 0.60) + mass_score = _clamp( + 0.08 + 0.34 * importance + 0.18 * degree_signal, 0.08, 0.60 + ) + public_score, gravity_mass, visual_radius = _public_mass_metrics(mass_score) memory_nodes[memory_id] = { "id": memory_id, "canonical_id": memory_id, @@ -1248,43 +1802,58 @@ def _build_complete_scene( "member_ids": [memory_id], "member_count": 1, "repo_ids": [str(memory["repo_id"])] if memory.get("repo_id") else [], + "repo_names": ([str(memory["repo_name"])] + if memory.get("repo_name") else []), "weighted_degree": round(float(memory_degree[memory_id]), 6), "pagerank": 0.0, "support_count": int(memory_degree[memory_id]), "entity_quality": 1.0, - "mass_score": round(mass_score, 6), - "gravity_mass": round(0.55 + 2.2 * (mass_score ** 1.35), 6), - "visual_radius": round(2.0 + 3.5 * math.sqrt(mass_score), 6), + "mass_score": public_score, + "gravity_mass": gravity_mass, + "visual_radius": visual_radius, "component_id": f"component_memory_{memory_id}", "community_id": memory_community[memory_id], "anchor_role": "none", "core_affinity": 0.0, - "scene_rank": round(_clamp(0.70 * mass_score + 0.30 * degree_percentile), 6), + "scene_rank": round(_clamp(0.70 * mass_score + 0.30 * degree_signal), 6), "importance": round(importance, 6), "pinned": bool(memory.get("pinned")), "valid_from": memory.get("valid_from"), "ingested_at": memory.get("ingested_at"), + "valid_to": memory.get("valid_to"), + "valid_to_recorded_at": memory.get("valid_to_recorded_at"), + "expired_at": memory.get("expired_at"), + "ghost": bool(memory.get("ghost")), } + # Historical nodes are presentation context only. They retain their deterministic + # community/position identity, but never contribute gravitational mass. + for node in memory_nodes.values(): + if node.get("ghost"): + node["mass_score"] = 0.0 + node["gravity_mass"] = 0.0 + node["weighted_degree"] = 0.0 + node["pagerank"] = 0.0 + node["support_count"] = 0 + node["scene_rank"] = 0.0 + node["visual_radius"] = 0.0 + all_nodes: dict[str, dict[str, Any]] = {**entity_nodes, **memory_nodes} community_members: dict[str, list[str]] = defaultdict(list) for node_id, node in all_nodes.items(): community_members[node["community_id"]].append(node_id) - community_anchors = dict(graph["community_anchors"]) - for community_id, member_ids in sorted(community_members.items()): - if community_id not in community_anchors: - community_anchors[community_id] = min(member_ids, key=lambda node_id: ( - -all_nodes[node_id]["scene_rank"], node_id - )) - all_nodes[community_anchors[community_id]]["anchor_role"] = "community" - - global_anchor = graph["global_anchor"] - if not global_anchor and all_nodes: - global_anchor = min(all_nodes, key=lambda node_id: ( - -all_nodes[node_id]["scene_rank"], node_id - )) + community_anchors, global_anchor = _hierarchy_anchors( + all_nodes, community_members + ) + for node in all_nodes.values(): + node["anchor_role"] = "none" + for anchor_id in community_anchors.values(): + all_nodes[anchor_id]["anchor_role"] = "community" + if global_anchor: all_nodes[global_anchor]["anchor_role"] = "global" - community_anchors[all_nodes[global_anchor]["community_id"]] = global_anchor + orbit_slots, system_radii = _assign_orbit_hierarchy( + all_nodes, community_members, community_anchors + ) complete_edges = sorted( [*raw_relations, *evidence_edges, *memory_link_edges, *code_memory_edges], @@ -1292,9 +1861,59 @@ def _build_complete_scene( edge["connector_kind"], -float(edge["strength"]), edge["id"] ), ) + if connected_only: + connected_ids = { + str(edge[endpoint]) + for edge in complete_edges + if not edge.get("ghost") + for endpoint in ("source", "target") + } + if include_history: + connected_ids |= { + str(edge[endpoint]) + for edge in complete_edges + if edge.get("ghost") + for endpoint in ("source", "target") + } + all_nodes = { + node_id: node for node_id, node in all_nodes.items() + if node_id in connected_ids + } + entity_nodes = { + node_id: node for node_id, node in entity_nodes.items() + if node_id in all_nodes + } + memory_nodes = { + node_id: node for node_id, node in memory_nodes.items() + if node_id in all_nodes + } + complete_edges = [ + edge for edge in complete_edges + if edge["source"] in all_nodes and edge["target"] in all_nodes + ] + community_members = defaultdict(list) + for node_id, node in all_nodes.items(): + community_members[node["community_id"]].append(node_id) + community_anchors, global_anchor = _hierarchy_anchors( + all_nodes, community_members + ) + for node in all_nodes.values(): + node["anchor_role"] = "none" + for anchor_id in community_anchors.values(): + all_nodes[anchor_id]["anchor_role"] = "community" + if global_anchor: + all_nodes[global_anchor]["anchor_role"] = "global" + orbit_slots, system_radii = _assign_orbit_hierarchy( + all_nodes, community_members, community_anchors + ) internal_strength: dict[str, float] = defaultdict(float) external_strength: dict[str, float] = defaultdict(float) for edge in complete_edges: + if edge.get("ghost"): + continue + if (all_nodes[edge["source"]].get("ghost") + or all_nodes[edge["target"]].get("ghost")): + continue source_community = all_nodes[edge["source"]]["community_id"] target_community = all_nodes[edge["target"]]["community_id"] strength = float(edge["strength"]) @@ -1305,80 +1924,113 @@ def _build_complete_scene( external_strength[target_community] += strength communities = [] for community_id, member_ids in sorted(community_members.items()): + active_member_ids = [ + node_id for node_id in member_ids if not all_nodes[node_id].get("ghost") + ] + if not active_member_ids: + continue anchor_id = community_anchors[community_id] - mass = sum(math.sqrt(max(0.1, float(all_nodes[node_id]["gravity_mass"]))) - for node_id in member_ids) + mass = sum(max(0.0, float(all_nodes[node_id]["gravity_mass"])) + for node_id in active_member_ids) communities.append({ "id": community_id, "label": f"{all_nodes[anchor_id]['label']} System", "anchor_id": anchor_id, "mass": round(mass, 6), - "radius": round(_clamp( - 30.0 + 5.0 * math.sqrt(len(member_ids)), 36.0, 180.0 - ), 6), - "member_count": len(member_ids), - "shown_member_count": len(member_ids), + "radius": system_radii[community_id], + "member_count": len(active_member_ids), + "shown_member_count": len(active_member_ids), "internal_strength": round(internal_strength[community_id], 6), "external_strength": round(external_strength[community_id], 6), - "representative_ids": sorted(member_ids, key=lambda node_id: ( + "representative_ids": sorted(active_member_ids, key=lambda node_id: ( -all_nodes[node_id]["scene_rank"], node_id ))[:8], }) communities.sort(key=lambda item: (-item["mass"], item["id"])) + bridges = _complete_bridges(all_nodes, complete_edges) hash_payload = { - "algorithm": f"{ALGORITHM_VERSION}-complete-1", + "algorithm": ALGORITHM_VERSION, "index_generation": index_generation, "workspace": workspace, "filters": filters, - "nodes": [( - node_id, all_nodes[node_id]["node_kind"], all_nodes[node_id]["label"], - all_nodes[node_id]["community_id"], all_nodes[node_id]["mass_score"], - ) for node_id in sorted(all_nodes)], - "edges": [( - edge["id"], edge["source"], edge["target"], edge["relation"], - edge["connector_kind"], edge["strength"], - ) for edge in sorted(complete_edges, key=lambda item: item["id"])], + "nodes": [ + (node_id, _hash_record(all_nodes[node_id])) + for node_id in sorted(all_nodes) + ], + "edges": [ + _hash_record(edge) + for edge in sorted(complete_edges, key=lambda item: item["id"]) + ], + "communities": [ + ( + community["id"], community["anchor_id"], community["mass"], + community["radius"], community["member_count"], + community["shown_member_count"], + ) + for community in sorted(communities, key=lambda item: item["id"]) + ], + "bridges": [ + ( + bridge["id"], bridge["aggregate_strength"], + bridge["physics_strength"], bridge["support_count"], + bridge["edge_count"], + ) + for bridge in sorted(bridges, key=lambda item: item["id"]) + ], } scene_hash = hashlib.sha256(json.dumps( hash_payload, sort_keys=True, separators=(",", ":") ).encode("utf-8")).hexdigest() - layout_seed = int(scene_hash[:8], 16) + layout_filters = dict(filters) + layout_filters.pop("include_history", None) + layout_hash_payload = { + **hash_payload, + "filters": layout_filters, + "nodes": [ + (node_id, _hash_record(all_nodes[node_id])) + for node_id in sorted(all_nodes) if not all_nodes[node_id].get("ghost") + ], + "edges": [ + _hash_record(edge) + for edge in sorted(complete_edges, key=lambda item: item["id"]) + if not edge.get("ghost") + ], + } + layout_hash = hashlib.sha256(json.dumps( + layout_hash_payload, sort_keys=True, separators=(",", ":") + ).encode("utf-8")).hexdigest() + layout_seed = int(layout_hash[:8], 16) - positions: dict[str, tuple[float, float]] = {} - for index, community in enumerate(communities): - if global_anchor in community_members[community["id"]]: - positions[community["id"]] = (0.0, 0.0) - else: - radius = 82.0 * math.sqrt(index + 1) - angle = GOLDEN_ANGLE * (index + 1) + (layout_seed % 360) * math.pi / 180.0 - positions[community["id"]] = ( - radius * math.cos(angle), radius * math.sin(angle) - ) - ranks: dict[str, int] = defaultdict(int) + global_community_id = ( + str(all_nodes[global_anchor]["community_id"]) if global_anchor else "" + ) + positions, community_hints = _community_positions( + communities, global_community_id, layout_seed, spacing=92.0 + ) + for community in communities: + community.update(community_hints[community["id"]]) scene_nodes = [] - radius_by_community = {item["id"]: item["radius"] for item in communities} for node_id in sorted(all_nodes, key=lambda value: ( -all_nodes[value]["scene_rank"], value )): node = dict(all_nodes[node_id]) community_id = node["community_id"] - center_x, center_y = positions[community_id] - rank = ranks[community_id] - ranks[community_id] += 1 - if node_id == community_anchors[community_id]: - x, y = center_x, center_y + if node.get("ghost") or community_id not in positions: + x, y = _ghost_position( + layout_seed, node_id, 82.0 * math.sqrt(len(communities) + 1) + ) + elif node_id == community_anchors[community_id]: + x, y = positions[community_id] else: - system_radius = radius_by_community[community_id] - orbit = _clamp( - 13.0 + 5.5 * math.sqrt(rank + 1) - + (1.0 - node["mass_score"]) * 0.35 * system_radius, - 14.0, system_radius, + center_x, center_y = positions[community_id] + x, y = _orbit_position( + center_x, center_y, community_id, + orbit_slots[node_id], layout_seed, ) - angle = GOLDEN_ANGLE * (rank + 1) + (layout_seed % 180) * math.pi / 180.0 - x = center_x + orbit * math.cos(angle) - y = center_y + orbit * math.sin(angle) node["x"], node["y"] = round(x, 6), round(y, 6) + if community_id in community_hints: + node.update(community_hints[community_id]) scene_nodes.append(node) facets = _facets(graph) @@ -1387,12 +2039,15 @@ def _build_complete_scene( for value, count in sorted( memory_type_counts.items(), key=lambda item: (-item[1], item[0]) )[:PUBLIC_FACET_LIMIT]] - bridges = _complete_bridges(all_nodes, complete_edges) return { "meta": { "workspace": workspace, "level": "complete", "complete_scene": True, + "node_projection": "all" if include_memory_nodes else "entities", + "connected_only": connected_only, + "include_history": include_history, + "include_memory_nodes": include_memory_nodes, "scene_hash": scene_hash, "index_generation": index_generation, "total_nodes": len(scene_nodes), @@ -1412,7 +2067,7 @@ def _build_complete_scene( "layout_seed": layout_seed, "index_state": "ready", "filters": filters, - "algorithm_version": f"{ALGORITHM_VERSION}-complete-1", + "algorithm_version": ALGORITHM_VERSION, }, "nodes": scene_nodes, "edges": complete_edges, @@ -1443,25 +2098,171 @@ def build_graph_scene( relations: Optional[set[str]] = None, min_support: int = 1, min_confidence: float = 0.0, + connected_only: bool = False, + include_history: bool = False, + include_memory_nodes: bool = True, filters: Optional[dict] = None, index_generation: int = 4, ) -> dict[str, Any]: level = level if level in { "overview", "system", "neighborhood", "path", "complete" } else "overview" + ghost_member_ids = { + str(edge.get(endpoint) or "") + for edge in edge_rows if edge.get("ghost") + for endpoint in ("src", "dst") + } + active_member_ids = { + str(edge.get(endpoint) or "") + for edge in edge_rows if not edge.get("ghost") + for endpoint in ("src", "dst") + } + historical_only_members = ghost_member_ids - active_member_ids + live_entity_rows = [ + row for row in entity_rows + if str(row.get("id") or "") not in historical_only_members + ] graph = build_canonical_graph( - entity_rows, edge_rows, support_rows, + live_entity_rows, edge_rows, support_rows, include_weak_cooccurrence=include_weak_cooccurrence, layers=layers, relations=relations, min_support=min_support, min_confidence=min_confidence, ) + if include_history and historical_only_members: + historical_graph = build_canonical_graph( + [row for row in entity_rows + if str(row.get("id") or "") in historical_only_members], + [], [], min_support=0, + ) + historical_id_map: dict[str, str] = {} + for node_id, node in historical_graph["nodes"].items(): + historical_id = node_id + live = graph["nodes"].get(node_id) + if live is not None: + # The canonical ID already holds a live evidence node. + # Record the historical-only alias under a distinct key so + # the live node keeps its mass, community, and relations. + node_id = f"{node_id}:ghost" + while node_id in graph["nodes"] or node_id in historical_id_map.values(): + node_id = f"{node_id}:ghost" + historical_id_map[historical_id] = node_id + node["id"] = node_id + node["ghost"] = True + node["mass_score"] = 0.0 + node["gravity_mass"] = 0.0 + node["weighted_degree"] = 0.0 + node["pagerank"] = 0.0 + node["support_count"] = 0 + node["core_affinity"] = 0.0 + node["scene_rank"] = 0.0 + node["entity_quality"] = 0.0 + node["visual_radius"] = 0.0 + node["anchor_eligible"] = False + node["system_anchor_id"] = "" + node["orbit_tier"] = -1 + node["orbit_radius"] = 0.0 + touching = [ + edge for edge in edge_rows if edge.get("ghost") and ( + str(edge.get("src") or "") in node["member_ids"] + or str(edge.get("dst") or "") in node["member_ids"] + ) + ] + for field in ( + "valid_from", "valid_to", "valid_to_recorded_at", + "ingested_at", "expired_at", + ): + values: list[float] = [ + _finite_float(edge[field]) + for edge in touching if edge.get(field) is not None + ] + if values: + node[field] = max(values) if field in {"valid_to", "expired_at"} else min(values) + graph["nodes"][node_id] = node + for member, canonical in historical_graph["member_to_canonical"].items(): + canonical = historical_id_map.get(canonical, canonical) + if canonical in graph["nodes"]: + # Route the member to the ghost alias when the live slot + # is already occupied so member_to_canonical stays a bijection. + if graph["nodes"][canonical].get("ghost") is not True: + canonical = f"{canonical}:ghost" + graph["member_to_canonical"][member] = canonical + for community_id, members in historical_graph["community_members"].items(): + members = [historical_id_map.get(member, member) for member in members] + existing = graph["community_members"].get(community_id) + if existing is None: + graph["community_members"][community_id] = list(members) + else: + seen = set(existing) + for member_id in members: + if member_id not in seen: + existing.append(member_id) + seen.add(member_id) + for community_id, anchor in historical_graph["community_anchors"].items(): + anchor = historical_id_map.get(anchor, anchor) + if community_id not in graph["community_anchors"]: + graph["community_anchors"][community_id] = anchor + + filtered_history_relations: list[dict[str, Any]] = [] + if include_history: + filtered_history_relations, _ = _complete_relations( + graph, [edge for edge in edge_rows if edge.get("ghost")], support_rows, + memory_ids=set(), include_weak_cooccurrence=include_weak_cooccurrence, + layers=layers, relations=relations, min_support=min_support, + min_confidence=min_confidence, + ) + + # Complete scenes construct memory and code-memory connectors below. Pruning their + # entity projection here would discard symbol endpoints before those connectors exist; + # _build_complete_scene performs the authoritative connected-only pass after assembling + # every enabled connector kind. + if connected_only and level != "complete": + connected_canonical_ids = { + str(edge[endpoint]) + for edge in graph["edges"] + for endpoint in ("source", "target") + } + connected_canonical_ids.discard("") + if include_history: + connected_canonical_ids |= { + str(edge[endpoint]) + for edge in filtered_history_relations + for endpoint in ("source", "target") + } + connected_canonical_ids.discard("") + graph["nodes"] = { + node_id: node for node_id, node in graph["nodes"].items() + if node_id in connected_canonical_ids + } + graph["edges"] = [ + edge for edge in graph["edges"] + if edge["source"] in graph["nodes"] and edge["target"] in graph["nodes"] + ] + graph["community_members"] = { + community_id: [node_id for node_id in member_ids if node_id in graph["nodes"]] + for community_id, member_ids in graph["community_members"].items() + if any(node_id in graph["nodes"] for node_id in member_ids) + } + graph["community_anchors"], graph["global_anchor"] = _hierarchy_anchors( + graph["nodes"], graph["community_members"] + ) + for node in graph["nodes"].values(): + node["anchor_role"] = "none" + for anchor_id in graph["community_anchors"].values(): + graph["nodes"][anchor_id]["anchor_role"] = "community" + if graph["global_anchor"]: + graph["nodes"][graph["global_anchor"]]["anchor_role"] = "global" + orbit_slots, _system_radii = _assign_orbit_hierarchy( + graph["nodes"], graph["community_members"], graph["community_anchors"] + ) if level == "complete": return _build_complete_scene( workspace, graph, edge_rows, support_rows, memory_rows, memory_link_rows, code_memory_link_rows, include_weak_cooccurrence=include_weak_cooccurrence, layers=layers, relations=relations, min_support=min_support, - min_confidence=min_confidence, filters=filters or {}, + min_confidence=min_confidence, connected_only=connected_only, + include_history=include_history, + include_memory_nodes=include_memory_nodes, filters=filters or {}, index_generation=index_generation, ) caps = { @@ -1471,8 +2272,8 @@ def build_graph_scene( "path": (100, 250), } default_node_cap, default_edge_cap = caps[level] - node_cap = min(300, max(1, int(node_limit or default_node_cap))) - edge_cap = min(900, max(0, int(edge_limit if edge_limit is not None else default_edge_cap))) + node_cap = min(1000, max(1, int(node_limit or default_node_cap))) + edge_cap = min(2000, max(0, int(edge_limit if edge_limit is not None else default_edge_cap))) nodes = graph["nodes"] ranked_nodes = sorted(nodes, key=lambda node_id: (-nodes[node_id]["scene_rank"], node_id)) ranked_communities = sorted(graph["community_members"], key=lambda community_id: ( @@ -1489,6 +2290,30 @@ def build_graph_scene( canonical_requested = [graph["member_to_canonical"].get(value, value) for value in requested_ids] explicit_requested = {node_id for node_id in canonical_requested if node_id in nodes} + historical_node_ids = { + node_id for node_id, node in nodes.items() if node.get("ghost") + } + ghost_relations = filtered_history_relations + reserved_history_endpoints: set[str] = set() + history_required_node_ids = set(historical_node_ids) + if include_history: + history_required_node_ids.update( + node_id + for edge in ghost_relations + for node_id in (edge["source"], edge["target"]) + if node_id in nodes + ) + if edge_cap: + for edge in sorted(ghost_relations, key=lambda item: ( + -float(item.get("strength") or 0.0), item["id"] + )): + if edge["source"] in nodes and edge["target"] in nodes: + reserved_history_endpoints.update((edge["source"], edge["target"])) + break + # A historical relation is atomic in the UI: returning only one endpoint makes + # the edge disappear and leaves an unexplained ghost. An undersized caller cap + # therefore yields the two endpoints of one deterministic relation. + selection_node_cap = max(node_cap, len(reserved_history_endpoints)) def eligible(node_id: str) -> bool: return nodes[node_id]["entity_quality"] > 0 or node_id in explicit_requested @@ -1528,14 +2353,14 @@ def eligible(node_id: str) -> bool: community_id for community_id in ranked_communities if any(nodes[node_id]["entity_quality"] > 0 for node_id in graph["community_members"][community_id]) - ][:24] + ][:36] chosen_communities.update(overview_communities) anchors = [graph["community_anchors"][community_id] for community_id in overview_communities if nodes[graph["community_anchors"][community_id]]["entity_quality"] > 0] - selected.update(anchors[:node_cap]) + selected.update(anchors[:selection_node_cap]) for node_id in ranked_nodes: - if len(selected) >= node_cap: + if len(selected) >= selection_node_cap: break if (nodes[node_id]["community_id"] in chosen_communities and nodes[node_id]["entity_quality"] > 0): @@ -1549,22 +2374,34 @@ def eligible(node_id: str) -> bool: if eligible(node_id) ) - if len(selected) > node_cap: + if include_history: + # Retain endpoints of ghost relations so forced historical nodes keep + # their explanatory edges even when the other endpoint would not + # otherwise be selected by the overview/community filter. + selected.update(history_required_node_ids) + + if len(selected) > selection_node_cap: forced = { graph["community_anchors"][community_id] for community_id in chosen_communities } forced.add(graph["global_anchor"]) forced.update(explicit_requested) + forced.update(history_required_node_ids) selected = set(sorted( - (node_id for node_id in forced if node_id in selected and eligible(node_id)), + ( + node_id for node_id in forced + if node_id in selected + and (eligible(node_id) or node_id in history_required_node_ids) + ), key=lambda node_id: ( + 0 if node_id in reserved_history_endpoints else 1, 0 if node_id in explicit_requested else 1, 0 if node_id == graph["global_anchor"] else 1, -nodes[node_id]["scene_rank"], node_id, ), - )[:node_cap]) + )[:selection_node_cap]) for node_id in ranked_nodes: - if len(selected) >= node_cap: + if len(selected) >= selection_node_cap: break if eligible(node_id) and ( not chosen_communities or nodes[node_id]["community_id"] in chosen_communities @@ -1572,6 +2409,48 @@ def eligible(node_id: str) -> bool: selected.add(node_id) chosen_communities = {nodes[node_id]["community_id"] for node_id in selected} scene_edges = _selected_edges(graph, selected, level, edge_cap) + total_scene_edges = len(graph["edges"]) + len(ghost_relations) + if include_history: + ghost_relations = [ + edge for edge in ghost_relations + if edge["source"] in selected and edge["target"] in selected + ] + historical_node_ids = { + node_id for node_id in selected if nodes[node_id].get("ghost") + } + reserved_history_edges: list[dict] = [] + sorted_ghost = sorted(ghost_relations, key=lambda item: ( + -float(item.get("strength") or 0.0), item["id"] + )) + if edge_cap and sorted_ghost: + uncovered = set(historical_node_ids) + for edge in sorted_ghost: + touched = { + endpoint for endpoint in (edge["source"], edge["target"]) + if endpoint in historical_node_ids + } + if not touched or not touched.intersection(uncovered): + continue + reserved_history_edges.append(edge) + uncovered.difference_update(touched) + if len(reserved_history_edges) >= edge_cap or not uncovered: + break + if not reserved_history_edges: + # A ghost relation can connect entities that are still live. It + # remains part of the requested history and needs one reserved slot + # even though there is no historical-only endpoint to cover. + reserved_history_edges.append(sorted_ghost[0]) + remaining_capacity = max(0, edge_cap - len(reserved_history_edges)) + scene_edges = _selected_edges( + graph, selected, level, remaining_capacity, + ) + scene_edges.extend(reserved_history_edges) + reserved_set = {edge["id"] for edge in reserved_history_edges} + scene_edges.extend( + edge for edge in sorted_ghost + if edge["id"] not in reserved_set + ) + scene_edges = scene_edges[:edge_cap] communities = _community_summaries(graph, chosen_communities, selected) bridges = _bridges(graph, chosen_communities, 80) @@ -1582,20 +2461,11 @@ def eligible(node_id: str) -> bool: "level": level, "filters": filters or {}, "nodes": [ - ( - node_id, nodes[node_id]["label"], nodes[node_id]["type"], - nodes[node_id]["mass_score"], nodes[node_id]["gravity_mass"], - nodes[node_id]["visual_radius"], nodes[node_id]["community_id"], - nodes[node_id]["anchor_role"], nodes[node_id]["scene_rank"], - ) + (node_id, _hash_record(nodes[node_id])) for node_id in sorted(selected) ], "edges": [ - ( - edge["id"], edge["strength"], edge["rest_length"], - edge["spring_strength"], edge["support_count"], edge["confidence"], - edge["tier"], edge["visible_by_default"], - ) + _hash_record(edge) for edge in sorted(scene_edges, key=lambda item: item["id"]) ], "communities": [ @@ -1618,39 +2488,54 @@ def eligible(node_id: str) -> bool: scene_hash = hashlib.sha256(json.dumps( hash_payload, sort_keys=True, separators=(",", ":") ).encode("utf-8")).hexdigest() - layout_seed = int(scene_hash[:8], 16) + layout_filters = dict(filters or {}) + layout_filters.pop("include_history", None) + layout_hash_payload = { + **hash_payload, + "filters": layout_filters, + "nodes": [ + (node_id, _hash_record(nodes[node_id])) + for node_id in sorted(selected) if not nodes[node_id].get("ghost") + ], + "edges": [ + _hash_record(edge) + for edge in sorted(scene_edges, key=lambda item: item["id"]) + if not edge.get("ghost") + ], + } + layout_hash = hashlib.sha256(json.dumps( + layout_hash_payload, sort_keys=True, separators=(",", ":") + ).encode("utf-8")).hexdigest() + layout_seed = int(layout_hash[:8], 16) - community_positions: dict[str, tuple[float, float]] = {} - for index, community in enumerate(communities): - if graph["global_anchor"] in graph["community_members"][community["id"]]: - community_positions[community["id"]] = (0.0, 0.0) - continue - radius = 145.0 * math.sqrt(index + 1) - angle = GOLDEN_ANGLE * (index + 1) + (layout_seed % 360) * math.pi / 180.0 - community_positions[community["id"]] = ( - radius * math.cos(angle), radius * math.sin(angle) - ) + global_community_id = ( + str(nodes[graph["global_anchor"]]["community_id"]) + if graph["global_anchor"] else "" + ) + community_positions, community_hints = _community_positions( + communities, global_community_id, layout_seed, spacing=98.0 + ) + for community in communities: + community.update(community_hints[community["id"]]) scene_nodes = [] - ranks_by_community: dict[str, int] = defaultdict(int) for node_id in sorted(selected, key=lambda value: (-nodes[value]["scene_rank"], value)): node = dict(nodes[node_id]) community_id = node["community_id"] - center_x, center_y = community_positions.get(community_id, (0.0, 0.0)) - rank = ranks_by_community[community_id] - ranks_by_community[community_id] += 1 - if node["anchor_role"] in {"global", "community"}: - x, y = center_x, center_y - else: - system_radius = next( - item["radius"] for item in communities if item["id"] == community_id + if node.get("ghost") or community_id not in community_positions: + x, y = _ghost_position( + layout_seed, node_id, 98.0 * math.sqrt(len(communities) + 1) ) - orbit = _clamp( - 14.0 + 9.0 + ((1.0 - node["mass_score"]) ** 1.4) - * (system_radius - 18.0), 14.0, system_radius + elif node_id == graph["community_anchors"][community_id]: + x, y = community_positions[community_id] + else: + center_x, center_y = community_positions[community_id] + x, y = _orbit_position( + center_x, center_y, community_id, + orbit_slots[node_id], layout_seed, ) - angle = GOLDEN_ANGLE * (rank + 1) + (layout_seed % 180) * math.pi / 180.0 - x, y = center_x + orbit * math.cos(angle), center_y + orbit * math.sin(angle) node["x"], node["y"] = round(x, 6), round(y, 6) + if community_id in community_hints: + node.update(community_hints[community_id]) node.pop("aliases", None) node.pop("anchor_eligible", None) scene_nodes.append(node) @@ -1662,14 +2547,17 @@ def eligible(node_id: str) -> bool: "scene_hash": scene_hash, "index_generation": index_generation, "total_nodes": len(nodes), - "total_edges": len(graph["edges"]), + "total_edges": total_scene_edges, "shown_nodes": len(scene_nodes), "shown_edges": len(scene_edges), - "truncated": len(scene_nodes) < len(nodes) or len(scene_edges) < len(graph["edges"]), + "truncated": len(scene_nodes) < len(nodes) or len(scene_edges) < total_scene_edges, "query_ms": 0.0, "layout_seed": layout_seed, "index_state": "ready", "filters": filters or {}, + "connected_only": connected_only, + "include_history": include_history, + "include_memory_nodes": include_memory_nodes, "algorithm_version": ALGORITHM_VERSION, }, "nodes": scene_nodes, @@ -1686,7 +2574,7 @@ def strongest_path(graph: dict[str, Any], source: str, target: str, *, target_id = graph["member_to_canonical"].get(target, target) if source_id not in graph["nodes"] or target_id not in graph["nodes"]: return {"found": False, "node_ids": [], "edge_ids": [], "nodes": [], - "edges": [], "cost": None, "hops": 0} + "edges": [], "cost": None, "hops": 0, "visited": 0} adjacency: dict[str, list[tuple[str, dict, float]]] = defaultdict(list) penalties = {"entity": 0.0, "causal": 0.0, "temporal": 0.1, "semantic": 0.2} for edge in graph["edges"]: diff --git a/engraphis/core/graphrank.py b/engraphis/core/graphrank.py index 0689c3e3..3e50f13b 100644 --- a/engraphis/core/graphrank.py +++ b/engraphis/core/graphrank.py @@ -86,7 +86,7 @@ def personalized_pagerank( ordered_nodes = sorted(nodes) node_index = {node: index for index, node in enumerate(ordered_nodes)} n_nodes = len(ordered_nodes) - seed_ids = [node_index[seed] for seed in seeds if seed in node_index] + seed_ids = list(dict.fromkeys(node_index[seed] for seed in seeds if seed in node_index)) live_seeds = [seed for seed in seeds if seed in adjacency and adjacency[seed]] if not seed_ids or not live_seeds: return {} diff --git a/engraphis/core/recall.py b/engraphis/core/recall.py index 4dc032fa..af2bbcde 100644 --- a/engraphis/core/recall.py +++ b/engraphis/core/recall.py @@ -69,7 +69,12 @@ inspection_eligible, prompt_eligible, ) -from engraphis.core.store import Store, memory_matches_filter, now_ts +from engraphis.core.store import ( + Store, + _is_memory_database_path, + memory_matches_filter, + now_ts, +) from engraphis.core.textutil import jaccard, tokenize @@ -217,10 +222,7 @@ def recall(self, query: str, flt: Optional[SearchFilter] = None, *, k: int = 8, config = arm_config or profile_config(selected_profile) capabilities = embedder_capabilities(self.embedder) vector_search_ready = bool(capabilities["semantic_support"]) - persistent_store = ( - self.store.path != ":memory:" - and not self.store.path.startswith("file::memory:") - ) + persistent_store = not _is_memory_database_path(self.store.path) if vector_search_ready and persistent_store: fingerprint = embedding_space_fingerprint(self.embedder) vector_search_ready = bool( diff --git a/engraphis/core/store.py b/engraphis/core/store.py index 47112313..4880147f 100644 --- a/engraphis/core/store.py +++ b/engraphis/core/store.py @@ -24,7 +24,9 @@ import weakref from contextlib import contextmanager from pathlib import Path -from typing import Any, Callable, Iterable, Optional, Protocol +from typing import Any, Callable, Iterable, Optional, Protocol, cast +from urllib.parse import parse_qs, unquote, urlsplit + import numpy as np @@ -81,6 +83,41 @@ ENTITY_BLOCK_TOKEN_CHUNK = 200 # Do not materialize unbounded common-token buckets during migration/live writes. ENTITY_BLOCK_BUCKET_LIMIT = 1024 + +def _is_memory_database_path(path: str) -> bool: + """Detect SQLite memory databases including named shared-memory URIs. + + Handles ``:memory:``, ``file::memory:``, and ``file:name?mode=memory`` + (with any query parameter order). + """ + text = str(path or "") + if not text or text == ":memory:": + return True + if not text.startswith("file:"): + return False + parsed = urlsplit(text.replace("\\", "/")) + uri_path = unquote(parsed.path) + if uri_path == ":memory:": + return True + query = parse_qs(parsed.query) + return "memory" in query.get("mode", []) + + +def _physical_sqlite_path(path: str) -> str: + """Return the filesystem path represented by a regular SQLite file URI.""" + text = str(path) + if not text.startswith("file:"): + return text + parsed = urlsplit(text.replace("\\", "/")) + if parsed.scheme != "file" or not parsed.path: + raise ValueError("file database URI must include a path") + uri_path = unquote(parsed.path) + if parsed.netloc and parsed.netloc != "localhost": + uri_path = f"//{parsed.netloc}{uri_path}" + from urllib.request import url2pathname + return str(Path(url2pathname(uri_path)).expanduser()) + +_SQLITE_CONNECT_TIMEOUT_SECONDS = 120.0 _LLM_CONSOLIDATION_REPAIR_STATE_KEY = "__schema_v11_llm_consolidation_trust_repair" _LLM_CONSOLIDATION_REPAIR_STATE_VALUE = "complete" _LLM_EXTRACTION_REPAIR_STATE_KEY = "__schema_v12_llm_extraction_trust_repair" @@ -1075,10 +1112,18 @@ def __init__(self, path: str = ":memory:", *, :class:`ReadOnlyConnector` ``open_read_only(path)`` contract; a bare writable callable is rejected before it can be invoked. """ - self.path = path + # Keep named shared-memory URIs intact for lifecycle bookkeeping. A URI such + # as ``file:shared?mode=memory&cache=shared`` is a logical SQLite database, + # not a filesystem path named ``shared``; reducing it here would let migration + # backups or secure-erase discovery create/inspect unrelated disk files. + self.path = ( + path if _is_memory_database_path(path) + else _physical_sqlite_path(path) if str(path).startswith("file:") + else path + ) self._connect = connect self.read_only = bool(read_only) - if self.read_only and path == ":memory:": + if self.read_only and _is_memory_database_path(path): raise ValueError("read-only Store requires an existing database file") read_only_path: Optional[str] = None if self.read_only: @@ -1090,8 +1135,8 @@ def __init__(self, path: str = ":memory:", *, "open_read_only(path) method" ) read_only_path = self._preflight_read_only_path(path) - if path != ":memory:" and not self.read_only: - Path(path).parent.mkdir(parents=True, exist_ok=True) + if not _is_memory_database_path(path) and not self.read_only: + Path(_physical_sqlite_path(path)).parent.mkdir(parents=True, exist_ok=True) raw_conn = self._open_connection(read_only_path or path) # Serialize the shared connection so concurrent threadpool handlers can't interleave # transactions on it (see _SerializedConnection). All Store/service/backend access @@ -1150,10 +1195,21 @@ def _open_connection(self, path: str): return self._connect.open_read_only(path) # type: ignore[attr-defined] return self._connect(path) if self.read_only: - uri = Path(path).resolve().as_uri() + "?mode=ro&immutable=1" - conn = sqlite3.connect(uri, uri=True, timeout=30, check_same_thread=False) + uri = Path(_physical_sqlite_path(path)).resolve().as_uri() + "?mode=ro&immutable=1" + conn = sqlite3.connect( + uri, uri=True, timeout=_SQLITE_CONNECT_TIMEOUT_SECONDS, + check_same_thread=False, + ) + elif str(path).startswith("file:"): + # Preserve all SQLite URI options, including mode=ro/rw and immutable. + conn = sqlite3.connect( + path, uri=True, timeout=_SQLITE_CONNECT_TIMEOUT_SECONDS, + check_same_thread=False, + ) else: - conn = sqlite3.connect(path, timeout=30, check_same_thread=False) + conn = sqlite3.connect( + path, timeout=_SQLITE_CONNECT_TIMEOUT_SECONDS, check_same_thread=False, + ) conn.row_factory = sqlite3.Row return conn @@ -1166,7 +1222,7 @@ def _preflight_read_only_path(path: str) -> str: refused because an immutable connection would skip recovery and silently expose an incomplete snapshot. """ - candidate = Path(path) + candidate = Path(_physical_sqlite_path(path)) try: info = os.lstat(candidate) except OSError: @@ -1658,10 +1714,78 @@ def _fsync_backup_parent(path: str) -> None: @staticmethod def _logical_digest(conn) -> str: + # ``iterdump()`` asks SQLite to read every table, including optional virtual + # tables whose extension is not loaded on the short-lived backup connection + # (for example sqlite-vec's ``vec0`` table during Store startup). A serialized + # main database avoids that module lookup on Python builds that expose it. + serialize = getattr(conn, "serialize", None) + if callable(serialize): + payload = bytearray(cast(Callable[[], bytes], serialize)()) + # SQLite may advance the change-counter, schema-cookie, and + # version-valid-for header fields while materializing an online backup. + # They describe the file image's write history, not its logical contents. + for offset in (24, 40, 92): + if len(payload) >= offset + 4: + payload[offset:offset + 4] = b"\x00" * 4 + digest = hashlib.sha256() + digest.update(payload) + return digest.hexdigest() + + # Python 3.9/3.10 and several SQLCipher adapters do not expose serialize(). + # Keep this fallback extension-agnostic: hash schema metadata and ordinary + # table rows directly, while recording (but never querying) virtual tables. + # ``iterdump()`` is deliberately not used here because it invokes each virtual + # table's module even when the verifier only needs an equality check. digest = hashlib.sha256() - for statement in conn.iterdump(): - digest.update(statement.encode("utf-8")) - digest.update(b"\n") + schema_rows = conn.execute( + "SELECT type, name, tbl_name, COALESCE(sql, '') FROM sqlite_master " + "ORDER BY type, name, tbl_name" + ).fetchall() + virtual_tables: set[str] = set() + for row in schema_rows: + object_type = str(row[0] or "") + name = str(row[1] or "") + table_name = str(row[2] or "") + sql = str(row[3] or "") + digest.update( + (object_type + "\x00" + name + "\x00" + table_name + "\x00" + + sql + "\n").encode("utf-8") + ) + if object_type == "table" and re.search( + r"\bcreate\s+virtual\s+table\b", sql, re.IGNORECASE, + ): + virtual_tables.add(name) + + def quote_identifier(value: str) -> str: + return '"' + value.replace('"', '""') + '"' + + for row in schema_rows: + if str(row[0] or "") != "table": + continue + table_name = str(row[1] or "") + if table_name in virtual_tables: + continue + columns = conn.execute( + "PRAGMA table_info(" + quote_identifier(table_name) + ")" + ).fetchall() + digest.update(("TABLE\x00" + table_name + "\x00").encode("utf-8")) + if not columns: + continue + expressions = ", ".join( + "quote(" + quote_identifier(str(column[1])) + ")" + for column in columns + ) + order_by = ", ".join( + quote_identifier(str(column[1])) for column in columns + ) + for values in conn.execute( + "SELECT " + expressions + " FROM " + quote_identifier(table_name) + + " ORDER BY " + order_by + ).fetchall(): + digest.update( + ("\x00".join(str(value) for value in values) + "\n") + .encode("utf-8") + ) return digest.hexdigest() def _cleanup_v4_backup_temps(self, backup_path: str) -> None: @@ -1674,12 +1798,25 @@ def _cleanup_v4_backup_temps(self, backup_path: str) -> None: return changed = False for entry in entries: - if not pattern.fullmatch(entry.name): + stage_match = pattern.fullmatch(entry.name) + sidecar_match = re.fullmatch( + r"^%s\.tmp-[0-9]+-[0-9]+-[0-9]+-(?:journal|wal|shm)$" + % re.escape(stable.name), + entry.name, + ) + if not stage_match and not sidecar_match: continue try: info = os.lstat(str(entry)) if not stat.S_ISREG(info.st_mode): continue + # A sidecar without its randomized stage is residue from a failed + # attempt. If the stage still exists, leave both alone: another + # process may still be writing that private snapshot. + if sidecar_match: + stage = entry.with_name(entry.name.rsplit("-", 1)[0]) + if stage.exists(): + continue if getattr(info, "st_nlink", 1) == 1: entry.unlink() changed = True @@ -1712,7 +1849,7 @@ def _backup_before_v4_migration(self, *, previous_version: int = 0) -> str: Preserve the legacy v4/v5 names and use the target schema version for newer backups. """ - if self.path in (":memory:", "") or self.path.startswith("file::memory:"): + if not self.path or _is_memory_database_path(self.path): raise RuntimeError("schema migration requires a durable pre-migration backup") backup_version = max(4, min(SCHEMA_VERSION, previous_version + 1)) backup_path = f"{self.path}.pre-migration-v{backup_version}.bak" @@ -1801,9 +1938,23 @@ def _backup_before_v4_migration(self, *, previous_version: int = 0) -> str: os.unlink(temp_path) except OSError: pass + for suffix in ("-journal", "-wal", "-shm"): + try: + sidecar = temp_path + suffix + if os.path.exists(sidecar): + os.unlink(sidecar) + except OSError: + pass + lowered = str(exc).casefold() + if "locked" in lowered or "busy" in lowered: + reason = "the database is busy" + elif "no such module" in lowered: + reason = "an optional SQLite extension could not be loaded" + else: + reason = "backup verification failed" raise RuntimeError( f"schema v{backup_version} migration aborted: could not create and verify the " - "pre-migration backup" + f"pre-migration backup ({reason})" ) from exc def _execute_script_transactional(self, script: str) -> None: @@ -2020,7 +2171,15 @@ def _restore_source_manifest_v15(self) -> None: self.conn.execute(f"DROP TABLE temp.{name}") # ── schema ────────────────────────────────────────────────────────────── - def init_schema(self) -> None: + def _schema_migration_state(self) -> tuple[int, bool]: + """Read schema state and cache the shape repairs needed by ``_apply_schema``. + + This is intentionally repeatable. A second process can inspect an old schema, + wait for the first process to finish its migration, and then acquire the writer + lock with stale observations. Re-reading the state under that lock lets it + recognize the completed migration instead of creating a second backup or + replaying transforms against the now-current database. + """ objects = self.conn.execute( "SELECT name FROM sqlite_master WHERE type IN ('table','view','index','trigger') " "AND name NOT LIKE 'sqlite_%'" @@ -2094,10 +2253,20 @@ def init_schema(self) -> None: or tombstones_need_export_class or sync_exports_need_table ) + return previous_version, needs_backup + + def init_schema(self) -> None: + # Capture the pre-lock view as well. A competing process may read the old + # version before it waits on BEGIN IMMEDIATE; the authoritative view is still + # re-read below after that wait completes. + self._schema_migration_state() try: # Reserve the writer before the snapshot. This is read/locking state only; # every schema/data transform remains inside the transaction below. + # Re-read after waiting: another process may have completed the whole + # migration while this connection was waiting on BEGIN IMMEDIATE. self.conn.execute("BEGIN IMMEDIATE") + previous_version, needs_backup = self._schema_migration_state() if needs_backup: self._backup_before_v4_migration(previous_version=previous_version) self._apply_schema(previous_version) @@ -3745,7 +3914,7 @@ def _authorize_workspace(self, name: str) -> str: """When this Store is bound to a workspace allow-list, refuse to create or retrieve a workspace outside it. This is the hard isolation boundary applied at the persistence layer so no caller (including a future sync path) can - bypass ENGRAPHIS_WORKSPACES by going directly to Store instead of through + bypass an explicit service binding by going directly to Store instead of through MemoryService.""" if self.allowed_workspaces is not None and name not in self.allowed_workspaces: raise ValueError(f"workspace '{name}' is not permitted on this instance") @@ -4669,6 +4838,10 @@ def put_vector(self, memory_id: str, vec: np.ndarray, *, model: str = "") -> Non norm = float(np.linalg.norm(v.astype(np.float64, copy=False))) if norm > 0: v = v / norm + # Pin back to float32 after division. NumPy 1.24-1.26 value-based promotion + # can widen to float64 when the Python float norm is not losslessly + # representable in float32, corrupting tobytes() byte count downstream. + v = v.astype(np.float32, copy=False) self.conn.execute( "INSERT OR REPLACE INTO mem_vectors(id, dim, vector, model) VALUES (?,?,?,?)", (memory_id, int(v.shape[0]), v.tobytes(), model), @@ -4891,9 +5064,19 @@ def _secure_erase_targets(cls, conn, memory_id: str) -> list[str]: """Include deterministic sync-conflict successors in one erase operation.""" if not cls._has_table(conn, "memories"): return [memory_id] + primary = conn.execute( + "SELECT id, workspace_id, repo_id FROM memories WHERE id=?", + (memory_id,), + ).fetchone() + if primary is None: + return [memory_id] rows = conn.execute( - "SELECT id, metadata, provenance FROM memories" + """SELECT id, workspace_id, repo_id, metadata, provenance + FROM memories + WHERE workspace_id IS ? AND repo_id IS ?""", + (primary["workspace_id"], primary["repo_id"]), ).fetchall() + same_authority_ids = {str(row["id"]) for row in rows} parents: dict[str, set[str]] = {} for row in rows: metadata = _loads(row["metadata"], {}) @@ -4901,12 +5084,22 @@ def _secure_erase_targets(cls, conn, memory_id: str) -> list[str]: metadata = metadata if isinstance(metadata, dict) else {} provenance = provenance if isinstance(provenance, dict) else {} sync_conflict = metadata.get("sync_conflict") - candidates = {provenance.get("conflict_of")} - if isinstance(sync_conflict, dict): - candidates.add(sync_conflict.get("memory_id")) + provenance_parent = provenance.get("conflict_of") + metadata_parent = ( + sync_conflict.get("memory_id") + if isinstance(sync_conflict, dict) else None + ) + # A caller-controlled pointer is only a valid erase lineage when it + # resolves to a memory in the primary's authoritative workspace/repo. + # If both persisted envelopes claim a parent, they must agree; otherwise + # an untrusted row could widen the destructive target set by smuggling a + # foreign ID through one of the two fields. + if provenance_parent and metadata_parent and provenance_parent != metadata_parent: + continue + candidates = {provenance_parent, metadata_parent} for parent in candidates: parent_id = str(parent or "") - if parent_id: + if parent_id and parent_id in same_authority_ids: parents.setdefault(parent_id, set()).add(str(row["id"])) targets = [memory_id] seen = {memory_id} @@ -4917,6 +5110,15 @@ def _secure_erase_targets(cls, conn, memory_id: str) -> list[str]: targets.append(child) return targets + def secure_erase_target_ids(self, memory_id: str) -> list[str]: + """Return the local memory IDs covered by a secure erase without mutating state. + + ``MemoryEngine`` uses this read-only view to coordinate deletion with an injected + external vector index. The destructive operation accepts the same ordered IDs so + both stores cannot silently diverge when a sync-conflict successor is present. + """ + return self._secure_erase_targets(self.conn, memory_id) + @classmethod def _erase_memory_rows(cls, conn, memory_id: str, *, actor: str = "user") -> dict: """Remove a memory and all known local derivatives from one SQLite database. @@ -4950,7 +5152,7 @@ def _erase_memory_rows(cls, conn, memory_id: str, *, actor: str = "user") -> dic name for name in ( "mem_fts", "mem_vectors", "mem_vec_ann", "code_memory_links", "memory_entities", "edge_supports", "edges", "entities", "mem_links", - "audit", + "source_imports", "source_import_items", "audit", ) if cls._has_table(conn, name) } incident_entities: list[str] = [] @@ -4968,8 +5170,17 @@ def _erase_memory_rows(cls, conn, memory_id: str, *, actor: str = "user") -> dic ("mem_fts", "id"), ("mem_vectors", "id"), ("mem_vec_ann", "id"), ("code_memory_links", "memory_id"), ("memory_entities", "memory_id"), ("edge_supports", "memory_id"), + ("source_imports", "memory_id"), ): if table in tables: + if table == "source_imports" and "source_import_items" in tables: + # source_id uses ON DELETE SET NULL, so erase per-job paths before + # deleting the manifest row that identifies them. + conn.execute( + "DELETE FROM source_import_items WHERE source_id IN " + "(SELECT id FROM source_imports WHERE memory_id=?)", + (memory_id,), + ) conn.execute(f"DELETE FROM {table} WHERE {column}=?", (memory_id,)) if "mem_links" in tables: conn.execute("DELETE FROM mem_links WHERE a=? OR b=?", (memory_id, memory_id)) @@ -5108,6 +5319,11 @@ def _checkpoint_and_vacuum(conn, *, durable: bool) -> dict: result["wal"] = "failed" return result + def run_secure_erase_maintenance(self) -> dict: + """Run physical cleanup after the secure-erase transaction has committed.""" + durable = bool(self.path) and not _is_memory_database_path(self.path) + return self._checkpoint_and_vacuum(self.conn, durable=durable) + def _recognised_local_backups(self) -> list[Path]: """Return recovery artefacts this Store created and can safely identify. @@ -5115,7 +5331,7 @@ def _recognised_local_backups(self) -> list[Path]: another process's encrypted backup location. Those remain an explicit operator obligation in the secure-erasure result and documentation. """ - if self.path in (":memory:", "") or self.path.startswith("file::memory:"): + if not self.path or _is_memory_database_path(self.path): return [] primary = Path(self.path).resolve() parent = primary.parent @@ -5145,7 +5361,10 @@ def _recognised_local_backups(self) -> list[Path]: continue return sorted(set(found), key=lambda value: str(value)) - def secure_erase_memory(self, memory_id: str, *, actor: str = "user") -> dict: + def secure_erase_memory( + self, memory_id: str, *, actor: str = "user", + _target_ids: Optional[Iterable[str]] = None, + _defer_maintenance: bool = False) -> dict: """Irreversibly erase one memory plus local index copies and known backups. This is a breach-remediation operation, not the normal ``retire`` lifecycle. @@ -5153,14 +5372,27 @@ def secure_erase_memory(self, memory_id: str, *, actor: str = "user") -> dict: state, audit details for that record, WAL contents when SQLite can checkpoint, and recognised local SQLite recovery backups. OS snapshots, copies, remote sync peers, and a process that already read the secret cannot be recalled or erased. + + Physical maintenance is deferred when this method participates in an outer + transaction. Call :meth:`run_secure_erase_maintenance` after that transaction + commits; running ``VACUUM`` or a WAL checkpoint before then is invalid. """ owns_transaction = not self.conn.transaction_owned_by_current_thread() try: - # Mint the origin before opening the erase transaction. ``device_id`` may - # need to write sync metadata on a new database; keeping that write outside - # the destructive transaction means the deletion and terminal tombstone - # commit (or roll back) as one unit. + # Hold the write lock while the authoritative target set is read and erased. + # A vector-index coordinator may pass a snapshot from before its external + # delete, but a sync/write successor that commits before this boundary must + # be included in the same destructive operation. + if owns_transaction: + self.conn.execute('BEGIN IMMEDIATE') + # Mint the origin before opening the erase transaction. The device identity may + # need to write sync metadata on a new database; keeping that write outside + # the destructive transaction means the deletion and terminal tombstone + # commit (or roll back) as one unit. device_id = self.device_id() + # Recompute under the transaction even when the engine supplies its earlier + # vector-index snapshot. Keep accepting ``_target_ids`` for API compatibility, + # but never let a stale or forged list widen the authoritative target set. targets = self._secure_erase_targets(self.conn, memory_id) current_rows = [] for target_id in targets: @@ -5202,8 +5434,11 @@ def secure_erase_memory(self, memory_id: str, *, actor: str = "user") -> dict: if owns_transaction and self.conn.transaction_owned_by_current_thread(): self.conn.rollback() raise - durable = self.path not in (":memory:", "") and not self.path.startswith("file::memory:") - maintenance = self._checkpoint_and_vacuum(self.conn, durable=durable) + maintenance = ( + {"secure_delete": True, "wal": "deferred", "vacuum": "deferred"} + if _defer_maintenance or not owns_transaction + else self.run_secure_erase_maintenance() + ) backup_processed = 0 backup_failed = 0 @@ -6188,7 +6423,13 @@ def edge_supports_in_scope(self, edge_ids: Optional[list[str]] = None, *, "SELECT s.id, s.edge_id, s.memory_id, s.source_kind, s.confidence, " "s.valid_from, s.valid_to, s.valid_to_recorded_at, " "s.ingested_at, s.expired_at, s.provenance " - "FROM edge_supports s JOIN edges e ON e.id=s.edge_id " + # Keep requested support IDs as the outer lookup. SQLite otherwise + # reorders this inner join to scan every workspace edge for *each* IN + # clause chunk, then probes its supports. Scene construction can pass + # tens of thousands of selected edge IDs, making that legal plan dominate + # load time. CROSS JOIN fixes only loop order; its ON predicate retains + # the exact inner-join and scope/temporal semantics. + "FROM edge_supports s CROSS JOIN edges e ON e.id=s.edge_id " "WHERE (s.valid_from IS NULL OR s.valid_from<=?) " "AND (s.valid_to IS NULL OR ? list[dict]: ).fetchall() return [_public_receipt_row(dict(row)) for row in rows] + @staticmethod + def _receipt_workspace_scope( + *, workspace_id: Optional[str], workspace_ids: Optional[Iterable[str]], + ) -> tuple[list[tuple[str, list[str]]], Optional[list[str]]]: + """Build bounded receipt predicates and retain the exact verification scope.""" + if workspace_id is not None and workspace_ids is not None: + raise ValueError("workspace_id and workspace_ids are mutually exclusive") + if workspace_id is not None: + ids = [str(workspace_id)] + return [("workspace_id=?", ids)], ids + if workspace_ids is None: + return [("1=1", [])], None + ids = list(dict.fromkeys(str(value) for value in workspace_ids)) + if not ids: + return [("1=0", [])], ids + scopes: list[tuple[str, list[str]]] = [] + for start in range(0, len(ids), IN_CLAUSE_CHUNK): + chunk = ids[start:start + IN_CLAUSE_CHUNK] + placeholders = ",".join("?" for _ in chunk) + scopes.append((f"workspace_id IN ({placeholders})", chunk)) + return scopes, ids + def context_savings( self, *, - workspace_id: str, + workspace_id: Optional[str] = None, + workspace_ids: Optional[Iterable[str]] = None, repo_id: Optional[str] = None, from_ts: Optional[float] = None, to_ts: Optional[float] = None, @@ -7872,8 +8136,10 @@ def context_savings( Token counts are kept separate by counter identity: a tokenizer change must not turn into a misleading cumulative total. Invalid, missing, and incomplete receipts remain visible only as counts; their payload is never reflected into this summary. The - workspace-wide receipt-chain validity is returned alongside any repo-scoped aggregate - so callers can distinguish useful local accounting from evidence eligible for audit. + receipt-chain validity is returned alongside the aggregate so callers can distinguish + useful local accounting from evidence eligible for audit. Pass ``workspace_ids`` when + the caller has an authorization-filtered set of workspaces; omit both workspace + arguments to aggregate the complete local history. """ if from_ts is not None and not math.isfinite(float(from_ts)): raise ValueError("from_ts must be finite") @@ -7886,23 +8152,53 @@ def context_savings( if not normalized_release: raise ValueError("release_version must be a semantic version") release_version = normalized_release - verification = self.verify_receipts(workspace_id=workspace_id) - where = "workspace_id=?" - params: list[Any] = [workspace_id] - if repo_id is not None: - where += " AND repo_id=?" - params.append(repo_id) - if from_ts is not None: - where += " AND ts>=?" - params.append(float(from_ts)) - if to_ts is not None: - where += " AND ts=?" + params.append(float(from_ts)) + if to_ts is not None: + where += " AND ts dict: if ( receipt.get("invalid_payload") or receipt.get("scope_digest") - != _receipt_scope_digest(workspace_id, raw_row["repo_id"]) + != _receipt_scope_digest( + workspace_id if workspace_id is not None else str(raw_row["workspace_id"] or ""), + raw_row["repo_id"], + ) ): if release_version is None: totals["receipt_count"] += 1 @@ -8163,7 +8462,9 @@ def finish_estimate(target: dict, label: str) -> dict: def context_savings_grouped( - self, *, workspace_id: str, repo_id: Optional[str] = None, + self, *, workspace_id: Optional[str] = None, + workspace_ids: Optional[Iterable[str]] = None, + repo_id: Optional[str] = None, group_by: str = "workspace", from_ts: Optional[float] = None, to_ts: Optional[float] = None, @@ -8193,22 +8494,27 @@ def context_savings_grouped( if not normalized_release: raise ValueError("release_version must be a semantic version") release_version = normalized_release - where = "workspace_id=?" - params: list[Any] = [workspace_id] - if repo_id is not None: - where += " AND repo_id=?" - params.append(repo_id) - if from_ts is not None: - where += " AND ts>=?" - params.append(float(from_ts)) - if to_ts is not None: - where += " AND ts=?" + params.append(float(from_ts)) + if to_ts is not None: + where += " AND ts dict: @@ -8237,7 +8543,10 @@ def _add(target: dict, usage: dict) -> None: if ( receipt.get("invalid_payload") or receipt.get("scope_digest") - != _receipt_scope_digest(workspace_id, raw_row["repo_id"]) + != _receipt_scope_digest( + workspace_id if workspace_id is not None else str(raw_row["workspace_id"] or ""), + raw_row["repo_id"], + ) ): continue metadata = receipt.get("metadata") @@ -8270,19 +8579,18 @@ def _add(target: dict, usage: dict) -> None: ): continue if group_by == "workspace": - key = workspace_id + key = str(raw_row["workspace_id"] or "(none)") elif group_by == "repo": key = str(raw_row["repo_id"] or "(none)") elif group_by == "agent": key = str(raw_row["actor"] or "system") elif group_by == "day": try: - day = _time.strftime("%Y-%m-%d", _time.gmtime(float(raw_row["ts"]))) + day = time.strftime("%Y-%m-%d", time.gmtime(float(raw_row["ts"]))) except (TypeError, ValueError, OverflowError, OSError): day = "unknown" key = day - else: - key = workspace_id + # group_by is validated above; no fallback needed token_counter = str(usage.get("token_counter") or "unknown") grp = groups.setdefault((key, token_counter), _bucket()) _add(grp, usage) diff --git a/engraphis/core/sync.py b/engraphis/core/sync.py index bb2b480a..c6b5a0e0 100644 --- a/engraphis/core/sync.py +++ b/engraphis/core/sync.py @@ -81,6 +81,7 @@ Store, TOMBSTONE_NEVER_EXPORT, TOMBSTONE_REMOTE_ERASURE, + _is_memory_database_path, now_ts, ) @@ -1407,11 +1408,15 @@ def _apply_one(self, d: dict, rec, report: dict, accepted: dict, known: dict, rec.metadata, PoisoningDecision(True, reasons=merged_reasons) ) rec.provenance = dict(rec.metadata["provenance"]) - at = existing.valid_to if existing.valid_to is not None else now_ts() - # Preserve the locally governed interval rather than letting a peer's - # LWW timestamps reactivate or future-date a quarantined record. + # Preserve the locally governed start boundary rather than letting a peer's + # LWW timestamps reactivate or future-date a quarantined record. A peer + # overwrite closes an open quarantined interval at the sync boundary, which + # keeps the replaced payload out of ordinary retrieval while retaining its + # history for governed inspection. rec.valid_from = existing.valid_from - rec.valid_to = at + rec.valid_to = ( + existing.valid_to if existing.valid_to is not None else now_ts() + ) rec.valid_to_recorded_at = now_ts() rec.embedding = None if existing is not None: @@ -1877,10 +1882,7 @@ def _write( ``commit=False`` leaves the transaction open for the caller's batch (apply_bundle).""" quarantined = metadata_is_quarantined(rec.metadata) external_index_action = None - persistent_store = ( - self.store.path != ":memory:" - and not self.store.path.startswith("file::memory:") - ) + persistent_store = not _is_memory_database_path(self.store.path) embedder = self.embedder rebuild_target = ( self.store.embedding_rebuild_target() if persistent_store else None diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index c39d8aa7..8398cf5e 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -381,8 +381,7 @@ async def _license_error(request: Request, exc: licensing.LicenseError): embed_dim=settings.embed_dim if settings.embed_dim is not None else 384, vector_backend=settings.vector_backend, rerank_model=getattr(settings, "rerank_model", "") or None, - rerank_revision=getattr(settings, "rerank_revision", "") or None, - allowed_workspaces=settings.allowed_workspaces) + rerank_revision=getattr(settings, "rerank_revision", "") or None) def _discard_unbound_service() -> None: try: @@ -456,13 +455,23 @@ def _discard_unbound_service() -> None: @app.get("/api/auth/state", include_in_schema=False) def local_auth_state(): - """Describe the local token gate without exposing hosted Team endpoints.""" + """Describe the local token gate and deployment mode. + + In local mode, hosted URLs are suppressed to prevent accidental org joins. + The ``deployment_mode`` field lets the dashboard UI show a clear indicator. + """ + from engraphis.config import deployment_mode, is_local_mode + mode = deployment_mode() + local = is_local_mode() + cloud_url = "" if local else licensing.upgrade_url("team") return { "enabled": bool(settings.api_token), "mode": "local-token" if settings.api_token else "open", + "deployment_mode": mode, "user": None, - "hosted_team": True, - "cloud_url": licensing.upgrade_url("team"), + "hosted_team": not local, + "cloud_url": cloud_url, + "local_invitations": local, } @app.post("/api/auth/session", include_in_schema=False) diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index cfdeead0..49ed5d8c 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -9,6 +9,7 @@ with both the dashboard adapter and standalone scene payloads. */ (function () { const PRESETS = { + galaxy: { label: 'Galaxy gravity', repel: 60, link: 8, gravity: 48, font: 12, size: 3, linkw: 0.72, labelDensity: 24, curve: 0.12, particles: 0 }, original: { label: 'Original force', repel: 120, link: 30, gravity: 14, font: 13, size: 3, linkw: 1, labelDensity: 40, curve: 0, particles: 0 }, compact: { label: 'Compact clusters', repel: 42, link: 20, gravity: 26, font: 12, size: 3, linkw: 0.7, labelDensity: 30, curve: 0.08, particles: 0 }, communities: { label: 'Community islands', repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72, labelDensity: 24, curve: 0.12, particles: 0 }, @@ -77,88 +78,5941 @@ makes the gravity control compact/expand the layout, and leaves the UI responsive. */ const FULL_FORCE_NODE_LIMIT = LARGE_NODE_LIMIT; const FULL_FORCE_LINK_LIMIT = LARGE_LINK_LIMIT; + /* The v2 overview scene is bounded at 1,000 nodes / 2,000 edges. Galaxy keeps that + complete overview physical even after the canvas enters its cheaper 600-node material + tier. Non-Galaxy complete snapshots retain the older FULL_FORCE_* fallback. */ + const GALAXY_LIVE_NODE_LIMIT = 1000; + const GALAXY_LIVE_LINK_LIMIT = 2000; + function galaxySceneWithinLiveLimit(data) { + const scene = data || {}; + return (scene.nodes || []).length <= GALAXY_LIVE_NODE_LIMIT + && (scene.links || []).length <= GALAXY_LIVE_LINK_LIMIT; + } + const GALAXY_EXACT_LIMIT = 64; + const GALAXY_BARNES_HUT_THETA = 0.85; + const GALAXY_GRAVITY_MAXIMUM = 400; + /* The emergency acceleration cap follows the full visible strength range. Direct callers can + still pass pathological values, but those values clamp to the same 0..400 physics ceiling. */ + const GALAXY_GRAVITY_CAP_REFERENCE = GALAXY_GRAVITY_MAXIMUM; + /* One response curve owns every physical layer. It retains the positive quadratic response + and two C1 smooth boost stages. Local gravity is exactly 120 at the default. Unannotated + compatibility graphs retain the raw zero endpoint; an explicit painted black hole applies + the small orbital floor below so the dashboard's "loose" setting never stops the galaxy. + Independent community stars apply their named minimum and faster clock afterward. */ + function galaxySmoothstep(value) { + const raw = Number(value); + const t = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : 0; + return t * t * (3 - 2 * t); + } + function galaxyGravityConstant(setting) { + const raw = Number(setting); + const value = Number.isFinite(raw) ? Math.max(0, Math.min(GALAXY_GRAVITY_MAXIMUM, raw)) : 0; + const base = value * (772 + 11 * value) / 2600; + const boost = 1 + 0.25 * galaxySmoothstep(value / 48) + + 0.25 * galaxySmoothstep((value - 48) / 52); + return base * boost * 4; + } + /* Gravity strength is a black-hole control first. A visible, explicit black hole retains a + small orbital floor at the loosest slider setting: "loose" must never mean that the + painted galaxy silently stops. Compatibility callers without the global annotation keep + the true zero endpoint, so we do not invent a black-hole well for an ordinary local graph. */ + const GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING = 24; + function galaxyBlackHoleGravitySetting(setting, explicitGlobal) { + const raw = Number(setting); + const value = Number.isFinite(raw) ? Math.max(0, Math.min(GALAXY_GRAVITY_MAXIMUM, raw)) : 0; + return explicitGlobal === true ? Math.max(GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, value) : value; + } + function galaxyBlackHoleGravityConstant(setting, explicitGlobal) { + return galaxyGravityConstant(galaxyBlackHoleGravitySetting(setting, explicitGlobal)) * 2; + } + function galaxyLocalGravityConstant(setting) { + return galaxyBlackHoleGravityConstant(setting) * 0.5; + } + /* A fit-to-view galaxy compresses stellar and galactic distances onto one canvas, so using + one physical clock made a valid planet orbit visually disappear under its system's + black-hole sweep. Give independent community stars a 2.5x angular clock by multiplying + their gravitational parameter by clock^2. Both the circular seed and every live + inverse-square sample consume this same constant: the result is a faster bound central + orbit, not a per-frame carousel or an unbalanced tangential kick. The global anchor keeps + the original local scale because its surrounding bulge belongs to the black-hole well. */ + const GALAXY_STELLAR_ORBIT_CLOCK = 2.5; + /* The dashboard's Gravity control owns the black-hole well. A saved zero value must not + erase either level of the hierarchy: eligible community stars retain the calibrated + default stellar well, while the explicit global anchor uses the smaller floor above. */ + const GALAXY_STELLAR_GRAVITY_FLOOR_SETTING = 48; + function galaxyStellarGravitySetting(setting) { + const raw = Number(setting); + const value = Number.isFinite(raw) + ? Math.max(0, Math.min(GALAXY_GRAVITY_MAXIMUM, raw)) : 0; + return Math.max(GALAXY_STELLAR_GRAVITY_FLOOR_SETTING, value); + } + function galaxyStellarGravityConstant(setting) { + return galaxyLocalGravityConstant(galaxyStellarGravitySetting(setting)) + * GALAXY_STELLAR_ORBIT_CLOCK * GALAXY_STELLAR_ORBIT_CLOCK; + } + function galaxyFallbackStellarGravityConstant(setting) { + return galaxyLocalGravityConstant(setting) + * GALAXY_STELLAR_ORBIT_CLOCK * GALAXY_STELLAR_ORBIT_CLOCK; + } + function galaxySystemGravityConstant(anchor, setting) { + if (anchor && anchor.anchor_role === 'global') { + return galaxyBlackHoleGravityConstant(setting, true) * 0.5; + } + if (anchor && anchor.anchor_role === 'community') { + return galaxyStellarGravityConstant(setting); + } + return galaxyFallbackStellarGravityConstant(setting); + } + function defaultGalaxyStellarAccelerationCap(gravity) { + return defaultGalaxyAccelerationCap(galaxyStellarGravitySetting(gravity)); + } + function defaultGalaxySystemAccelerationCap(anchor, gravity) { + if (anchor && anchor.anchor_role === 'global') { + return GALAXY_CENTER_ACCELERATION_CAP + * galaxyBlackHoleGravityConstant(gravity, true) * 0.5 / 24; + } + return anchor && anchor.anchor_role === 'community' + ? defaultGalaxyStellarAccelerationCap(gravity) + : defaultGalaxyAccelerationCap(gravity); + } + function galaxyAccelerationCapReference(gravity) { + const raw = Number(gravity); + return Number.isFinite(raw) + ? Math.max(0, Math.min(GALAXY_GRAVITY_CAP_REFERENCE, raw)) : 0; + } + function defaultGalaxyAccelerationCap(gravity) { + const reference = galaxyAccelerationCapReference(gravity); + return GALAXY_CENTER_ACCELERATION_CAP * galaxyLocalGravityConstant(reference) / 24; + } + function defaultGalaxyBlackHoleAccelerationCap(gravity, explicitGlobal) { + const reference = galaxyAccelerationCapReference(gravity); + return GALAXY_CENTER_ACCELERATION_CAP + * galaxyBlackHoleGravityConstant(reference, explicitGlobal) / 24; + } + const GALAXY_LINK_DEFAULT = 8; + const GALAXY_LINK_REFERENCE = 16; + const GALAXY_LINK_MINIMUM = 4; + const GALAXY_LINK_MAXIMUM = 80; + const GALAXY_RELATION_STRENGTH_MULTIPLIER = 2; + const GALAXY_RELATION_FORCE_CAP = 1.6; + const GALAXY_RELATION_ACCELERATION_CAP = 3.2; + const GALAXY_RELATION_CONSTRAINT_STRENGTH_MULTIPLIER = 2; + const GALAXY_RELATION_CONSTRAINT_RESPONSE_MULTIPLIER = 1; + const GALAXY_RELATION_CONSTRAINT_RATE = 24; + /* Position constraints must remain contractive. A larger per-frame displacement cap made + dense relation hubs snap by a visible distance even after the response itself was bounded. + Keep the established release cap and one monotone exponential response. */ + const GALAXY_RELATION_CONSTRAINT_MAX_CORRECTION = 12; + /* A valid inner orbit can be faster than 16 world units at ordinary gravity. Keep the local + guard at the engine's true emergency ceiling; a lower arbitrary cap makes a circular + planet sub-orbital and spirals it into the star even though the integrator is stable. */ + const GALAXY_LOCAL_RELATIVE_SPEED_LIMIT = 48; + /* Preserve headroom below the 48-unit emergency guard while allowing real overview systems + whose physically sampled circular speed exceeds the retired 10-unit presentation cap to + visibly orbit the black hole. */ + const GALAXY_SYSTEM_ORBIT_SEED_SPEED_LIMIT = 18; + const GALAXY_DRAG_GRAVITY_TIME = 6; + const GALAXY_DRAG_GRAVITY_SOFTENING = 12; + const GALAXY_DRAG_GRAVITY_MAX_PULL = 36; + const GALAXY_DRAG_GRAVITY_MAX_IMPULSE = 8; + const GALAXY_DRAG_GRAVITY_CAPTURE_RADIUS = 180; + const GALAXY_DRAG_GRAVITY_MULTIPLIER = 2; + /* Solar systems are not isolated islands. A deliberately weaker mutual field lets nearby + evidence-heavy systems perturb one another while the dominant black hole remains the + galaxy-wide potential. Mass and inverse-square distance, rather than graph topology, + determine this secondary attraction. */ + const GALAXY_MUTUAL_SYSTEM_GRAVITY_FRACTION = 0.12; + const GALAXY_MUTUAL_SYSTEM_SOFTENING = 80; + const GALAXY_DRAG_POSITION_MAX_PULL = 2; + const GALAXY_ORBITAL_SEPARATION_MULTIPLIER = 2; + /* `graph-repel` remains the persisted setting key for saved-view compatibility, but Galaxy + presents it as orbital speed. The neutral midpoint (60) preserves the shipped orbit rate. */ + const GALAXY_ORBITAL_SPEED_MINIMUM = 0.5; + const GALAXY_ORBITAL_SPEED_MAXIMUM = 1.5; + const GALAXY_ORBITAL_RADIUS_MINIMUM = 0.94; + const GALAXY_ORBITAL_RADIUS_MAXIMUM = 1.06; + function galaxyOrbitalSpeedMultiplier(setting) { + const raw = Number(setting); + const value = Number.isFinite(raw) ? Math.max(0, Math.min(120, raw)) : 60; + return GALAXY_ORBITAL_SPEED_MINIMUM + + (GALAXY_ORBITAL_SPEED_MAXIMUM - GALAXY_ORBITAL_SPEED_MINIMUM) * value / 120; + } + function galaxyOrbitalRadiusMultiplier(setting) { + const speed = galaxyOrbitalSpeedMultiplier(setting); + return GALAXY_ORBITAL_RADIUS_MINIMUM + + (GALAXY_ORBITAL_RADIUS_MAXIMUM - GALAXY_ORBITAL_RADIUS_MINIMUM) + * (speed - GALAXY_ORBITAL_SPEED_MINIMUM) + / (GALAXY_ORBITAL_SPEED_MAXIMUM - GALAXY_ORBITAL_SPEED_MINIMUM); + } + const GALAXY_ORBITAL_SEPARATION_BASE_SETTING = 60; + /* Link distance is a physical scale, so doubled sensitivity uses the squared response + (setting/reference)^2. The UI's 4..80 range spans 1/16x through 25x; the shipped setting + remains 8 (0.25x). Authored star/planet topology is excluded from this constraint so the + dominant stellar potential still owns orbital radii. */ + function galaxyRelationOrbitScale(setting) { + const raw = Number(setting); + const value = Number.isFinite(raw) + ? Math.max(GALAXY_LINK_MINIMUM, Math.min(GALAXY_LINK_MAXIMUM, raw)) + : GALAXY_LINK_DEFAULT; + const ratio = value / GALAXY_LINK_REFERENCE; + return ratio * ratio; + } + function galaxyOrbitalSeparationPadding(setting) { + const raw = Number(setting); + const value = Number.isFinite(raw) ? Math.max(0, Math.min(120, raw)) : 48; + /* The old latent cushion was one eighth world unit per slider point. Doubling that + response makes the control visibly span touching orbits through a 30-unit envelope. */ + return value * 0.125 * GALAXY_ORBITAL_SEPARATION_MULTIPLIER; + } + function galaxyOrbitalSeparationStrength(setting) { + const raw = Number(setting); + const value = Number.isFinite(raw) ? Math.max(0, Math.min(120, raw)) : 48; + /* A penetration projection must remain at or below one. Crossing the contact manifold + reverses the correction on the next frame and reheats dense systems. */ + return Math.min(1, value / 120 * GALAXY_ORBITAL_SEPARATION_MULTIPLIER); + } + const GALAXY_LOCAL_PAIR_FRACTION = 0.15; + const GALAXY_CORE_PAIR_MULTIPLIER = 0.75; + /* A community's dominant evidence node is its only local gravity well. Its painted edge is + also a permanent stellar surface: relation constraints and dense layouts may touch it, + but a satellite can never be placed through the star. This cushion is deliberately not + slider-controlled; Repel may add more room, never remove the minimum physical surface. */ + const GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING = 1.5; + /* A short conservative pressure band makes the painted stellar surface a real repulsive + field instead of relying only on post-step projection. This value is the bounded net-outward + margin at the hard surface: the live pressure first cancels the sampled stellar attraction, + then adds this small margin, tapering C1 to zero across the band. The hard exclusion remains + the exact no-overlap fallback for pathological payloads and pointer teleports. */ + const GALAXY_SYSTEM_ANCHOR_REPULSION_RANGE = 6; + const GALAXY_SYSTEM_ANCHOR_REPULSION_ACCELERATION = 0.12; + /* Legacy telemetry retains this padding name, but cross-system clearance now belongs to the + complete rigid envelope below—not arbitrary node-pair pressure. */ + const GALAXY_CROSS_SYSTEM_REPULSION_PADDING = 1.5; + /* Solar systems are packed by their complete painted envelopes, never by pushing arbitrary + cross-community node pairs. Eight world units stays visible between two outer planets; + the bounded response lets live systems keep orbiting while their carrier frames separate. */ + /* Default Galaxy admission should keep complete solar systems visually near the black-hole + interior. Four world units still leaves a painted clearance band, while the explicit + higher gaps used by callers/tests remain available through `systemPackingGap`. */ + const GALAXY_SYSTEM_PACKING_GAP = 4; + const GALAXY_SYSTEM_PACKING_STRENGTH = 0.45; + const GALAXY_SYSTEM_PACKING_MAX_CORRECTION = 6; + /* The orbital-speed control can expand local radii by at most 6%. Keep a small additional + margin, but do not reserve the old 12% by default because that needlessly adds outer rings. */ + const GALAXY_CARRIER_LANE_SLACK = 1.08; + /* Tiny solver drift should keep the deterministic lane phase shared across a ring. A larger + displacement is an actual contact/boundary correction and is allowed to become phase. */ + const GALAXY_LANE_PHASE_CORRECTION_DISTANCE = 0.5; + const GALAXY_BRIDGE_SCALE = 0.35; + const GALAXY_CENTER_ACCELERATION_CAP = 2.5; + /* The visible black hole is a contact boundary as well as a gravity source. Its skin must + exceed one emergency-speed drift (48 * 0.032 = 1.536 world units), so a body cannot + tunnel through the painted edge between fixed steps. The constraint never adds an outward + kick; deep corrections preserve angular momentum instead of manufacturing orbital speed. */ + const GALAXY_BLACK_HOLE_EXCLUSION_PADDING = 2.5; + /* The Plummer well keeps ordinary systems bound, but a finite visual galaxy also needs a + dormant outer safety field. It starts well outside the seeded scene, adds a smooth + inward acceleration only near that edge, then applies an exact last-resort boundary if a + body still escapes. The cached radius never follows an escaped body outward. */ + /* The finite disk must reserve painted-envelope capacity, not merely the furthest seeded + carrier. The 2x bound clears the complete 542-node / 36-system overview while explicit + caller radii remain exact for embedded and boundary-test scenes. */ + const GALAXY_FAR_FIELD_ENVELOPE_SCALE = 2; + const GALAXY_FAR_FIELD_MIN_RADIUS = 96; + const GALAXY_FAR_FIELD_SOFT_FRACTION = 0.82; + const GALAXY_FAR_FIELD_ACCELERATION = 12; + const GALAXY_FAR_FIELD_MAX_ACCELERATION = 16; + /* Frozen compatibility nodes swallow Object.defineProperty, so the far-field cache also + lives in a WeakMap keyed by anchor identity. The property-based path stays for ordinary + mutable nodes; the WeakMap wins when the anchor is frozen. */ + const galaxyFarFieldEnvelopeCache = typeof WeakMap === 'function' ? new WeakMap() : null; + const galaxyBlackHoleSpinCache = typeof WeakMap === 'function' ? new WeakMap() : null; + /* Galaxy has its own physical clock. Thirty fixed steps per second bounds main-thread work, + while a 0.032 leapfrog slice makes both levels of the hierarchy visibly rotate without + changing their circular initial conditions or force balance. This is a time-scale increase, + not an extra tangential kick: planets still orbit only their dominant star and whole systems + still orbit the black hole. Damping removes numerical noise over minutes rather than erasing + the seeded angular momentum during the opening animation. */ + const GALAXY_FRAME_INTERVAL_MS = 1000 / 30; + const GALAXY_MOTION_RATE = 0.68; + const GALAXY_FIXED_TIMESTEP = 0.032; + /* The black hole remains the chart's fixed origin, but its visible accretion disk must not + read as a frozen node when the central community has no separately painted satellites. */ + const GALAXY_BLACK_HOLE_SPIN_RATE = 1.2; + const GALAXY_MAX_SUBSTEPS = 3; + /* Galaxy's fixed-step solver is persistent, so it has no cold alpha to reheat. Extra fixed + slices would literally fast-forward physical time (up to 3x at a 60 Hz render cadence), + making every system lurch despite adding no random impulse. Keep the public action and its + activation telemetry, but let it only wake/reset the ordinary clock; no bonus time enters + the integrator. */ + const GALAXY_REHEAT_STEPS = 0; + const GALAXY_REHEAT_LARGE_STEPS = 0; + const GALAXY_VELOCITY_DECAY = 0.00005; + /* Developer-facing spacetime controls are normalized multipliers around the calibrated + dashboard physics. Keeping them separate from the established Gravity/Link controls makes + the advanced panel reversible and avoids changing saved-layout semantics. */ + const GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER = 1; + const GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER = 1; + const GALAXY_BLACK_HOLE_MASS_MULTIPLIER = 1; + const GALAXY_SPRING_STIFFNESS_MULTIPLIER = 1; + const GALAXY_FRAME_DRAGGING_FRACTION = 0.018; + const GALAXY_FRAME_DRAGGING_MAX_ACCELERATION = 0.22; + const GALAXY_EVENT_HORIZON_INFLUENCE_SCALE = 4.5; + /* The black-hole node is intentionally painted much larger than ordinary evidence. Letting + that display radius scale the complete weak-field band made most of a fitted galaxy look + near-horizon. This finite chart-space thickness keeps curvature local to the event horizon + while the scale still controls smaller/custom black holes. */ + const GALAXY_EVENT_HORIZON_BAND_LIMIT = 24; + const GALAXY_EVENT_HORIZON_DECAY_RATE = 0.12; + const GALAXY_EVENT_HORIZON_INWARD_ACCELERATION = 0.28; + const GALAXY_TIDAL_STRENGTH_FRACTION = 0.18; + const GALAXY_TIDAL_ACCELERATION_CAP = 0.16; + const GALAXY_SLINGSHOT_VELOCITY_SCALE = 0.022; + const GALAXY_SLINGSHOT_SPEED_LIMIT = 24; + const GALAXY_SLINGSHOT_CAPTURE_RADIUS = 120; + const GALAXY_SLINGSHOT_ESCAPE_FACTOR = 1.08; + function galaxyPhysicsMultiplier(value, fallback, maximum) { + const raw = Number(value); + return Number.isFinite(raw) + ? Math.max(0, Math.min(maximum, raw)) : fallback; + } + function galaxyLocalGravityMultiplier(anchor, options) { + const opts = options || {}; + const value = anchor && anchor.anchor_role === 'global' + ? opts.gravitationalConstant + : opts.localGravitationalConstant; + return galaxyPhysicsMultiplier(value, + GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8); + } + function galaxyEventHorizonOuterRadius(anchorRadius, contactRadius, influenceScale) { + const scale = Math.max(1.1, Number(influenceScale) || GALAXY_EVENT_HORIZON_INFLUENCE_SCALE); + const thickness = Math.max(1, Math.min(GALAXY_EVENT_HORIZON_BAND_LIMIT, + Math.max(0, Number(anchorRadius) || 0) * (scale - 1))); + return Math.max(Number(contactRadius) + 1, Number(contactRadius) + thickness); + } + /* This is a deliberate external field in the black-hole frame, rather than an + equal-and-opposite pair force: it makes the visible galaxy contract at a reliable + wall-clock rate even while orbital forces and drag-derived energy vary. One minute at + the previous default left 75% of a radius. The motion-rate exponent below now advances + that same physical trajectory at 68% speed, matching the faster leapfrog clock without + weakening the force field itself. */ + const GALAXY_INWARD_CONVERGENCE_PER_MINUTE = 0.25; + const GALAXY_INWARD_CONVERGENCE_SECONDS = 60; + const GALAXY_OUTWARD_OVERRIDE = 0.10; + + /* Density follows the same effective-G curve as orbital acceleration. Gravity 0 keeps + the seeded loose radius (while still rejecting outward escape), the default follows + the former 25%/minute trajectory at 68% speed, and the former 100-setting response + remains 3.6x while the extended range adds two additional movement spans. This makes the + full slider visibly control whole-galaxy looseness instead of changing only imperceptible + acceleration underneath a fixed radial projector. */ + function galaxyInwardConvergencePerMinute(gravitySetting) { + const setting = gravitySetting === undefined ? 48 : gravitySetting; + const relativeGravity = galaxyBlackHoleGravityConstant(setting, true) + / galaxyBlackHoleGravityConstant(48, true); + return 1 - Math.pow(1 - GALAXY_INWARD_CONVERGENCE_PER_MINUTE, + relativeGravity * GALAXY_MOTION_RATE); + } + + /* Acceleration alone is intentionally gradual; a range control still needs an immediate, + legible density response. Map the same black-hole G curve onto a reversible 1.0..0.6 + system-radius scale, then apply only the ratio between the old and new settings. This is + path-independent across a burst of input events, preserves every solar system's internal + geometry and velocity, and never wakes D3. Lowering gravity is an explicit user-requested + loosening action; automatic dynamics remain inward-only. */ + function galaxyImmediateGravityRadiusScale(setting) { + const maximum = Math.max(1e-9, + galaxyBlackHoleGravityConstant(GALAXY_GRAVITY_MAXIMUM, true)); + const normalized = Math.max(0, Math.min(1, + galaxyBlackHoleGravityConstant(setting, true) / maximum)); + return Math.exp(Math.log(0.6) * normalized); + } + + /* The oversized-scene fallback has no live integrator, so its grid must map the complete + slider range directly. Keeping the old `setting / 100` scale made compactness hit its + minimum near 112 and left every higher gravity value visually identical. */ + const GALAXY_LAYOUT_COMPACTNESS_MAXIMUM = 1.75; + const GALAXY_LAYOUT_COMPACTNESS_MINIMUM = 0.18; + function galaxyLayoutCompactness(setting) { + const raw = Number(setting); + const normalized = Number.isFinite(raw) + ? Math.max(0, Math.min(1, raw / GALAXY_GRAVITY_MAXIMUM)) : 0; + return GALAXY_LAYOUT_COMPACTNESS_MAXIMUM + - (GALAXY_LAYOUT_COMPACTNESS_MAXIMUM - GALAXY_LAYOUT_COMPACTNESS_MINIMUM) * normalized; + } + + function applyGalaxyGravitySettingResponse(nodes, previousSetting, nextSetting, options) { + const opts = options || {}; + const anchor = galaxyGlobalAnchor(nodes); + let systems = 0; + communityCenters(nodes).forEach(center => { + if (!center || center.nodes.includes(anchor) + || center.nodes.some(node => node.anchor_role === 'global' + || node.id === opts.fixedNodeId)) return; + systems++; + }); + /* A gravity control changes the force sampled on the next tick. Carrier positions and + velocities are phase state, never an immediate density response. */ + return { systems, moved: 0, ratio: 1, maximumShift: 0, + anchorId: anchor ? anchor.id : null }; + } + + /* `zoomToFit()` derives its bounds from force-graph's default node geometry rather than + our custom canvas radius. A compact, nearly-linear graph can therefore produce a 10×+ + fit zoom even though its rendered nodes already fill the canvas. At that scale a normal + drag maps to a tiny world-space movement and reheating makes the rest of the layout look + like it is racing away. Keep auto-fit useful without letting its scale become unstable. */ + const MAX_AUTO_FIT_ZOOM = 4; + const SETTINGS_ALPHA_TARGET = 0.12; + const ALPHA_TARGET_HOLD_MS = 180; + + /* Physics is allowed to respond live, but one bad force update must never turn a + settled graph into a high-speed slingshot. Keep the bounds in world units so they + remain meaningful at every camera zoom. */ + const MIN_NODE_SPEED = 8; + const MAX_NODE_SPEED = 48; + + /* The classic renderer's *dense* signal (`GPERF.dense`, `links>1500` in dashboard.js). Past + it the classic path turns off the two per-edge costs that scale with the link count and + buy nothing at that density: link curvature (a quadratic bezier per relation instead of a + straight line) and the directional arrowhead (a filled triangle per relation, recomputed + every frame). Relation labels get the same treatment unless one node is highlighted. Same + thresholds and same behaviour here — a second signal would only drift. */ + const DENSE_LINK_LIMIT = 1500; + + /* Relation labels are the noisiest layer on the canvas, so — exactly as the classic + `linkCanvasObject` does — they only appear once the user has zoomed in past this scale. */ + const LINK_LABEL_MIN_SCALE = 2.4; + + function hasOwn(value, key) { + return value != null && Object.prototype.hasOwnProperty.call(value, key); + } + function idOf(value) { return value && typeof value === 'object' ? value.id : value; } + function nodeName(node) { + if (node === undefined || node === null) return ''; + if (typeof node !== 'object' && typeof node !== 'function') return String(node); + return String(node.name || node.label || node.id || ''); + } + function showRelationLabel(label) { + return Boolean(label) && String(label).toLowerCase() !== 'co_occurs'; + } + /* Replace force-graph's round flow particles with a small directional glyph. The vendor + callback supplies the particle's current position and its link; the context already has + the resolved particle colour, so this only changes the silhouette and orientation. */ + function paintFlowArrow(x, y, link, ctx, globalScale) { + const source = link && link.source; + const target = link && link.target; + if (!source || !target || !Number.isFinite(source.x) || !Number.isFinite(target.x)) return; + const dx = target.x - source.x; + const dy = target.y - source.y; + if (!dx && !dy) return; + const size = 1 / Math.sqrt(Math.max(0.01, Number(globalScale) || 1)); + const angle = Math.atan2(dy, dx); + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle); + ctx.beginPath(); + ctx.moveTo(size * 0.55, 0); + ctx.lineTo(-size * 0.45, size * 0.32); + ctx.lineTo(-size * 0.45, -size * 0.32); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + /* Keep node geometry in the same compact world-space range as the Classic/Ledger renderer. + The previous overview formula used the full size-slider value plus a normalized degree + bonus, which made a seven-node workspace occupy only a small simulation area while each + node still had a dense-graph radius. `zoomToFit()` then magnified those radii into large + discs. Material style must not change geometry; it only changes the painted surface. */ + function graphNodeRadius(node, base, metric) { + const size = Number.isFinite(+base) && +base > 0 ? +base : 3; + if (node && node.cluster) { + const members = Math.max(1, Number(node.members) || 1); + const radius = size * 0.45 * (1.4 + Math.min(3, Math.sqrt(members) * 0.7)); + return Math.max(2, Math.min(size * 2.7, radius)); + } + const normalized = Math.max(0, Math.min(1, Number(metric) || 0)); + const radius = size * 0.45 * (0.55 + Math.min(1.6, normalized * 1.9)); + return Math.max(0.8, Math.min(size * 1.1, radius)); + } + function finitePositive(value, fallback, ceiling) { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) return fallback; + return Math.min(number, ceiling === undefined ? Number.MAX_VALUE : ceiling); + } + function communityKey(node) { + if (node && node.community_id !== undefined && node.community_id !== null) { + return String(node.community_id); + } + return String(node && node.community !== undefined && node.community !== null + ? node.community : 0); + } + function setGalaxyBlackHoleChild(node, value) { + if (!node) return; + if (!value) { + try { delete node.__galaxyBlackHoleChild; } catch (_) { /* compatibility payload */ } + return; + } + try { + Object.defineProperty(node, '__galaxyBlackHoleChild', { + value: true, writable: true, configurable: true, enumerable: false, + }); + } catch (_) { + node.__galaxyBlackHoleChild = true; + } + } + /* A direct black-hole edge is a valid hierarchy declaration even when an older payload lacks + system_anchor_id or puts the child in a different community. Mark those non-anchor nodes so + every orbit path (live support and oversized kinematics) groups them around the fixed hole. */ + function markGalaxyBlackHoleChildren(nodes, links) { + const values = Array.isArray(nodes) ? nodes : []; + const anchor = galaxyGlobalAnchor(values); + const connected = new Set(); + const orbiting = new Set(); + const endpointId = endpoint => endpoint && typeof endpoint === 'object' + ? endpoint.id : endpoint; + (Array.isArray(links) ? links : []).forEach(link => { + const source = endpointId(link && link.source); + const target = endpointId(link && link.target); + const anchorId = anchor ? String(anchor.id) : null; + if (anchorId === null) return; + const relationValue = link && link.relation !== undefined + ? link.relation : link && link.label; + const isOrbitalRelation = String(relationValue || '').trim().toLowerCase() + .indexOf('orbit') === 0; + if (String(source) === anchorId && target !== undefined && target !== null) { + connected.add(String(target)); + if (isOrbitalRelation) orbiting.add(String(target)); + } else if (String(target) === anchorId && source !== undefined && source !== null) { + connected.add(String(source)); + if (isOrbitalRelation) orbiting.add(String(source)); + } + }); + values.forEach(node => { + if (!node || node === anchor) return; + const isDirectChild = connected.has(String(node.id)) + && (node.anchor_role !== 'community' || orbiting.has(String(node.id))); + setGalaxyBlackHoleChild(node, isDirectChild); + }); + return values; + } + function fallbackGravityMass(degree, maxDegree) { + const normalized = Math.max(0, Math.min(1, + finitePositive(degree, 0, Number.MAX_VALUE) / Math.max(1, Number(maxDegree) || 1))); + return 1 + 15 * normalized * normalized; + } + function radiusFromGravityMass(mass) { + return 1.5 + 2 * Math.pow(finitePositive(mass, 1, 1000), 2 / 3); + } + /* Scene evidence is the authority in Galaxy mode. Compatibility payloads without mass use + one deterministic degree fallback; malformed values never inject NaN/Infinity. Radius is + always derived from the sanitized mass, making visual scale and gravitational pull one + contract and preventing a bad sibling radius from flattening every later node. */ + function sanitizeEvidenceMetrics(nodes, maxDegree) { + const values = Array.isArray(nodes) ? nodes : []; + values.forEach(node => { + if (node.ghost) { + node.gravity_mass = 0; + node.visual_radius = finitePositive(node.visual_radius, 2.5, 64); + return; + } + node.gravity_mass = finitePositive( + node.gravity_mass, fallbackGravityMass(node.degree, maxDegree), 1000 + ); + /* Radius is a view of mass, never an independent sibling input. Trusting a stale or + flattened visual_radius made every star identical even when its evidence differed. */ + node.visual_radius = Math.min(64, radiusFromGravityMass(node.gravity_mass)); + }); + return values; + } + function evidenceNodeRadius(node, base) { + const scale = finitePositive(base, 3, 100) / 3; + if (node && node.cluster) { + if (node.ghost || !(Number(node.gravity_mass) > 0)) return 2.5 * scale; + return Math.max(2, Math.min(80 * scale, + radiusFromGravityMass(node.gravity_mass) * scale)); + } + const evidenceRadius = Math.max(0.8, Math.min(80 * scale, + finitePositive(node && node.visual_radius, + radiusFromGravityMass(node && node.gravity_mass), 64) * scale)); + /* The global evidence anchor is both the physical and visual black hole. Double only its + rendered/hit radius; gravity_mass remains canonical and community stars retain ordinary + evidence geometry. Adornments consume node.radius, so their halo follows this scale. */ + return node && !node.ghost && node.anchor_role === 'global' + ? evidenceRadius * 2 : evidenceRadius; + } + + function seededHash(seed, value) { + const text = String(seed === undefined ? 0 : seed) + ':' + String(value); + let hash = 2166136261; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; + } + function ensureGalaxyPositions(nodes, layoutSeed) { + const groups = new Map(); + (nodes || []).forEach(node => { + const key = communityKey(node); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(node); + }); + [...groups.keys()].sort().forEach((key, groupIndex) => { + const members = groups.get(key).sort((a, b) => String(a.id).localeCompare(String(b.id))); + const positioned = members.filter(node => Number.isFinite(node.x) && Number.isFinite(node.y)); + let centerX = 0, centerY = 0; + if (positioned.length) { + positioned.forEach(node => { centerX += node.x; centerY += node.y; }); + centerX /= positioned.length; + centerY /= positioned.length; + } else if (groups.size > 1) { + const angle = (seededHash(layoutSeed, key) / 0x100000000) * Math.PI * 2; + const reach = 90 * Math.sqrt(groupIndex + 1); + centerX = Math.cos(angle) * reach; + centerY = Math.sin(angle) * reach; + } + members.forEach((node, index) => { + if (Number.isFinite(node.x) && Number.isFinite(node.y)) return; + const hash = seededHash(layoutSeed, node.id); + const angle = (hash / 0x100000000) * Math.PI * 2; + const orbit = index === 0 ? 0 : 14 + 7 * Math.sqrt(index + 1); + node.x = centerX + Math.cos(angle) * orbit; + node.y = centerY + Math.sin(angle) * orbit; + }); + }); + return nodes; + } + function communityCenters(nodes) { + const centers = new Map(); + (nodes || []).forEach(node => { + if (node.ghost || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + const mass = finitePositive(node.gravity_mass, 1, 1000); + const key = communityKey(node); + let center = centers.get(key); + if (!center) { + center = { id: key, mass: 0, x: 0, y: 0, nodes: [] }; + centers.set(key, center); + } + center.mass += mass; + center.x += node.x * mass; + center.y += node.y * mass; + center.nodes.push(node); + }); + centers.forEach(center => { + if (center.mass > 0) { center.x /= center.mass; center.y /= center.mass; } + }); + return centers; + } + function galaxyOrbitGroups(nodes) { + const groups = new Map(); + const communityAnchors = new Map(); + const globalAnchor = (nodes || []).find(node => node && !node.ghost + && node.anchor_role === 'global'); + const blackHoleCommunities = new Set(); + const byId = new Map((nodes || []).filter(node => node && node.id !== undefined) + .map(node => [String(node.id), node])); + (nodes || []).forEach(node => { + if (!node || node.ghost) return; + const key = communityKey(node); + if (globalAnchor && (node.__galaxyBlackHoleChild === true + || String(node.system_anchor_id || '') === String(globalAnchor.id))) { + blackHoleCommunities.add(key); + } + if (node.anchor_role !== 'global' && node.anchor_role !== 'community') return; + const existing = communityAnchors.get(key); + if (!existing || node.anchor_role === 'global') { + communityAnchors.set(key, { + id: String(node.id), global: node.anchor_role === 'global', + }); + } + }); + (nodes || []).forEach(node => { + if (!node || node.ghost || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + const declared = communityAnchors.get(communityKey(node)); + let root = node; + let current = node; + const visited = new Set(); + while (current && current.system_anchor_id !== undefined + && current.system_anchor_id !== null) { + const parentId = String(current.system_anchor_id); + if (!parentId || parentId === String(current.id) + || (globalAnchor && parentId === String(globalAnchor.id)) + || visited.has(parentId)) break; + const parentNode = byId.get(parentId); + if (!parentNode) break; + visited.add(parentId); + root = parentNode; + current = parentNode; + } + /* Parent metadata can be absent on a filtered member. Infer the local star from its + community, then resolve nested planets/moons to the same top-level carrier. */ + const rootHasNoParent = root.system_anchor_id === undefined + || root.system_anchor_id === null || String(root.system_anchor_id) === String(root.id); + const rootCanUseCommunityFallback = rootHasNoParent && ( + (root.anchor_role !== 'global' && root.anchor_role !== 'community') + || (declared && declared.global)); + if (declared && declared.id !== root.id && rootCanUseCommunityFallback) { + const declaredNode = byId.get(String(declared.id)); + if (declaredNode) root = declaredNode; + } + const rootParentId = root.system_anchor_id === undefined + || root.system_anchor_id === null ? '' : String(root.system_anchor_id); + const rootIsBlackHoleChild = root.__galaxyBlackHoleChild === true + || (globalAnchor && rootParentId === String(globalAnchor.id)); + const rootIsGlobal = globalAnchor && String(root.id) === String(globalAnchor.id); + const hasExplicitSystemAnchor = node.system_anchor_id !== undefined + && node.system_anchor_id !== null && String(node.system_anchor_id) !== ''; + const compatibilityCommunityRoot = root === node && !hasExplicitSystemAnchor && !declared + && node.anchor_role !== 'global' && node.anchor_role !== 'community'; + const rootKey = compatibilityCommunityRoot ? communityKey(node) : String(root.id); + const followsBlackHoleCommunity = globalAnchor + && blackHoleCommunities.has(communityKey(node)); + const key = globalAnchor && (rootIsGlobal || rootIsBlackHoleChild + || followsBlackHoleCommunity) + ? String(globalAnchor.id) : rootKey; + const mass = finitePositive(node.gravity_mass, 1, 1000); + let group = groups.get(key); + if (!group) { + group = { id: key, mass: 0, x: 0, y: 0, nodes: [] }; + groups.set(key, group); + } + group.mass += mass; group.x += node.x * mass; group.y += node.y * mass; + group.nodes.push(node); + }); + groups.forEach(group => { + if (group.mass > 0) { group.x /= group.mass; group.y /= group.mass; } + }); + return groups; + } + function galaxySystemAnchor(members) { + const global = (members || []).find(node => node && !node.ghost + && node.anchor_role === 'global'); + if (global) return global; + const declaredIds = new Set((members || []).map(node => node && node.system_anchor_id) + .filter(value => value !== undefined && value !== null).map(String)); + return (members || []).slice().sort((left, right) => { + const leftDeclared = declaredIds.has(String(left.id)) ? 1 : 0; + const rightDeclared = declaredIds.has(String(right.id)) ? 1 : 0; + const leftRole = left.anchor_role === 'global' ? 2 + : left.anchor_role === 'community' ? 1 : 0; + const rightRole = right.anchor_role === 'global' ? 2 + : right.anchor_role === 'community' ? 1 : 0; + return rightDeclared - leftDeclared || rightRole - leftRole + || finitePositive(right.gravity_mass, 1, 1000) + - finitePositive(left.gravity_mass, 1, 1000) + || String(left.id).localeCompare(String(right.id)); + })[0] || null; + } + /* Resolve one local orbital parent for every member. Explicit ancestry wins when the parent + is present in this carrier group; filtered/legacy payloads fall back to the system star. + The global black hole is a valid parent for direct core satellites. */ + function galaxyLocalOrbitParent(node, members, carrier, byId) { + if (!node || node === carrier) return null; + const lookup = byId || new Map((members || []).map(item => [String(item.id), item])); + const declaredId = node.system_anchor_id === undefined || node.system_anchor_id === null + ? '' : String(node.system_anchor_id); + const declared = declaredId ? lookup.get(declaredId) : null; + if (declared && declared !== node) return declared; + let communityAnchors = lookup.__galaxyCommunityAnchors; + if (!communityAnchors) { + communityAnchors = new Map(); + const declaredIds = new Set((members || []).map(item => item && item.system_anchor_id) + .filter(value => value !== undefined && value !== null && String(value) !== '') + .map(String)); + (members || []).forEach(candidate => { + if (!candidate) return; + const key = communityKey(candidate); + const priority = candidate.anchor_role === 'global' ? 3 + : candidate.anchor_role === 'community' ? 2 + : declaredIds.has(String(candidate.id)) ? 1 : 0; + const previous = communityAnchors.get(key); + if (!previous || priority > previous.priority + || (priority === previous.priority + && finitePositive(candidate.gravity_mass, 1, 1000) + > finitePositive(previous.node.gravity_mass, 1, 1000)) + || (priority === previous.priority + && finitePositive(candidate.gravity_mass, 1, 1000) + === finitePositive(previous.node.gravity_mass, 1, 1000) + && String(candidate.id).localeCompare(String(previous.node.id)) < 0)) { + communityAnchors.set(key, { node: candidate, priority }); + } + }); + try { Object.defineProperty(lookup, '__galaxyCommunityAnchors', { + value: communityAnchors, configurable: true, + }); } catch (error) { lookup.__galaxyCommunityAnchors = communityAnchors; } + } + const inferred = communityAnchors.get(communityKey(node)); + if (inferred && inferred.node !== node) return inferred.node; + return carrier && carrier !== node ? carrier : null; + } + /* A community anchor can itself be an explicit black-hole satellite. Keep its declared + stellar children in the same central carrier group so support translates the local system + together instead of leaving the planet group to orbit its already-detached star. */ + function galaxyBlackHoleCoreSystems(members, globalAnchor) { + const values = (members || []).filter(node => node && node !== globalAnchor); + const byId = new Map(values.map(node => [String(node.id), node])); + const communityAnchors = new Map(); + values.forEach(node => { + if (!node || (node.anchor_role !== 'community' + && node.__galaxyBlackHoleChild !== true)) return; + const key = communityKey(node); + const previous = communityAnchors.get(key); + if (!previous || finitePositive(node.gravity_mass, 1, 1000) + > finitePositive(previous.gravity_mass, 1, 1000) + || (finitePositive(node.gravity_mass, 1, 1000) + === finitePositive(previous.gravity_mass, 1, 1000) + && String(node.id).localeCompare(String(previous.id)) < 0)) { + communityAnchors.set(key, node); + } + }); + const groups = new Map(); + values.forEach(node => { + let root = node; + let current = node; + let followedExplicitParent = false; + const visited = new Set(); + while (current && current.system_anchor_id !== undefined + && current.system_anchor_id !== null) { + const parentId = String(current.system_anchor_id); + if (!parentId || parentId === String(current.id) + || parentId === String(globalAnchor && globalAnchor.id) + || visited.has(parentId)) break; + visited.add(parentId); + const parent = byId.get(parentId); + if (!parent) break; + root = parent; + current = parent; + followedExplicitParent = true; + } + /* Older/filtered payloads often retain the community anchor but omit the per-node + system_anchor_id. In a black-hole carrier group, that omission must not turn every + planet into an independent BH satellite: infer the local star from its community. */ + if (!followedExplicitParent) { + const communityAnchor = communityAnchors.get(communityKey(node)); + if (communityAnchor && communityAnchor !== node) root = communityAnchor; + } + const key = String(root.id); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(node); + }); + return [...groups.values()]; + } + function orderedGalaxySatellites(members, anchor) { + return (members || []).filter(node => node !== anchor).map(node => { + if (!node.__galaxyOrbitOrder) { + const hint = Number(node.orbit_tier); + Object.defineProperty(node, '__galaxyOrbitOrder', { + value: { + tier: Number.isFinite(hint) ? hint : Number.POSITIVE_INFINITY, + seedRadius: Math.hypot(node.x - anchor.x, node.y - anchor.y), + }, + writable: false, configurable: true, enumerable: false, + }); + } + return { node, tier: node.__galaxyOrbitOrder.tier, + radius: node.__galaxyOrbitOrder.seedRadius }; + }).sort((left, right) => left.tier - right.tier || left.radius - right.radius + || String(left.node.id).localeCompare(String(right.node.id))); + } + function setGalaxyOrbitAnchor(node, anchor) { + const anchorId = anchor && anchor.id !== undefined && anchor.id !== null + ? String(anchor.id) : ''; + if (!anchorId || !node) return; + Object.defineProperty(node, '__galaxyOrbitAnchorId', { + value: anchorId, writable: true, configurable: true, enumerable: false, + }); + } + function setGalaxyOrbitSeeded(node) { + if (!node || node.__galaxyOrbitSeeded === true) return; + Object.defineProperty(node, '__galaxyOrbitSeeded', { + value: true, writable: true, configurable: true, enumerable: false, + }); + } + function setGalaxyOrbitSpeed(node, multiplier) { + if (!node) return; + Object.defineProperty(node, '__galaxyOrbitSpeedMultiplier', { + value: multiplier, writable: true, configurable: true, enumerable: false, + }); + } + function setGalaxyOrbitBaseRadius(node, radius) { + if (!node || !Number.isFinite(radius) || radius <= 0 + || Number.isFinite(Number(node.__galaxyOrbitBaseRadius))) return; + Object.defineProperty(node, '__galaxyOrbitBaseRadius', { + value: radius, writable: true, configurable: true, enumerable: false, + }); + } + function setGalaxySystemOrbitSpeed(node, multiplier) { + if (!node) return; + Object.defineProperty(node, '__galaxySystemOrbitSpeedMultiplier', { + value: multiplier, writable: true, configurable: true, enumerable: false, + }); + } + /* Seed the same immediate-parent hierarchy used by the live force and kinematic clock. The + older community pass remains for compatibility payloads, but this final authoritative pass + repairs cross-community children and nested descendants that community grouping cannot see. */ + function seedGalaxyHierarchicalLocalOrbits(nodes, gravity, softening, options) { + const opts = options || {}; + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const epsilon = Math.max(0.1, Number(softening) || 8); + const centers = galaxyOrbitGroups(nodes); + centers.forEach(center => { + const members = center.nodes || []; + const carrier = galaxySystemAnchor(members); + if (!carrier || members.length < 2) return; + const byId = new Map(members.map(node => [String(node.id), node])); + members.forEach(node => { + if (node === carrier || node.ghost || node.id === opts.fixedNodeId + || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + const parent = galaxyLocalOrbitParent(node, members, carrier, byId) || carrier; + const dx = node.x - parent.x, dy = node.y - parent.y; + const radius = Math.hypot(dx, dy); + if (!(radius > 1e-9)) return; + const localGravityMultiplier = galaxyLocalGravityMultiplier(parent, opts); + const localGravity = galaxySystemGravityConstant(parent, gravity) + * localGravityMultiplier; + const localAccelerationCap = defaultGalaxySystemAccelerationCap(parent, gravity) + * Math.max(0.25, localGravityMultiplier); + const denominator = Math.pow(radius * radius + epsilon * epsilon, 1.5); + const rawAcceleration = localGravity * finitePositive(parent.gravity_mass, 1, 1000) + * radius / Math.max(1e-9, denominator); + const acceleration = localAccelerationCap > 0 + ? Math.min(localAccelerationCap, rawAcceleration) : rawAcceleration; + const targetTangent = Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, + Math.sqrt(Math.max(0, acceleration * radius)) * orbitalSpeed); + const parentVx = Number.isFinite(parent.vx) ? parent.vx : 0; + const parentVy = Number.isFinite(parent.vy) ? parent.vy : 0; + const relativeVx = (Number.isFinite(node.vx) ? node.vx : 0) - parentVx; + const relativeVy = (Number.isFinite(node.vy) ? node.vy : 0) - parentVy; + const tangentX = -dy / radius, tangentY = dx / radius; + const currentTangent = relativeVx * tangentX + relativeVy * tangentY; + const parentId = String(parent.id); + const previousParent = typeof node.__galaxyOrbitAnchorId === 'string' + ? node.__galaxyOrbitAnchorId : ''; + const previousSpeed = Number(node.__galaxyOrbitSpeedMultiplier); + const speedChanged = !Number.isFinite(previousSpeed) + || Math.abs(previousSpeed - orbitalSpeed) > 1e-9; + const needsSeed = previousParent !== parentId || Math.abs(currentTangent) < 1e-8; + if (needsSeed || speedChanged) { + const sign = Math.sign(currentTangent) + || ((seededHash(opts.layoutSeed, 'system:' + parentId) & 1) ? 1 : -1); + node.vx = parentVx + tangentX * targetTangent * sign; + node.vy = parentVy + tangentY * targetTangent * sign; + } + setGalaxyOrbitAnchor(node, parent); + setGalaxyOrbitSpeed(node, orbitalSpeed); + setGalaxyOrbitSeeded(node); + }); + }); + return nodes; + } + /* Seed once for each node/central-star pairing. The pairing tag is deliberately + non-enumerable, so scene export remains portable. More importantly, it makes a + compatibility node that became eligible only after a later reveal (or a changed declared + star) receive its one circular local seed without re-seeding healthy planets each frame. */ + function seedGalaxyOrbits(nodes, layoutSeed, gravity, softening, reducedMotion, options) { + const opts = options || {}; + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const orbitalRadius = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed); + const speedControlEnabled = opts.restorePhase !== true + && Number.isFinite(Number(opts.orbitalSpeed)); + /* Core-community satellites are local children of the explicit black hole. Admit only + those that begin inside its painted horizon before taking a star-relative radius sample; + the generic system seed below then gives them the ordinary BH-relative circular tangent. + A pointer-owned node remains exact and is intentionally left for the drag/horizon path. */ + const blackHole = (nodes || []).find(node => node && !node.ghost + && node.anchor_role === 'global' && Number.isFinite(node.x) && Number.isFinite(node.y)); + if (blackHole) { + const blackHoleRadius = finitePositive(blackHole.radius, + evidenceNodeRadius(blackHole, 3), 160); + const coreSatellites = (nodes || []).filter(node => node && node !== blackHole + && !node.ghost && node.id !== opts.fixedNodeId + && (String(node.system_anchor_id || '') === String(blackHole.id) + || node.__galaxyBlackHoleChild === true) + && Number.isFinite(node.x) && Number.isFinite(node.y)); + /* Coincident core children used to inherit the farthest authored distance, then every + child was placed on that same distant ring. Admit compact black-hole lanes instead: + each ring is close to the horizon, each node has a deterministic phase, and overflow + continues onto the next compact ring with a real radial clearance. The black hole + remains fixed; these are independent test-particle phases, not a translated system. */ + const penetrating = coreSatellites.slice().sort( + (left, right) => Number(left.orbit_tier || 0) - Number(right.orbit_tier || 0) + || String(left.id).localeCompare(String(right.id))); + const penetratingIds = new Set(penetrating.map(node => String(node.id))); + const childrenByAnchor = new Map(); + (nodes || []).forEach(candidate => { + if (!candidate || candidate.system_anchor_id === undefined + || candidate.system_anchor_id === null) return; + const parentId = String(candidate.system_anchor_id); + if (!childrenByAnchor.has(parentId)) childrenByAnchor.set(parentId, []); + childrenByAnchor.get(parentId).push(candidate); + }); + const translateSystemDescendants = (root, shiftX, shiftY) => { + if (!(Math.abs(shiftX) > 1e-12 || Math.abs(shiftY) > 1e-12)) return; + const pending = [String(root.id)], visited = new Set(); + while (pending.length) { + const parentId = pending.pop(); + if (visited.has(parentId)) continue; + visited.add(parentId); + (childrenByAnchor.get(parentId) || []).forEach(candidate => { + if (!candidate || candidate === blackHole || penetratingIds.has(String(candidate.id))) return; + candidate.x += shiftX; + candidate.y += shiftY; + pending.push(String(candidate.id)); + }); + } + }; + const laneGap = Math.max(3, GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING); + const compactBaseRadius = penetrating.reduce((maximum, node) => { + const nodeRadius = finitePositive(node.radius, evidenceNodeRadius(node, 3), 160); + const contact = blackHoleRadius + nodeRadius + GALAXY_BLACK_HOLE_EXCLUSION_PADDING; + const outsideWarp = galaxyEventHorizonOuterRadius( + blackHoleRadius, contact, GALAXY_EVENT_HORIZON_INFLUENCE_SCALE) + 1; + return Math.max(maximum, outsideWarp); + }, 0); + const rings = []; + let ringCursor = 0; + let previousRingRadius = 0; + let previousRingExtent = 0; + while (ringCursor < penetrating.length) { + const remaining = penetrating.slice(ringCursor); + const ringExtent = remaining.reduce((maximum, node) => Math.max(maximum, + finitePositive(node.radius, evidenceNodeRadius(node, 3), 160)), 0); + const ringRadius = Math.max(compactBaseRadius, + previousRingRadius + previousRingExtent + ringExtent + laneGap); + let capacity = 1; + while (capacity < remaining.length) { + const candidate = capacity + 1; + const chord = 2 * ringRadius * Math.sin(Math.PI / candidate); + if (chord < ringExtent * 2 + laneGap - 1e-9) break; + capacity = candidate; + } + const count = Math.min(capacity, remaining.length); + rings.push({ start: ringCursor, count, radius: ringRadius, extent: ringExtent }); + ringCursor += count; + previousRingRadius = ringRadius; + previousRingExtent = ringExtent; + } + const phaseOffset = seededHash(layoutSeed, 'core-lanes:' + String(blackHole.id)) + / 0x100000000 * Math.PI * 2; + rings.forEach((ring, ringIndex) => { + const ringPhase = phaseOffset + seededHash(layoutSeed, + 'core-ring:' + String(blackHole.id) + ':' + ringIndex) / 0x100000000 * Math.PI * 2; + penetrating.slice(ring.start, ring.start + ring.count).forEach((node, slot) => { + const minimum = blackHoleRadius + finitePositive(node.radius, + evidenceNodeRadius(node, 3), 160) + GALAXY_BLACK_HOLE_EXCLUSION_PADDING; + const dx = node.x - blackHole.x, dy = node.y - blackHole.y; + const distance = Math.hypot(dx, dy); + const angle = ring.count > 1 + ? ringPhase + slot * Math.PI * 2 / ring.count + : (distance > 1e-9 ? Math.atan2(dy, dx) : phaseOffset); + const unitX = Math.cos(angle), unitY = Math.sin(angle); + const anchorVx = Number.isFinite(blackHole.vx) ? blackHole.vx : 0; + const anchorVy = Number.isFinite(blackHole.vy) ? blackHole.vy : 0; + const relativeVx = (Number.isFinite(node.vx) ? node.vx : 0) - anchorVx; + const relativeVy = (Number.isFinite(node.vy) ? node.vy : 0) - anchorVy; + const tangentX = -unitY, tangentY = unitX; + const radialSpeed = relativeVx * unitX + relativeVy * unitY; + const tangentSpeed = relativeVx * tangentX + relativeVy * tangentY; + const tangentScale = distance > 1e-9 ? Math.max(0, Math.min(1, distance / minimum)) : 0; + const cachedLaneRadius = Number(node.__galaxyCoreLaneRadius); + const cachedLaneAngle = Number(node.__galaxyCoreLaneAngle); + const admittedRadius = Number.isFinite(cachedLaneRadius) && cachedLaneRadius > 0 + ? Math.max(minimum, cachedLaneRadius) : Math.max(minimum, ring.radius); + const admittedAngle = Number.isFinite(cachedLaneAngle) ? cachedLaneAngle : angle; + const admittedUnitX = Math.cos(admittedAngle), admittedUnitY = Math.sin(admittedAngle); + const previousX = node.x, previousY = node.y; + node.x = blackHole.x + admittedUnitX * admittedRadius; + node.y = blackHole.y + admittedUnitY * admittedRadius; + translateSystemDescendants(node, node.x - previousX, node.y - previousY); + try { + Object.defineProperty(node, '__galaxyCoreLaneRadius', { + value: admittedRadius, writable: true, configurable: true, enumerable: false, + }); + Object.defineProperty(node, '__galaxyCoreLaneAngle', { + value: admittedAngle, writable: true, configurable: true, enumerable: false, + }); + } catch (error) { + node.__galaxyCoreLaneRadius = admittedRadius; + node.__galaxyCoreLaneAngle = admittedAngle; + } + const admittedTangentX = -admittedUnitY, admittedTangentY = admittedUnitX; + const admittedRadialSpeed = relativeVx * admittedUnitX + relativeVy * admittedUnitY; + const admittedTangentSpeed = relativeVx * admittedTangentX + relativeVy * admittedTangentY; + node.vx = anchorVx + Math.max(0, admittedRadialSpeed) * admittedUnitX + + admittedTangentSpeed * tangentScale * admittedTangentX; + node.vy = anchorVy + Math.max(0, admittedRadialSpeed) * admittedUnitY + + admittedTangentSpeed * tangentScale * admittedTangentY; + if (Number.isFinite(node.fx)) node.fx = node.x; + if (Number.isFinite(node.fy)) node.fy = node.y; + }); + }); + } + /* Oversized/static renders only need direct black-hole lane admission. Leave ordinary + local systems untouched so the normal horizon/exclusion pass can report and resolve + their contacts instead of silently moving them during the seed. */ + if (opts.coreOnly === true) return nodes; + /* Establish each painted stellar surface before sampling the central field. Otherwise a + payload that starts a planet inside its star seeds circular speed at an impossible + radius and immediately converts the later contact correction into eccentric energy. */ + applyGalaxySystemAnchorExclusion(nodes, { + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + fixAnchors: true, + }); + const centers = communityCenters(nodes); + const epsilon = Math.max(0.1, Number(softening) || 8); + /* Seed from the satellite's dominant-star attraction only. Aggregate star recoil contains + the summed pull of every planet; projecting that aggregate onto one planet's radial axis + can point outward in a dense/asymmetric system and incorrectly seed zero angular motion. + Other satellites and the near-surface pressure are perturbations for the live integrator, + not independent local wells or inputs to a planet's circular initial condition. */ + const systemsToCheck = new Map(); + /* Capture this before installing the compatibility flag. A late member can inherit a + moving star's frame and look tangential despite never receiving its own local orbit. */ + const wasOrbitSeeded = new Map(); + (nodes || []).forEach(node => { + wasOrbitSeeded.set(node, node.__galaxyOrbitSeeded === true); + node.vx = Number.isFinite(node.vx) ? node.vx : 0; + node.vy = Number.isFinite(node.vy) ? node.vy : 0; + if (node.ghost) { + node.vx = 0; + node.vy = 0; + return; + } + /* Reduced motion suppresses cosmetic particles and animated camera travel; it does not + switch the persistent Galaxy solver to a radial-only physical model. The clock remains + active under that preference, so omitting this one-shot angular seed makes every planet + fall straight into its dominant star. Freeze/static layout are the no-physics controls. */ + if (!Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + const key = communityKey(node); + if (!systemsToCheck.has(key)) systemsToCheck.set(key, []); + systemsToCheck.get(key).push(node); + }); + /* Seed satellites around the evidence-heaviest star from that one dominant attraction. + A late reveal is expressed in the star's already-moving frame. The dominant node owns the + local inertial frame: it follows the system's black-hole trajectory but never recoils when + a planet is admitted, so a real local phase cannot be hidden by whole-system wobble. */ + systemsToCheck.forEach((members, key) => { + const center = centers.get(key); + if (!center || center.nodes.length < 2) return; + const anchor = galaxySystemAnchor(center.nodes); + /* Ghost/history nodes intentionally remain non-physical and are never promoted into an + orbit here. The global core retains its established seed law below; its hierarchy is + later governed by the black-hole frame rather than this repair path. */ + if (!anchor) return; + setGalaxyOrbitSeeded(anchor); + const localGravityMultiplier = galaxyLocalGravityMultiplier(anchor, opts); + const localGravity = galaxySystemGravityConstant(anchor, gravity) + * localGravityMultiplier; + const localAccelerationCap = defaultGalaxySystemAccelerationCap(anchor, gravity) + * Math.max(0.25, localGravityMultiplier); + const anchorMass = finitePositive(anchor.gravity_mass, 1, 1000); + const anchorVx = Number.isFinite(anchor.vx) ? anchor.vx : 0; + const anchorVy = Number.isFinite(anchor.vy) ? anchor.vy : 0; + const direction = anchor.anchor_role === 'global' + ? ((seededHash(layoutSeed, 'galaxy-spin') & 1) ? 1 : -1) + : ((seededHash(layoutSeed, 'system:' + key) & 1) ? 1 : -1); + const anchorId = String(anchor.id); + const desiredVelocity = new Map(); + const repair = []; + orderedGalaxySatellites(center.nodes, anchor).forEach(item => { + const satellite = item.node; + if (satellite.ghost || satellite.id === opts.fixedNodeId) return; + let dx = satellite.x - anchor.x, dy = satellite.y - anchor.y; + let currentRadius = Math.hypot(dx, dy); + if (!(currentRadius > 1e-9)) return; + setGalaxyOrbitBaseRadius(satellite, currentRadius); + const baseRadius = Number(satellite.__galaxyOrbitBaseRadius); + if (speedControlEnabled) { + const minimumRadius = finitePositive(anchor.radius, evidenceNodeRadius(anchor, 3), 160) + + finitePositive(satellite.radius, evidenceNodeRadius(satellite, 3), 160) + + GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING; + const targetRadius = Math.max(minimumRadius, baseRadius * orbitalRadius); + if (Number.isFinite(targetRadius) && Math.abs(targetRadius - currentRadius) > 1e-9) { + const angle = Math.atan2(dy, dx); + satellite.x = anchor.x + Math.cos(angle) * targetRadius; + satellite.y = anchor.y + Math.sin(angle) * targetRadius; + if (Number.isFinite(satellite.fx)) satellite.fx = satellite.x; + if (Number.isFinite(satellite.fy)) satellite.fy = satellite.y; + dx = satellite.x - anchor.x; + dy = satellite.y - anchor.y; + currentRadius = targetRadius; + } + } + const speedRadius = speedControlEnabled ? baseRadius : currentRadius; + const denominator = Math.pow( + speedRadius * speedRadius + epsilon * epsilon, 1.5); + const rawInwardAcceleration = denominator > 0 + ? localGravity * anchorMass * speedRadius / denominator : 0; + const inwardAcceleration = localAccelerationCap > 0 + ? Math.min(localAccelerationCap, rawInwardAcceleration) : rawInwardAcceleration; + const omega = Math.sqrt(Math.max(0, inwardAcceleration / speedRadius)); + const targetTangent = Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, + omega * speedRadius * orbitalSpeed); + const relativeVx = (Number.isFinite(satellite.vx) ? satellite.vx : 0) - anchorVx; + const relativeVy = (Number.isFinite(satellite.vy) ? satellite.vy : 0) - anchorVy; + const tangent = (-dy * relativeVx + dx * relativeVy) / currentRadius; + const previousAnchorId = typeof satellite.__galaxyOrbitAnchorId === 'string' + ? satellite.__galaxyOrbitAnchorId : ''; + const anchoredHere = previousAnchorId === anchorId; + const anchorChanged = !!previousAnchorId && !anchoredHere; + const wasSeeded = wasOrbitSeeded.get(satellite) === true; + const previousSpeed = Number(satellite.__galaxyOrbitSpeedMultiplier); + const speedKnown = Number.isFinite(previousSpeed); + const speedChanged = speedKnown + && Math.abs(previousSpeed - orbitalSpeed) > 1e-9; + if (wasSeeded && anchoredHere && speedChanged) { + const unitX = dx / currentRadius, unitY = dy / currentRadius; + const radialSpeed = relativeVx * unitX + relativeVy * unitY; + const tangentSpeed = (-unitY * relativeVx + unitX * relativeVy); + const tangentDirection = Math.sign(tangentSpeed) || direction; + const signedTarget = targetTangent * tangentDirection; + satellite.vx = anchorVx + radialSpeed * unitX - unitY * signedTarget; + satellite.vy = anchorVy + radialSpeed * unitY + unitX * signedTarget; + } + setGalaxyOrbitSpeed(satellite, orbitalSpeed); + /* A preexisting healthy phase only needs its parent tag. Repaired legacy/late nodes + must be genuinely sub-orbital before we touch them; this one-shot threshold avoids + resetting a valid eccentric phase on ordinary render calls. */ + const movingLocally = Math.abs(tangent) >= Math.max(0.02, targetTangent * 0.18); + /* The parent tag is not a permanent exemption: mode restoration, an old pin, or an + integration failure can zero a previously healthy satellite after it was tagged. + Repair only a truly frozen tagged phase (rather than every merely eccentric orbit), + while untagged compatibility nodes still use the conservative sub-orbital check. */ + const frozenLocally = Math.abs(tangent) < 1e-8; + if (wasSeeded && speedKnown && !anchorChanged + && ((anchoredHere && !frozenLocally) || (!previousAnchorId && movingLocally))) { + setGalaxyOrbitAnchor(satellite, anchor); + setGalaxyOrbitSeeded(satellite); + return; + } + repair.push(satellite); + const unitX = dx / currentRadius, unitY = dy / currentRadius; + const tangentX = -unitY * direction, tangentY = unitX * direction; + desiredVelocity.set(satellite, { + vx: anchorVx + tangentX * targetTangent, + vy: anchorVy + tangentY * targetTangent, + }); + }); + if (!repair.length) return; + desiredVelocity.forEach((velocity, node) => { + node.vx = velocity.vx; + node.vy = velocity.vy; + setGalaxyOrbitAnchor(node, anchor); + setGalaxyOrbitSeeded(node); + }); + }); + seedGalaxyHierarchicalLocalOrbits(nodes, gravity, softening, opts); + return nodes; + } + + /* Give whole solar systems one-shot angular momentum around the global evidence anchor. + Each system follows the composite black-hole field with a bounded eccentric perturbation. + The tag is intentionally not a permanent exemption: a filter/restore can retain the tag + while supplying a zeroed velocity. In that case repair the *system COM* once, preserving + every local star/planet relative orbit rather than leaving a visibly frozen island. */ + function seedGalaxySystemOrbits(nodes, layoutSeed, gravity, softening, reducedMotion, options) { + const opts = options || {}; + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + /* Compatibility scenes may omit velocity fields on the selected fallback anchor. Give + every physical body a finite frame velocity before computing system COM tangents; this + is deliberately not a seed tag, so normal admission/repair policy remains unchanged. */ + (nodes || []).forEach(node => { + if (!node || node.ghost || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + node.vx = Number.isFinite(node.vx) ? node.vx : 0; + node.vy = Number.isFinite(node.vy) ? node.vy : 0; + }); + /* A late external system can arrive exactly on the visible event horizon. Project that + one contact before sampling its COM radius; otherwise the zero-radius guard below would + skip it forever and the system would remain tagged but motionless after the next render. */ + if ((nodes || []).some(node => node && !node.ghost && node.anchor_role === 'global')) { + applyGalaxyBlackHoleExclusion(nodes, { + padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING, + }); + } + const centers = [...communityCenters(nodes).values()]; + const direction = (seededHash(layoutSeed, 'galaxy-spin') & 1) ? 1 : -1; + /* Reduced motion is a paint/camera preference. The live solver still advances, so it must + receive the same barycentric initial condition or whole systems contract radially without + rotating around the black hole. */ + if (centers.length < 2) { + return nodes; + } + + /* Use the same smooth black-hole field as the integrator, then add a small deterministic + eccentric/radial perturbation. Systems are bound but not painted onto a rigid circular + carousel; inner angular frequency remains higher than outer angular frequency. */ + const field = galaxyBlackHoleField(nodes, { + gravity, softening, + gravitationalConstant: opts.gravitationalConstant, + blackHoleMass: opts.blackHoleMass, + }); + if (!(field.gravitationalConstant > 0)) return nodes; + field.systems.forEach(item => { + if (item.radius <= 1e-9) return; + const members = item.center.nodes; + const carrier = galaxySystemAnchor(members) || members[0]; + const tagged = members.some(node => node.__galaxySystemOrbitSeeded === true); + const previousSpeed = Number(carrier.__galaxySystemOrbitSpeedMultiplier); + const speedKnown = Number.isFinite(previousSpeed); + const speedChanged = speedKnown + && Math.abs(previousSpeed - orbitalSpeed) > 1e-9; + /* The dominant star—not the barycentre altered by its planets' local tangents—is the + galactic carrier. G_star may change planet speed without changing this G_center orbit; + translating every member by the star's carrier correction preserves all local relative + velocities exactly. */ + const centerVx = Number.isFinite(carrier.vx) ? carrier.vx : 0; + const centerVy = Number.isFinite(carrier.vy) ? carrier.vy : 0; + const outwardX = -item.dx / item.radius, outwardY = -item.dy / item.radius; + const tangentX = -outwardY * direction, tangentY = outwardX * direction; + const tangentialSpeed = centerVx * tangentX + centerVy * tangentY; + /* A tagged eccentric system still has meaningful angular momentum. Repair only a + visibly sub-orbital COM; this avoids turning normal periapsis and apoapsis into a + per-render carousel while not accepting a nearly frozen cached tag forever. */ + const stalledThreshold = Math.max(0.0025, item.circularSpeed * 0.18); + const stalled = Math.abs(tangentialSpeed) < stalledThreshold; + if (tagged && (!speedKnown || !speedChanged) && !stalled) { + members.forEach(node => { + node.vx = Number.isFinite(node.vx) ? node.vx : 0; + node.vy = Number.isFinite(node.vy) ? node.vy : 0; + if (node.__galaxySystemOrbitSeeded !== true) { + Object.defineProperty(node, '__galaxySystemOrbitSeeded', { + value: true, writable: true, configurable: true, enumerable: false + }); + } + }); + return; + } + const tangentFactor = 0.92 + + (seededHash(layoutSeed, 'system-speed:' + item.center.id) / 0x100000000) * 0.12; + /* Start every system on a gentle settling spiral. A symmetric +/- phase can launch an + outer system away from the well before gravity turns it around; a bounded inward kick + gives the black-hole centre first claim on motion while preserving tangential rotation. */ + /* Start on the collision-free lane itself. A compulsory inward kick contradicts the + circular seed and makes every otherwise healthy system spiral into its neighbours. */ + const radialFactor = 0; + const speed = Math.min(GALAXY_SYSTEM_ORBIT_SEED_SPEED_LIMIT * orbitalSpeed, + item.circularSpeed * tangentFactor * orbitalSpeed); + const kick = { + vx: tangentX * speed + outwardX * speed * radialFactor, + vy: tangentY * speed + outwardY * speed * radialFactor, + }; + /* Translate every member by the same COM correction. That is momentum-balanced inside + the solar system (and leaves all local relative velocities exactly intact), while the + fixed black-hole frame is the intentional external momentum reservoir. Crucially we + replace a stalled COM instead of adding another kick to a tagged frozen system. */ + const deltaX = kick.vx - centerVx; + const deltaY = kick.vy - centerVy; + members.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY; + setGalaxySystemOrbitSpeed(node, orbitalSpeed); + Object.defineProperty(node, '__galaxySystemOrbitSeeded', { + value: true, writable: true, configurable: true, enumerable: false + }); + }); + }); + return nodes; + } + + function addGravityPair(left, right, gravitationalConstant, softening, alphaValue) { + const dx = right.x - left.x, dy = right.y - left.y; + const distanceSquared = dx * dx + dy * dy; + const denominator = Math.pow(distanceSquared + softening * softening, 1.5); + if (!Number.isFinite(denominator) || denominator <= 0) return; + const scale = gravitationalConstant * alphaValue / denominator; + const leftMass = finitePositive(left.gravity_mass, 1, 1000); + const rightMass = finitePositive(right.gravity_mass, 1, 1000); + left.vx = (Number.isFinite(left.vx) ? left.vx : 0) + scale * rightMass * dx; + left.vy = (Number.isFinite(left.vy) ? left.vy : 0) + scale * rightMass * dy; + right.vx = (Number.isFinite(right.vx) ? right.vx : 0) - scale * leftMass * dx; + right.vy = (Number.isFinite(right.vy) ? right.vy : 0) - scale * leftMass * dy; + } + + function buildGravityQuad(nodes, x, y, size, depth) { + const quad = { x, y, size, mass: 0, cx: 0, cy: 0, bodies: null, children: null }; + nodes.forEach(node => { + const mass = finitePositive(node.gravity_mass, 1, 1000); + quad.mass += mass; + quad.cx += node.x * mass; + quad.cy += node.y * mass; + }); + if (quad.mass) { quad.cx /= quad.mass; quad.cy /= quad.mass; } + if (nodes.length <= 1 || depth >= 24 || size <= 1e-7) { + quad.bodies = nodes; + return quad; + } + const half = size / 2, midX = x + half, midY = y + half; + const buckets = [[], [], [], []]; + nodes.forEach(node => { + const index = (node.x >= midX ? 1 : 0) + (node.y >= midY ? 2 : 0); + buckets[index].push(node); + }); + const childBoxes = [ + [x, y], [midX, y], [x, midY], [midX, midY] + ]; + quad.children = []; + buckets.forEach((bucket, index) => { + if (bucket.length) quad.children.push(buildGravityQuad( + bucket, childBoxes[index][0], childBoxes[index][1], half, depth + 1 + )); + }); + return quad; + } + function gravityQuad(nodes) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + nodes.forEach(node => { + minX = Math.min(minX, node.x); minY = Math.min(minY, node.y); + maxX = Math.max(maxX, node.x); maxY = Math.max(maxY, node.y); + }); + const size = Math.max(1e-6, maxX - minX, maxY - minY) * 1.000001; + return buildGravityQuad(nodes, minX, minY, size, 0); + } + function applyQuadGravity(target, quad, gravitationalConstant, softening, alphaValue, theta, stats) { + stats.traversals++; + if (quad.bodies) { + quad.bodies.forEach(source => { + if (source === target) return; + const proxy = { x: source.x, y: source.y, gravity_mass: source.gravity_mass, vx: 0, vy: 0 }; + addGravityPair(target, proxy, gravitationalConstant, softening, alphaValue); + stats.interactions++; + }); + return; + } + const dx = quad.cx - target.x, dy = quad.cy - target.y; + const distance = Math.hypot(dx, dy); + const containsTarget = target.x >= quad.x && target.x < quad.x + quad.size + && target.y >= quad.y && target.y < quad.y + quad.size; + if (!containsTarget && distance > 0 && quad.size / distance < theta) { + const denominator = Math.pow(dx * dx + dy * dy + softening * softening, 1.5); + const scale = gravitationalConstant * alphaValue * quad.mass / denominator; + target.vx = (Number.isFinite(target.vx) ? target.vx : 0) + scale * dx; + target.vy = (Number.isFinite(target.vy) ? target.vy : 0) + scale * dy; + stats.approximations++; + return; + } + quad.children.forEach(child => applyQuadGravity( + target, child, gravitationalConstant, softening, alphaValue, theta, stats + )); + } + function applyGalaxyGravity(nodes, options) { + const opts = options || {}; + const active = (nodes || []).filter(node => !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const groups = new Map(); + active.forEach(node => { + const key = communityKey(node); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(node); + }); + const explicitGravity = Number(opts.effectiveGravity); + const gravitationalConstant = Number.isFinite(explicitGravity) && explicitGravity >= 0 + ? explicitGravity : galaxyLocalGravityConstant(opts.gravity); + const pairFraction = Math.max(0, Math.min(1, + Number.isFinite(Number(opts.pairFraction)) ? Number(opts.pairFraction) : 1)); + const corePairFraction = Math.max(0, Math.min(1, + Number.isFinite(Number(opts.corePairFraction)) ? Number(opts.corePairFraction) + : pairFraction)); + const coreCommunity = opts.coreCommunity === undefined || opts.coreCommunity === null + ? null : String(opts.coreCommunity); + const softening = Math.max(0.1, Number(opts.softening) || 8); + const alphaValue = Number.isFinite(opts.alpha) ? Math.max(0, opts.alpha) : 1; + const exactLimit = Math.max(2, Number(opts.exactLimit) || GALAXY_EXACT_LIMIT); + const theta = Math.max(0.1, Number(opts.theta) || GALAXY_BARNES_HUT_THETA); + const stats = { communities: groups.size, interactions: 0, traversals: 0, approximations: 0 }; + groups.forEach((group, key) => { + const groupGravity = gravitationalConstant + * (coreCommunity !== null && key === coreCommunity + ? corePairFraction : pairFraction); + if (group.length <= exactLimit) { + for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) { + addGravityPair(group[i], group[j], groupGravity, softening, alphaValue); + stats.interactions++; + } + } + return; + } + const quad = gravityQuad(group); + let groupMass = 0, momentumBeforeX = 0, momentumBeforeY = 0; + group.forEach(node => { + const mass = finitePositive(node.gravity_mass, 1, 1000); + groupMass += mass; + momentumBeforeX += mass * (Number.isFinite(node.vx) ? node.vx : 0); + momentumBeforeY += mass * (Number.isFinite(node.vy) ? node.vy : 0); + }); + group.forEach(node => applyQuadGravity( + node, quad, groupGravity, softening, alphaValue, theta, stats + )); + /* Barnes-Hut approximates each target separately, so its truncation error can create a + tiny net force. Remove only that shared reference-frame drift; relative acceleration + and the internal orbit are unchanged. Exact pair communities need no correction. */ + if (groupMass > 0) { + let momentumAfterX = 0, momentumAfterY = 0; + group.forEach(node => { + const mass = finitePositive(node.gravity_mass, 1, 1000); + momentumAfterX += mass * node.vx; + momentumAfterY += mass * node.vy; + }); + const driftX = (momentumAfterX - momentumBeforeX) / groupMass; + const driftY = (momentumAfterY - momentumBeforeY) / groupMass; + group.forEach(node => { + node.vx -= driftX; + node.vy -= driftY; + }); + } + }); + return stats; + } + + /* Most of a solar system's field is a smooth Plummer halo rather than repeated close stellar + encounters. Every satellite sees the total evidence mass of its community; subtracting the + mass-weighted mean from a free system preserves its COM without changing any relative + acceleration. A small direct-pair fraction remains for organic multi-star perturbations. */ + function applyGalaxySystemHaloGravity(nodes, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const groups = new Map(); + galaxyOrbitGroups(bodies).forEach(center => groups.set(center.id, center.nodes)); + const gravity = galaxyLocalGravityConstant(opts.gravity); + const smoothFraction = Math.max(0, Math.min(1, + Number.isFinite(Number(opts.smoothFraction)) ? Number(opts.smoothFraction) : 0.85)); + const coreSmoothFraction = Math.max(0, Math.min(1, + Number.isFinite(Number(opts.coreSmoothFraction)) ? Number(opts.coreSmoothFraction) + : smoothFraction)); + const coreCommunity = opts.coreCommunity === undefined || opts.coreCommunity === null + ? null : String(opts.coreCommunity); + const alphaValue = Number.isFinite(opts.alpha) ? Math.max(0, opts.alpha) : 1; + const softening = Math.max(0.1, Number(opts.softening) || 8); + const stats = { communities: groups.size, satellites: 0 }; + if (gravity <= 0 || Math.max(smoothFraction, coreSmoothFraction) <= 0 + || alphaValue <= 0) return stats; + groups.forEach((members, key) => { + if (members.length < 2) return; + const anchor = galaxySystemAnchor(members); + const pinnedAnchor = anchor.anchor_role === 'global'; + const isCoreCommunity = coreCommunity !== null + && (key === coreCommunity || members.some(node => + String(node.community_id || '') === coreCommunity)); + const groupSmoothFraction = isCoreCommunity + ? coreSmoothFraction : smoothFraction; + const communityMass = members.reduce((sum, node) => sum + + finitePositive(node.gravity_mass, 1, 1000), 0); + const accelerations = new Map(members.map(node => [node, { ax: 0, ay: 0 }])); + orderedGalaxySatellites(members, anchor).forEach(item => { + const dx = anchor.x - item.node.x, dy = anchor.y - item.node.y; + const denominator = Math.pow( + dx * dx + dy * dy + softening * softening, 1.5 + ); + if (Number.isFinite(denominator) && denominator > 0) { + const scale = gravity * groupSmoothFraction * alphaValue + * communityMass / denominator; + const acceleration = accelerations.get(item.node); + acceleration.ax += dx * scale; + acceleration.ay += dy * scale; + stats.satellites++; + } + }); + let totalMass = 0, driftX = 0, driftY = 0; + members.forEach(node => { + const mass = finitePositive(node.gravity_mass, 1, 1000); + const acceleration = accelerations.get(node); + totalMass += mass; + driftX += mass * acceleration.ax; + driftY += mass * acceleration.ay; + }); + if (!pinnedAnchor && totalMass > 0) { driftX /= totalMass; driftY /= totalMass; } + else { driftX = 0; driftY = 0; } + const accelerationCap = Math.max(0, Number.isFinite(Number(opts.accelerationCap)) + ? Number(opts.accelerationCap) : defaultGalaxyAccelerationCap(opts.gravity)); + const maximumAcceleration = members.reduce((maximum, node) => { + const acceleration = accelerations.get(node); + return Math.max(maximum, + Math.hypot(acceleration.ax - driftX, acceleration.ay - driftY)); + }, 0); + const capScale = accelerationCap > 0 && maximumAcceleration > accelerationCap + ? accelerationCap / maximumAcceleration : 1; + members.forEach(node => { + const acceleration = accelerations.get(node); + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + + (acceleration.ax - driftX) * capScale; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + + (acceleration.ay - driftY) * capScale; + }); + }); + return stats; + } + /* Compatibility name for embedders that exercised the experimental enclosed-mass helper. */ + const applyGalaxyEnclosedSystemGravity = applyGalaxySystemHaloGravity; + + /* Hierarchical local gravity. A real solar system is not an all-to-all attraction graph: + one dominant star supplies the central well and the smaller bodies orbit that source. + The declared system anchor/role wins; compatibility scenes fall back to evidence mass + (which already has the deterministic degree-derived fallback). Satellites never become + independent wells, so a dense community cannot scramble itself through planet-to-planet + gravity. The dominant star is the local inertial frame: the black-hole and inter-system + fields translate it with the complete system, while only its planets receive this central + acceleration. That preserves every planet's sampled relative orbit without a fictitious + star wobble masking local phase. */ + function applyGalaxySystemAnchorGravity(nodes, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const groups = new Map(); + galaxyOrbitGroups(bodies).forEach(center => groups.set(center.id, center.nodes)); + const softening = Math.max(0.1, Number(opts.softening) || 8); + const alphaValue = Number.isFinite(opts.alpha) ? Math.max(0, opts.alpha) : 1; + const explicitAccelerationCap = Number.isFinite(Number(opts.accelerationCap)) + ? Math.max(0, Number(opts.accelerationCap)) : null; + const repulsionPadding = Math.max(0, Number.isFinite(Number(opts.repulsionPadding)) + ? Number(opts.repulsionPadding) : GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING); + const repulsionRange = Math.max(0.1, Number.isFinite(Number(opts.repulsionRange)) + ? Number(opts.repulsionRange) : GALAXY_SYSTEM_ANCHOR_REPULSION_RANGE); + const repulsionAcceleration = Math.max(0, + Number.isFinite(Number(opts.repulsionAcceleration)) + ? Number(opts.repulsionAcceleration) : GALAXY_SYSTEM_ANCHOR_REPULSION_ACCELERATION); + const bodyRadius = node => finitePositive( + node.radius, finitePositive(node.visual_radius, + radiusFromGravityMass(node.gravity_mass), 80), 160 + ); + const stats = { + systems: groups.size, anchors: 0, satellites: 0, + repulsions: 0, surfaceRepulsions: 0, + maximumRepulsion: 0, maximumSampledAttraction: 0, maximumNetRepulsion: 0, + minimumSurfaceNetRepulsion: null, + repulsionPadding, repulsionRange, repulsionAcceleration, + maximumAcceleration: 0, capScale: 1, + gravitySetting: galaxyAccelerationCapReference(opts.gravity), + stellarGravityFloorSetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING, + stellarGravity: galaxyStellarGravityConstant(opts.gravity) + * galaxyPhysicsMultiplier(opts.localGravitationalConstant, + GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), + localGravitationalConstant: galaxyPhysicsMultiplier( + opts.localGravitationalConstant, + GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), + eligibleStellarAnchors: 0, fallbackAnchors: 0, globalAnchors: 0, + stellarFloorActive: false, + }; + if (!(alphaValue > 0)) return stats; + groups.forEach(members => { + if (members.length < 2) return; + const anchor = galaxySystemAnchor(members); + if (!anchor) return; + stats.anchors++; + if (anchor.anchor_role === 'community') { + stats.eligibleStellarAnchors++; + if (galaxyStellarGravitySetting(opts.gravity) + > galaxyAccelerationCapReference(opts.gravity)) stats.stellarFloorActive = true; + } else if (anchor.anchor_role === 'global') stats.globalAnchors++; + else stats.fallbackAnchors++; + const gravityMultiplier = galaxyLocalGravityMultiplier(anchor, opts); + const accelerationCap = explicitAccelerationCap !== null + ? explicitAccelerationCap : defaultGalaxySystemAccelerationCap(anchor, opts.gravity) + * Math.max(0.25, gravityMultiplier); + const accelerations = new Map(members.map(node => [node, { ax: 0, ay: 0 }])); + let systemMaximumRepulsion = 0, systemMaximumSampledAttraction = 0; + let systemMaximumNetRepulsion = 0, systemMinimumSurfaceNetRepulsion = null; + const byId = new Map(members.map(node => [String(node.id), node])); + const childrenByParent = new Map(); + members.forEach(node => { + if (node === anchor) return; + const parent = galaxyLocalOrbitParent(node, members, anchor, byId) || anchor; + if (!childrenByParent.has(parent)) childrenByParent.set(parent, []); + childrenByParent.get(parent).push(node); + }); + childrenByParent.forEach((satellites, parent) => { + const parentMass = finitePositive(parent.gravity_mass, 1, 1000); + const parentGravityMultiplier = galaxyLocalGravityMultiplier(parent, opts); + const parentGravity = galaxySystemGravityConstant(parent, opts.gravity) + * parentGravityMultiplier; + satellites.sort((left, right) => Number(left.orbit_tier || 0) + - Number(right.orbit_tier || 0) || String(left.id).localeCompare(String(right.id))); + satellites.forEach(satellite => { + let dx = parent.x - satellite.x, dy = parent.y - satellite.y; + let distance = Math.hypot(dx, dy); + if (!(distance > 1e-9)) { + const angle = seededHash(0, 'stellar-pressure:' + String(parent.id) + + '|' + String(satellite.id)) / 0x100000000 * Math.PI * 2; + dx = -Math.cos(angle) * 1e-9; + dy = -Math.sin(angle) * 1e-9; + distance = 1e-9; + } + const denominator = Math.pow(dx * dx + dy * dy + softening * softening, 1.5); + if (!(denominator > 0) || !Number.isFinite(denominator)) return; + const scale = parentGravity * alphaValue / denominator; + const sampledAttraction = distance * scale * parentMass; + const satelliteAcceleration = accelerations.get(satellite); + satelliteAcceleration.ax += dx * scale * parentMass; + satelliteAcceleration.ay += dy * scale * parentMass; + /* Every local parent owns a painted clearance band. This keeps nested moons from + colliding with their immediate carrier while preserving the global black-hole + boundary as a separate constraint. */ + if (parent.anchor_role !== 'global' && repulsionAcceleration > 0) { + const surfaceDistance = bodyRadius(parent) + bodyRadius(satellite) + + repulsionPadding; + const pressureEdge = surfaceDistance + repulsionRange; + if (distance < pressureEdge) { + const depth = galaxySmoothstep((pressureEdge - distance) / repulsionRange); + const outwardAcceleration = (sampledAttraction + + repulsionAcceleration * alphaValue) * depth; + const netRepulsion = outwardAcceleration - sampledAttraction; + const unitX = dx / distance, unitY = dy / distance; + satelliteAcceleration.ax -= unitX * outwardAcceleration; + satelliteAcceleration.ay -= unitY * outwardAcceleration; + stats.repulsions++; + systemMaximumRepulsion = Math.max(systemMaximumRepulsion, outwardAcceleration); + systemMaximumSampledAttraction = Math.max( + systemMaximumSampledAttraction, sampledAttraction); + systemMaximumNetRepulsion = Math.max(systemMaximumNetRepulsion, netRepulsion); + if (distance <= surfaceDistance + 1e-9) { + stats.surfaceRepulsions++; + systemMinimumSurfaceNetRepulsion = systemMinimumSurfaceNetRepulsion === null + ? netRepulsion : Math.min(systemMinimumSurfaceNetRepulsion, netRepulsion); + } + } + } + stats.satellites++; + }); + }); + /* Do not add an equal-and-opposite local kick to the dominant node. The dashboard renders + that star as the stationary centre of its own solar system; galaxy-wide fields below + still give every member the same black-hole-frame translation. */ + const maximum = members.reduce((value, node) => { + const acceleration = accelerations.get(node); + return Math.max(value, Math.hypot(acceleration.ax, acceleration.ay)); + }, 0); + const scale = accelerationCap > 0 && maximum > accelerationCap + ? accelerationCap / maximum : 1; + stats.maximumAcceleration = Math.max(stats.maximumAcceleration, maximum * scale); + stats.maximumRepulsion = Math.max( + stats.maximumRepulsion, systemMaximumRepulsion * scale); + stats.maximumSampledAttraction = Math.max( + stats.maximumSampledAttraction, systemMaximumSampledAttraction * scale); + stats.maximumNetRepulsion = Math.max( + stats.maximumNetRepulsion, systemMaximumNetRepulsion * scale); + if (systemMinimumSurfaceNetRepulsion !== null) { + const boundedSurfaceNet = systemMinimumSurfaceNetRepulsion * scale; + stats.minimumSurfaceNetRepulsion = stats.minimumSurfaceNetRepulsion === null + ? boundedSurfaceNet : Math.min(stats.minimumSurfaceNetRepulsion, boundedSurfaceNet); + } + stats.capScale = Math.min(stats.capScale, scale); + members.forEach(node => { + const acceleration = accelerations.get(node); + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + acceleration.ax * scale; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + acceleration.ay * scale; + }); + }); + return stats; + } + + /* Permanent local-surface contact for every carrier hierarchy. Projection is radial and + bounded to the exact painted edge; velocity response removes only inward normal motion in + the parent frame. Tangential velocity is untouched, so contact cannot drain orbital phase + or manufacture a repulsive slingshot. The global anchor is included for direct black-hole + satellites; its separate event-horizon projection remains the stricter central boundary. */ + function applyGalaxySystemAnchorExclusion(nodes, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const groups = new Map(); + galaxyOrbitGroups(bodies).forEach(center => groups.set(center.id, center.nodes)); + const padding = Math.max(0, Number.isFinite(Number(opts.padding)) + ? Number(opts.padding) : GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING); + const maximumIterations = Math.max(1, Math.min(64, + Number.isFinite(Number(opts.maximumIterations)) + ? Math.floor(Number(opts.maximumIterations)) : 24)); + const clearanceEpsilon = Math.max(1e-12, + Number.isFinite(Number(opts.clearanceEpsilon)) + ? Number(opts.clearanceEpsilon) : 1e-9); + const bodyRadius = node => finitePositive( + node.radius, finitePositive(node.visual_radius, + radiusFromGravityMass(node.gravity_mass), 80), 160 + ); + const stats = { + padding, + systems: 0, contacts: 0, correctedDistance: 0, maximumShift: 0, + inwardVelocityRemoved: 0, tangentialVelocityRemoved: 0, + minimumClearance: null, iterations: 0, + }; + groups.forEach(members => { + if (members.length < 2) return; + const anchor = galaxySystemAnchor(members); + if (!anchor) return; + stats.systems++; + const byId = new Map(members.map(node => [String(node.id), node])); + /* Resolve every direct parent instead of projecting every body against the top star. This + preserves nested moon trajectories and gives each local carrier its own clearance band. */ + const satellites = members.filter(node => node !== anchor).map(node => ({ + node, parent: galaxyLocalOrbitParent(node, members, anchor, byId) || anchor, + })).sort((left, right) => Number(left.node.orbit_tier || 0) + - Number(right.node.orbit_tier || 0) || String(left.node.id).localeCompare(String(right.node.id))); + /* A bounded solve handles pathological dense payloads with 80+ bodies around one dominant + node. Ordinary non-contact systems still exit after one O(n) scan; every penetration is + projected in the stationary star frame and therefore closes in one pass per satellite. */ + for (let iteration = 0; iteration < maximumIterations; iteration++) { + let corrected = false; + let maximumPenetration = 0; + satellites.forEach(item => { + const satellite = item.node; + const parent = item.parent; + const minimumDistance = bodyRadius(parent) + bodyRadius(satellite) + padding; + let dx = satellite.x - parent.x, dy = satellite.y - parent.y; + let distance = Math.hypot(dx, dy); + let unitX, unitY; + if (distance > 1e-9) { + unitX = dx / distance; + unitY = dy / distance; + } else { + const angle = seededHash(0, String(parent.id) + '|' + String(satellite.id)) + / 0x100000000 * Math.PI * 2; + unitX = Math.cos(angle); + unitY = Math.sin(angle); + distance = 0; + } + const penetration = minimumDistance - distance; + if (penetration <= clearanceEpsilon) return; + corrected = true; + maximumPenetration = Math.max(maximumPenetration, penetration); + const correction = penetration; + const satelliteMass = finitePositive(satellite.gravity_mass, 1, 1000); + const anchorInverseMass = 0; + const satelliteInverseMass = 1 / satelliteMass; + const inverseMass = satelliteInverseMass; + const anchorShift = 0; + const satelliteShift = correction; + satellite.x += unitX * satelliteShift; + satellite.y += unitY * satelliteShift; + if (Number.isFinite(parent.fx)) parent.fx = parent.x; + if (Number.isFinite(parent.fy)) parent.fy = parent.y; + if (Number.isFinite(satellite.fx)) satellite.fx = satellite.x; + if (Number.isFinite(satellite.fy)) satellite.fy = satellite.y; + const relativeVx = (Number.isFinite(satellite.vx) ? satellite.vx : 0) + - (Number.isFinite(parent.vx) ? parent.vx : 0); + const relativeVy = (Number.isFinite(satellite.vy) ? satellite.vy : 0) + - (Number.isFinite(parent.vy) ? parent.vy : 0); + const inwardSpeed = relativeVx * unitX + relativeVy * unitY; + if (inwardSpeed < 0) { + const impulse = -inwardSpeed / inverseMass; + parent.vx -= unitX * impulse * anchorInverseMass; + parent.vy -= unitY * impulse * anchorInverseMass; + satellite.vx += unitX * impulse * satelliteInverseMass; + satellite.vy += unitY * impulse * satelliteInverseMass; + stats.inwardVelocityRemoved += -inwardSpeed; + } + stats.contacts++; + stats.correctedDistance += correction; + stats.maximumShift = Math.max(stats.maximumShift, anchorShift, satelliteShift); + }); + stats.iterations = Math.max(stats.iterations, iteration + 1); + if (!corrected) break; + if (maximumPenetration <= clearanceEpsilon) break; + } + satellites.forEach(item => { + const minimumDistance = bodyRadius(item.parent) + bodyRadius(item.node) + padding; + const rawClearance = Math.hypot(item.node.x - item.parent.x, + item.node.y - item.parent.y) + - minimumDistance; + /* Avoid reporting harmless binary rounding as an overlap. The actual phase remains + within the same 1e-9 solver tolerance; larger residuals are never hidden. */ + const clearance = rawClearance >= -clearanceEpsilon ? Math.max(0, rawClearance) + : rawClearance; + stats.minimumClearance = stats.minimumClearance === null + ? clearance : Math.min(stats.minimumClearance, clearance); + }); + }); + return stats; + } + + /* Read-only final audit for the composite black-hole/outer-wall/stellar closure. Keeping the + measurement separate from projection prevents diagnostics from claiming the pre-annulus + clearance after a member-wise outer clamp has moved a planet back through its star. */ + function galaxySystemAnchorClearance(nodes, options) { + const opts = options || {}; + const padding = Math.max(0, Number.isFinite(Number(opts.padding)) + ? Number(opts.padding) : GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING); + const bodyRadius = node => finitePositive( + node.radius, finitePositive(node.visual_radius, + radiusFromGravityMass(node.gravity_mass), 80), 160 + ); + const groups = new Map(); + galaxyOrbitGroups(nodes || []).forEach(center => groups.set(center.id, center.nodes)); + let systems = 0, satellites = 0, minimumClearance = null; + groups.forEach(members => { + if (members.length < 2) return; + const anchor = galaxySystemAnchor(members); + if (!anchor) return; + systems++; + const byId = new Map(members.map(node => [String(node.id), node])); + members.filter(node => node !== anchor).forEach(node => { + const parent = galaxyLocalOrbitParent(node, members, anchor, byId) || anchor; + const clearance = Math.hypot(node.x - parent.x, node.y - parent.y) + - bodyRadius(parent) - bodyRadius(node) - padding; + minimumClearance = minimumClearance === null + ? clearance : Math.min(minimumClearance, clearance); + satellites++; + }); + }); + return { padding, systems, satellites, minimumClearance }; + } + + function combineGalaxySystemAnchorExclusions(passes) { + const usable = (passes || []).filter(Boolean); + if (!usable.length) return { + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + systems: 0, contacts: 0, correctedDistance: 0, maximumShift: 0, + inwardVelocityRemoved: 0, tangentialVelocityRemoved: 0, + minimumClearance: null, iterations: 0, + }; + const final = usable[usable.length - 1]; + return { + padding: final.padding, + systems: Math.max(...usable.map(pass => pass.systems || 0)), + contacts: usable.reduce((sum, pass) => sum + (pass.contacts || 0), 0), + correctedDistance: usable.reduce( + (sum, pass) => sum + (pass.correctedDistance || 0), 0), + maximumShift: Math.max(...usable.map(pass => pass.maximumShift || 0)), + inwardVelocityRemoved: usable.reduce( + (sum, pass) => sum + (pass.inwardVelocityRemoved || 0), 0), + tangentialVelocityRemoved: usable.reduce( + (sum, pass) => sum + (pass.tangentialVelocityRemoved || 0), 0), + minimumClearance: final.minimumClearance, + iterations: usable.reduce((sum, pass) => sum + (pass.iterations || 0), 0), + }; + } + + /* Treat every community as one solar system and apply exact softened Newtonian attraction + between system pairs. One acceleration is applied to every member of a system, preserving + its internal orbit, while each pair contributes equal-and-opposite momentum. A single + common cap scale bounds the final acceleration without changing any system's direction or + manufacturing the outward impulses caused by post-hoc drift subtraction. Community count + is bounded by the live-scene ceiling, so O(nodes + systems^2) remains cheaper and more + physically faithful than another approximation layer here. */ + function applyGalaxyCentralGravity(nodes, options) { + const opts = options || {}; + const centers = [...communityCenters(nodes).values()]; + const gravitationalConstant = galaxyBlackHoleGravityConstant(opts.gravity); + const softening = Math.max(0.1, Number(opts.softening) || 40); + const alphaValue = Number.isFinite(opts.alpha) ? Math.max(0, opts.alpha) : 1; + const accelerationCap = Math.max(0, Number.isFinite(Number(opts.accelerationCap)) + ? Number(opts.accelerationCap) : defaultGalaxyBlackHoleAccelerationCap(opts.gravity)); + const totalMass = centers.reduce((sum, center) => sum + center.mass, 0); + if (centers.length < 2 || totalMass <= 0 || gravitationalConstant <= 0 || alphaValue <= 0) { + return { systems: centers.length, applied: 0, totalMass }; + } + const accelerations = centers.map(center => ({ center, ax: 0, ay: 0 })); + let applied = 0; + for (let leftIndex = 0; leftIndex < centers.length; leftIndex++) { + const left = centers[leftIndex]; + for (let rightIndex = leftIndex + 1; rightIndex < centers.length; rightIndex++) { + const right = centers[rightIndex]; + const dx = right.x - left.x, dy = right.y - left.y; + const denominator = Math.pow(dx * dx + dy * dy + softening * softening, 1.5); + if (!Number.isFinite(denominator) || denominator <= 0) continue; + const scale = gravitationalConstant * alphaValue / denominator; + accelerations[leftIndex].ax += scale * right.mass * dx; + accelerations[leftIndex].ay += scale * right.mass * dy; + accelerations[rightIndex].ax -= scale * left.mass * dx; + accelerations[rightIndex].ay -= scale * left.mass * dy; + applied++; + } + } + const maximumAcceleration = accelerations.reduce( + (maximum, item) => Math.max(maximum, Math.hypot(item.ax, item.ay)), 0 + ); + const capScale = accelerationCap > 0 && maximumAcceleration > accelerationCap + ? accelerationCap / maximumAcceleration : 1; + accelerations.forEach(item => { + const ax = item.ax * capScale, ay = item.ay * capScale; + item.center.nodes.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + ax; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + ay; + }); + }); + return { systems: centers.length, applied, totalMass }; + } + + /* Nearby solar systems exert a secondary Newtonian field on one another even when no + evidence edge connects them. The black-hole community is excluded here because it already + owns the stronger global potential below. Each system receives one rigid acceleration, so + cross-system attraction cannot tear apart its local orbit. Exact pairs preserve momentum; + Barnes-Hut removes only approximation drift for large scenes. */ + function applyGalaxyMutualSystemGravity(nodes, options) { + const opts = options || {}; + const allCenters = [...communityCenters(nodes).values()]; + const anchor = galaxyGlobalAnchor(nodes); + const coreKey = anchor ? communityKey(anchor) : null; + const centers = allCenters.filter(center => center && center.mass > 0 + && (coreKey === null || center.id !== coreKey)); + const strengthFraction = Math.max(0, Math.min(1, + Number.isFinite(Number(opts.strengthFraction)) + ? Number(opts.strengthFraction) : GALAXY_MUTUAL_SYSTEM_GRAVITY_FRACTION)); + const gravityMultiplier = galaxyPhysicsMultiplier(opts.gravitationalConstant, + GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8); + const gravitationalConstant = galaxyLocalGravityConstant(opts.gravity) * strengthFraction + * gravityMultiplier; + const softening = Math.max(0.1, Number(opts.softening) + || GALAXY_MUTUAL_SYSTEM_SOFTENING); + const alphaValue = Number.isFinite(opts.alpha) ? Math.max(0, opts.alpha) : 1; + const exactLimit = Math.max(2, Number(opts.exactLimit) || GALAXY_EXACT_LIMIT); + const theta = Math.max(0.1, Number(opts.theta) || GALAXY_BARNES_HUT_THETA); + const accelerationCap = Math.max(0, Number.isFinite(Number(opts.accelerationCap)) + ? Number(opts.accelerationCap) + : defaultGalaxyAccelerationCap(opts.gravity) * strengthFraction + * Math.max(0.25, gravityMultiplier)); + const stats = { + systems: centers.length, interactions: 0, traversals: 0, approximations: 0, + maximumAcceleration: 0, capScale: 1, + }; + if (centers.length < 2 || gravitationalConstant <= 0 || alphaValue <= 0) return stats; + const proxies = centers.map(center => ({ + id: center.id, x: center.x, y: center.y, gravity_mass: center.mass, + vx: 0, vy: 0, center, + })); + if (proxies.length <= exactLimit) { + for (let left = 0; left < proxies.length; left++) { + for (let right = left + 1; right < proxies.length; right++) { + addGravityPair( + proxies[left], proxies[right], gravitationalConstant, softening, alphaValue + ); + stats.interactions++; + } + } + } else { + const quad = gravityQuad(proxies); + proxies.forEach(proxy => applyQuadGravity( + proxy, quad, gravitationalConstant, softening, alphaValue, theta, stats + )); + let totalMass = 0, momentumX = 0, momentumY = 0; + proxies.forEach(proxy => { + totalMass += proxy.gravity_mass; + momentumX += proxy.gravity_mass * proxy.vx; + momentumY += proxy.gravity_mass * proxy.vy; + }); + if (totalMass > 0) proxies.forEach(proxy => { + proxy.vx -= momentumX / totalMass; + proxy.vy -= momentumY / totalMass; + }); + } + stats.maximumAcceleration = proxies.reduce((maximum, proxy) => Math.max( + maximum, Math.hypot(proxy.vx, proxy.vy) + ), 0); + stats.capScale = accelerationCap > 0 && stats.maximumAcceleration > accelerationCap + ? accelerationCap / stats.maximumAcceleration : 1; + proxies.forEach(proxy => proxy.center.nodes.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + proxy.vx * stats.capScale; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + proxy.vy * stats.capScale; + })); + return stats; + } + + function galaxyGlobalAnchor(nodes) { + let anchor = null; + (nodes || []).forEach(node => { + if (!node || node.ghost || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + if (!anchor) { anchor = node; return; } + const nodeGlobal = node.anchor_role === 'global' ? 1 : 0; + const anchorGlobal = anchor.anchor_role === 'global' ? 1 : 0; + const nodeMass = finitePositive(node.gravity_mass, 1, 1000); + const anchorMass = finitePositive(anchor.gravity_mass, 1, 1000); + const nodeRank = Number.isFinite(Number(node.scene_rank)) ? Number(node.scene_rank) : 0; + const anchorRank = Number.isFinite(Number(anchor.scene_rank)) ? Number(anchor.scene_rank) : 0; + const nodeStructure = Number.isFinite(Number(node.weighted_degree)) + ? Number(node.weighted_degree) : (Number.isFinite(Number(node.degree)) ? Number(node.degree) : 0); + const anchorStructure = Number.isFinite(Number(anchor.weighted_degree)) + ? Number(anchor.weighted_degree) : (Number.isFinite(Number(anchor.degree)) ? Number(anchor.degree) : 0); + if (nodeGlobal > anchorGlobal || (nodeGlobal === anchorGlobal + && (nodeMass > anchorMass || (nodeMass === anchorMass + && (nodeRank > anchorRank || (nodeRank === anchorRank + && (nodeStructure > anchorStructure || (nodeStructure === anchorStructure + && String(node.id).localeCompare(String(anchor.id)) < 0)))))))) anchor = node; + }); + return anchor; + } + + function galaxyBlackHoleSpinAngle(node) { + if (!node) return 0; + const propertyAngle = Number(node.__galaxyBlackHoleSpinAngle); + if (Number.isFinite(propertyAngle)) return propertyAngle; + const cachedAngle = galaxyBlackHoleSpinCache ? galaxyBlackHoleSpinCache.get(node) : null; + return Number.isFinite(cachedAngle) ? cachedAngle : 0; + } + + function setGalaxyBlackHoleSpinAngle(node, angle) { + if (!node || !Number.isFinite(angle)) return angle; + if (galaxyBlackHoleSpinCache) galaxyBlackHoleSpinCache.set(node, angle); + try { + Object.defineProperty(node, '__galaxyBlackHoleSpinAngle', { + value: angle, writable: true, configurable: true, enumerable: false, + }); + } catch (_) { + /* Frozen compatibility payloads still receive the WeakMap-backed visual phase. */ + } + return angle; + } + + function advanceGalaxyBlackHoleSpin(nodes, options) { + const opts = options || {}; + const anchor = galaxyGlobalAnchor(nodes); + if (!anchor || anchor.anchor_role !== 'global' + || opts.frozen === true || opts.orbitPaused === true) { + return anchor ? galaxyBlackHoleSpinAngle(anchor) : 0; + } + const timestep = Math.max(0.001, Math.min(2, + Number(opts.timestep) || GALAXY_FIXED_TIMESTEP)); + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const direction = (seededHash(opts.layoutSeed, 'black-hole-spin') & 1) ? 1 : -1; + return setGalaxyBlackHoleSpinAngle(anchor, + galaxyBlackHoleSpinAngle(anchor) + direction + * GALAXY_BLACK_HOLE_SPIN_RATE * orbitalSpeed * timestep); + } + + function linearMedian(values) { + if (!values.length) return 0; + const data = values.slice(); + const target = Math.floor((data.length - 1) / 2); + let left = 0, right = data.length - 1; + while (left < right) { + const pivot = data[(left + right) >> 1]; + let low = left, high = right; + while (low <= high) { + while (data[low] < pivot) low++; + while (data[high] > pivot) high--; + if (low <= high) { + const swap = data[low]; data[low] = data[high]; data[high] = swap; + low++; high--; + } + } + if (target <= high) right = high; + else if (target >= low) left = low; + else break; + } + return data[target]; + } + + /* A galaxy is not a collection of peer point masses: its dominant evidence node is the + black hole, its community is the dense bulge, and all remaining evidence supplies a + smooth halo. This Plummer composite is O(nodes + systems), continuous across system-rank + changes, and conservative in the black-hole frame. It also gives differential rotation: + omega² = G[M_core/(r²+eps²)^(3/2) + M_halo/(r²+a²)^(3/2)]. */ + function galaxyBlackHoleField(nodes, options) { + const opts = options || {}; + const centers = galaxyOrbitGroups(nodes); + const anchor = galaxyGlobalAnchor(nodes); + if (!anchor) return { + anchor: null, systems: [], coreMass: 0, haloMass: 0, haloScale: 0, traversals: 0 + }; + const coreKey = String(anchor.id); + const totalMass = [...centers.values()].reduce((sum, center) => sum + center.mass, 0); + /* The singular center term is sourced by the actual dominant evidence node. Other stars + in its community remain part of the smooth bulge/halo instead of inflating black-hole + mass merely because they share a community label. */ + const blackHoleMassMultiplier = galaxyPhysicsMultiplier(opts.blackHoleMass, + GALAXY_BLACK_HOLE_MASS_MULTIPLIER, 16); + const baseCoreMass = finitePositive(anchor.gravity_mass, 1, 1000); + const coreMass = baseCoreMass * blackHoleMassMultiplier; + /* Black-hole mass tuning changes only the compact central source. It must not create or + consume halo evidence mass; the scene's remaining authored mass stays invariant. */ + const haloMass = Math.max(0, totalMass - baseCoreMass); + const external = [...centers.values()].filter(center => center.id !== coreKey).map(center => ({ + center, + dx: anchor.x - center.x, + dy: anchor.y - center.y, + radius: Math.hypot(center.x - anchor.x, center.y - anchor.y), + })); + const coreSoftening = Math.max(0.1, Number(opts.softening) || 40); + const hintedRadii = external.map(item => { + const hint = item.center.nodes.map(node => Number(node.galactic_radius)) + .find(value => Number.isFinite(value) && value > 0); + return hint || item.radius; + }); + const initialMedianRadius = linearMedian(hintedRadii); + const explicitScale = Number(opts.haloScale); + const cachedScale = Number(anchor.__galaxyHaloScale); + const haloScale = Math.max(coreSoftening * 2, + Number.isFinite(explicitScale) && explicitScale > 0 ? explicitScale + : Number.isFinite(cachedScale) && cachedScale > 0 ? cachedScale + : initialMedianRadius * 0.65); + /* The halo is part of the scene's potential, not a rubber band fitted to the current + positions. Recomputing it after every inward step shrinks the Plummer radius, deepens + the next step, and creates runaway collapse/ejection. Cache the seed scale on the + black-hole node; it is non-enumerable, so exports and a fresh setData payload stay clean. */ + if (!(Number.isFinite(cachedScale) && cachedScale > 0) + && !(Number.isFinite(explicitScale) && explicitScale > 0)) { + Object.defineProperty(anchor, '__galaxyHaloScale', { + value: haloScale, writable: false, configurable: true, enumerable: false + }); + } + const explicitGlobal = anchor.anchor_role === 'global'; + const gravitationalConstantMultiplier = galaxyPhysicsMultiplier(opts.gravitationalConstant, + GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8); + const gravitationalConstant = galaxyBlackHoleGravityConstant(opts.gravity, explicitGlobal) + * gravitationalConstantMultiplier; + const accelerationCap = Math.max(0, Number.isFinite(Number(opts.accelerationCap)) + ? Number(opts.accelerationCap) + : defaultGalaxyBlackHoleAccelerationCap(opts.gravity, explicitGlobal) + * Math.max(0.25, Math.min(8, + gravitationalConstantMultiplier * Math.max(1, blackHoleMassMultiplier)))); + const systems = external.map(item => { + const coreDenominator = Math.pow( + item.radius * item.radius + coreSoftening * coreSoftening, 1.5 + ); + const haloDenominator = Math.pow( + item.radius * item.radius + haloScale * haloScale, 1.5 + ); + const omegaSquared = gravitationalConstant * ( + coreMass / coreDenominator + (haloMass > 0 ? haloMass / haloDenominator : 0) + ); + const omega = Math.sqrt(Math.max(0, omegaSquared)); + return { ...item, omega, circularSpeed: omega * item.radius, + ax: item.dx * omegaSquared, ay: item.dy * omegaSquared }; + }); + const maximumAcceleration = systems.reduce( + (maximum, item) => Math.max(maximum, Math.hypot(item.ax, item.ay)), 0 + ); + const capScale = accelerationCap > 0 && maximumAcceleration > accelerationCap + ? accelerationCap / maximumAcceleration : 1; + if (capScale < 1) systems.forEach(item => { + item.ax *= capScale; + item.ay *= capScale; + item.omega *= Math.sqrt(capScale); + item.circularSpeed *= Math.sqrt(capScale); + }); + return { + anchor, systems, baseCoreMass, coreMass, haloMass, haloScale, totalMass, + gravitationalConstant, gravitationalConstantMultiplier, + blackHoleMassMultiplier, + gravitySetting: galaxyBlackHoleGravitySetting(opts.gravity, explicitGlobal), + floorActive: explicitGlobal && Number(opts.gravity) < GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, + traversals: centers.size, + }; + } + + function applyGalaxyBlackHoleGravity(nodes, options) { + const field = galaxyBlackHoleField(nodes, options); + field.systems.forEach(item => item.center.nodes.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + item.ax; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + item.ay; + })); + return { + anchorId: field.anchor ? field.anchor.id : null, + systems: field.systems.length, + coreMass: field.coreMass, + haloMass: field.haloMass, + haloScale: field.haloScale, + traversals: field.traversals, + }; + } + + function setGalaxySpacetimeWarp(node, value) { + if (!node) return; + const warp = Math.max(0, Math.min(1, Number(value) || 0)); + try { + if (Object.prototype.hasOwnProperty.call(node, '__galaxySpacetimeWarp')) { + node.__galaxySpacetimeWarp = warp; + } else { + Object.defineProperty(node, '__galaxySpacetimeWarp', { + value: warp, writable: true, configurable: true, enumerable: false, + }); + } + } catch (error) { /* Frozen compatibility payloads still receive the physical field. */ } + } + + /* Bounded weak-field frame dragging plus a smooth near-horizon acceleration band. External + solar systems receive one rigid carrier acceleration, so a star's planets keep their local + orbit exactly; core-community bodies are sampled independently around the fixed black hole. + The strict painted horizon remains an impenetrable numerical boundary, preventing a + singular acceleration while the warp value lets paint fade/lens an approaching body. */ + function applyGalaxySpacetimeAcceleration(nodes, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const field = galaxyBlackHoleField(bodies, opts); + const anchor = field.anchor && field.anchor.anchor_role === 'global' ? field.anchor : null; + const stats = { + anchorId: anchor ? anchor.id : null, systems: 0, coreNodes: 0, warpedNodes: 0, + maximumWarp: 0, maximumFrameDragAcceleration: 0, + maximumHorizonAcceleration: 0, + tidalSystems: 0, tidalPlanets: 0, maximumTidalAcceleration: 0, + accelerations: new Map(), + }; + bodies.forEach(node => setGalaxySpacetimeWarp(node, node === anchor ? 1 : 0)); + if (!anchor) return stats; + const anchorRadius = finitePositive(anchor.radius, evidenceNodeRadius(anchor, 3), 160); + const padding = Math.max(0, Number.isFinite(Number(opts.blackHoleExclusionPadding)) + ? Number(opts.blackHoleExclusionPadding) : GALAXY_BLACK_HOLE_EXCLUSION_PADDING); + const influenceScale = Math.max(1.1, + Number.isFinite(Number(opts.eventHorizonInfluenceScale)) + ? Number(opts.eventHorizonInfluenceScale) : GALAXY_EVENT_HORIZON_INFLUENCE_SCALE); + const draggingFraction = Math.max(0, Number.isFinite(Number(opts.frameDraggingFraction)) + ? Number(opts.frameDraggingFraction) : GALAXY_FRAME_DRAGGING_FRACTION); + const draggingCap = Math.max(0, Number.isFinite(Number(opts.frameDraggingMaxAcceleration)) + ? Number(opts.frameDraggingMaxAcceleration) : GALAXY_FRAME_DRAGGING_MAX_ACCELERATION); + const horizonAcceleration = Math.max(0, + Number.isFinite(Number(opts.eventHorizonInwardAcceleration)) + ? Number(opts.eventHorizonInwardAcceleration) + : GALAXY_EVENT_HORIZON_INWARD_ACCELERATION); + const direction = Number(opts.frameDraggingDirection) < 0 ? -1 : 1; + const coreKey = String(anchor.id); + const bodyRadius = node => finitePositive( + node.radius, evidenceNodeRadius(node, 3), 160 + ); + const accelerate = (members, dx, dy, contactRadius, gravityAcceleration, scope) => { + const distance = Math.hypot(dx, dy); + if (!(distance > 1e-9)) return 0; + const unitX = dx / distance, unitY = dy / distance; + /* `contactRadius` includes the complete solar-system radius so its nearest painted + planet cannot cross the black-hole surface. Multiplying that composite radius made a + wide solar system look "near horizon" while its star was still far away, draining the + ordinary galactic orbit. Curvature instead extends a fixed number of black-hole radii + beyond the safe painted contact: system size affects collision clearance, not the + spacetime-well thickness. */ + const outerRadius = galaxyEventHorizonOuterRadius( + anchorRadius, contactRadius, influenceScale); + const warp = distance < outerRadius + ? galaxySmoothstep((outerRadius - distance) / Math.max(1e-9, outerRadius - contactRadius)) + : 0; + const radialAcceleration = horizonAcceleration * warp * warp; + const frameAcceleration = Math.min(draggingCap, + Math.max(0, gravityAcceleration) * draggingFraction + * warp * Math.pow(contactRadius / Math.max(contactRadius, distance), 2)); + const tangentX = -unitY * direction, tangentY = unitX * direction; + members.forEach(node => { + stats.accelerations.set(node, { + ax: -unitX * radialAcceleration + tangentX * frameAcceleration, + ay: -unitY * radialAcceleration + tangentY * frameAcceleration, + }); + setGalaxySpacetimeWarp(node, warp); + }); + if (warp > 0) stats.warpedNodes += members.length; + stats.maximumWarp = Math.max(stats.maximumWarp, warp); + stats.maximumFrameDragAcceleration = Math.max( + stats.maximumFrameDragAcceleration, frameAcceleration); + stats.maximumHorizonAcceleration = Math.max( + stats.maximumHorizonAcceleration, radialAcceleration); + if (scope === 'core') stats.coreNodes += members.length; + else stats.systems++; + return warp; + }; + const centers = galaxyOrbitGroups(bodies); + centers.forEach(center => { + if (center.id === coreKey) { + center.nodes.forEach(node => { + if (node === anchor) return; + const dx = node.x - anchor.x, dy = node.y - anchor.y; + const distance = Math.hypot(dx, dy); + const denominator = Math.pow(distance * distance + + Math.max(0.1, Number(opts.softening) || 8) ** 2, 1.5); + const gravityAcceleration = denominator > 0 + ? field.gravitationalConstant * field.coreMass * distance / denominator : 0; + accelerate([node], dx, dy, + anchorRadius + bodyRadius(node) + padding, gravityAcceleration, 'core'); + }); + return; + } + const item = field.systems.find(candidate => candidate.center.id === center.id); + if (!item) return; + /* Galactic and stellar distances are deliberately two visual scales on one canvas. A + wide outer planet must not make its otherwise distant star count as near-horizon. Use + the dominant star as the rigid system carrier, then give every member the same weak + precession/decay delta so the local solar orbit remains exact. */ + const carrier = galaxySystemAnchor(center.nodes) || center.nodes[0]; + const carrierDx = carrier.x - anchor.x, carrierDy = carrier.y - anchor.y; + const warp = accelerate(center.nodes, carrierDx, carrierDy, + anchorRadius + bodyRadius(carrier) + padding, + Math.hypot(item.ax, item.ay), 'system'); + /* The shared carrier acceleration above preserves the local solar frame. Inside the + bounded horizon band, add only the differential part of the black-hole field to + planets: T·s = GM/r³ [3(n·s)n - s]. The community star remains the inertial carrier; + ordinary far systems have warp=0 and are byte-for-behaviour unchanged. */ + if (!(warp > 0) || carrier.anchor_role !== 'community') return; + const carrierDistance = Math.hypot(carrierDx, carrierDy); + if (!(carrierDistance > 1e-9)) return; + const unitX = carrierDx / carrierDistance, unitY = carrierDy / carrierDistance; + const tidalFraction = Math.max(0, Number.isFinite(Number(opts.tidalStrengthFraction)) + ? Number(opts.tidalStrengthFraction) : GALAXY_TIDAL_STRENGTH_FRACTION); + const tidalCap = Math.max(0, Number.isFinite(Number(opts.tidalAccelerationCap)) + ? Number(opts.tidalAccelerationCap) : GALAXY_TIDAL_ACCELERATION_CAP); + const softenedRadiusSquared = carrierDistance * carrierDistance + + Math.max(0.1, Number(opts.softening) || 8) ** 2; + const tensorScale = field.gravitationalConstant * field.coreMass + / Math.pow(softenedRadiusSquared, 1.5) * tidalFraction * warp; + let systemApplied = false; + center.nodes.forEach(node => { + if (node === carrier || node.id === opts.fixedNodeId + || node.anchor_role === 'community' || node.anchor_role === 'global') return; + const offsetX = node.x - carrier.x, offsetY = node.y - carrier.y; + const projection = offsetX * unitX + offsetY * unitY; + let ax = tensorScale * (3 * projection * unitX - offsetX); + let ay = tensorScale * (3 * projection * unitY - offsetY); + const magnitude = Math.hypot(ax, ay); + if (tidalCap > 0 && magnitude > tidalCap) { + const scale = tidalCap / magnitude; + ax *= scale; ay *= scale; + } + const existing = stats.accelerations.get(node) || { ax: 0, ay: 0 }; + stats.accelerations.set(node, { ax: existing.ax + ax, ay: existing.ay + ay }); + const applied = Math.hypot(ax, ay); + if (applied > 0) { + stats.tidalPlanets++; + stats.maximumTidalAcceleration = Math.max(stats.maximumTidalAcceleration, applied); + systemApplied = true; + } + }); + if (systemApplied) stats.tidalSystems++; + }); + return stats; + } + + /* Dissipate only the black-hole-frame carrier tangent in the event-horizon band. Local + planet/star relative velocity is untouched because every external system receives the same + delta. This models orbital decay without a singular kick or the violent local reheating that + per-node damping would cause. */ + function applyGalaxyEventHorizonDecay(nodes, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const anchor = galaxyGlobalAnchor(bodies); + const rate = Math.max(0, Number.isFinite(Number(opts.eventHorizonDecayRate)) + ? Number(opts.eventHorizonDecayRate) : GALAXY_EVENT_HORIZON_DECAY_RATE); + const timestep = Math.max(0, Number(opts.timestep) || 1); + const stats = { anchorId: anchor ? anchor.id : null, systems: 0, nodes: 0, + maximumWarp: 0, maximumVelocityRemoved: 0 }; + if (!anchor || anchor.anchor_role !== 'global' || !(rate > 0) || !(timestep > 0)) return stats; + const anchorVx = Number.isFinite(anchor.vx) ? anchor.vx : 0; + const anchorVy = Number.isFinite(anchor.vy) ? anchor.vy : 0; + const coreKey = String(anchor.id); + galaxyOrbitGroups(bodies).forEach(center => { + const members = center.id === coreKey + ? center.nodes.filter(node => node !== anchor).map(node => [node]) + : [center.nodes]; + members.forEach(group => { + if (!group.length) return; + const warp = group.reduce((maximum, node) => Math.max(maximum, + Number(node.__galaxySpacetimeWarp) || 0), 0); + if (!(warp > 0)) return; + let mass = 0, x = 0, y = 0, vx = 0, vy = 0; + group.forEach(node => { + const nodeMass = finitePositive(node.gravity_mass, 1, 1000); + mass += nodeMass; x += node.x * nodeMass; y += node.y * nodeMass; + vx += (Number.isFinite(node.vx) ? node.vx : 0) * nodeMass; + vy += (Number.isFinite(node.vy) ? node.vy : 0) * nodeMass; + }); + if (!(mass > 0)) return; + x /= mass; y /= mass; vx = vx / mass - anchorVx; vy = vy / mass - anchorVy; + const dx = x - anchor.x, dy = y - anchor.y, distance = Math.hypot(dx, dy); + if (!(distance > 1e-9)) return; + const unitX = dx / distance, unitY = dy / distance; + const tangentX = -unitY, tangentY = unitX; + const tangentSpeed = vx * tangentX + vy * tangentY; + const keep = Math.exp(-rate * warp * warp * timestep); + const removed = tangentSpeed * (1 - keep); + group.forEach(node => { + node.vx -= tangentX * removed; + node.vy -= tangentY * removed; + }); + stats.systems++; + stats.nodes += group.length; + stats.maximumWarp = Math.max(stats.maximumWarp, warp); + stats.maximumVelocityRemoved = Math.max(stats.maximumVelocityRemoved, Math.abs(removed)); + }); + }); + return stats; + } + + /* Conservative drag-release capture. Only a non-anchor body already declaring a community + star, or belonging to that star's authored community, is eligible; this never rewrites + system_anchor_id/community topology. Sub-escape releases inside the bounded capture radius + are inserted into a softened circular star-relative orbit. High-speed releases retain their + capped pointer velocity as intentional escape trajectories. */ + function galaxySlingshotCapture(node, nodes, releaseVelocity, options) { + const opts = options || {}; + const velocity = { + vx: Number.isFinite(releaseVelocity && releaseVelocity.vx) ? releaseVelocity.vx : 0, + vy: Number.isFinite(releaseVelocity && releaseVelocity.vy) ? releaseVelocity.vy : 0, + }; + const result = { eligible: false, captured: false, escaped: false, + reason: 'ineligible', starId: null, radius: null, circularSpeed: null, + escapeSpeed: null, vx: velocity.vx, vy: velocity.vy }; + if (!node || node.anchor_role === 'global' || node.anchor_role === 'community' + || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return result; + const explicitId = node.system_anchor_id === undefined || node.system_anchor_id === null + ? '' : String(node.system_anchor_id).trim(); + const stars = (nodes || []).filter(candidate => candidate && candidate !== node + && !candidate.ghost && candidate.anchor_role === 'community' + && Number.isFinite(candidate.x) && Number.isFinite(candidate.y)); + let candidates = explicitId + ? stars.filter(star => String(star.id) === explicitId) + : stars.filter(star => communityKey(star) === communityKey(node)); + if (!candidates.length) return result; + candidates = candidates.sort((left, right) => + Math.hypot(node.x - left.x, node.y - left.y) + - Math.hypot(node.x - right.x, node.y - right.y) + || String(left.id).localeCompare(String(right.id))); + const star = candidates[0]; + const dx = node.x - star.x, dy = node.y - star.y; + const radius = Math.hypot(dx, dy); + const captureRadius = Math.max(1, Number.isFinite(Number(opts.captureRadius)) + ? Number(opts.captureRadius) : GALAXY_SLINGSHOT_CAPTURE_RADIUS); + result.eligible = true; + result.starId = star.id; + result.radius = radius; + if (!(radius > 1e-9) || radius > captureRadius) { + result.reason = radius > captureRadius ? 'outside-capture-radius' : 'coincident'; + return result; + } + const multiplier = galaxyLocalGravityMultiplier(star, opts); + const gravitationalParameter = galaxySystemGravityConstant(star, opts.gravity) + * multiplier * finitePositive(star.gravity_mass, 1, 1000); + const softening = Math.max(0.1, Number(opts.softening) || 8); + const denominator = Math.pow(radius * radius + softening * softening, 1.5); + const inwardAcceleration = denominator > 0 + ? gravitationalParameter * radius / denominator : 0; + const circularSpeed = Math.sqrt(Math.max(0, inwardAcceleration * radius)); + const escapeSpeed = circularSpeed * Math.SQRT2; + const starVx = Number.isFinite(star.vx) ? star.vx : 0; + const starVy = Number.isFinite(star.vy) ? star.vy : 0; + const relativeVx = velocity.vx - starVx, relativeVy = velocity.vy - starVy; + const relativeSpeed = Math.hypot(relativeVx, relativeVy); + result.circularSpeed = circularSpeed; + result.escapeSpeed = escapeSpeed; + if (relativeSpeed > escapeSpeed * GALAXY_SLINGSHOT_ESCAPE_FACTOR) { + result.escaped = true; + result.reason = 'escape-velocity'; + return result; + } + const unitX = dx / radius, unitY = dy / radius; + let direction = Math.sign(-dy * relativeVx + dx * relativeVy); + if (!direction) direction = (seededHash(opts.layoutSeed, + 'slingshot:' + String(node.id) + '|' + String(star.id)) & 1) ? 1 : -1; + const insertionSpeed = Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, circularSpeed); + result.vx = starVx - unitY * insertionSpeed * direction; + result.vy = starVy + unitX * insertionSpeed * direction; + const absoluteSpeed = Math.hypot(result.vx, result.vy); + if (absoluteSpeed > GALAXY_SLINGSHOT_SPEED_LIMIT) { + const scale = GALAXY_SLINGSHOT_SPEED_LIMIT / absoluteSpeed; + result.vx *= scale; result.vy *= scale; + } + result.captured = true; + result.reason = explicitId ? 'authored-anchor' : 'authored-community'; + return result; + } + + /* History ghosts are intentionally massless: they never enter community COMs, gravity, + contacts, or recoil. They are nevertheless painted by default, so a frozen historical + marker is visually indistinguishable from a broken galaxy. Advance each as an exact + test particle in the same cached core+halo potential used by live systems. Holding its + sampled radius constant is deliberate: it gives the dim history layer a calm, bounded + black-hole sweep without feeding any energy back into the evidence simulation. */ + function integrateGalaxyGhostOrbits(nodes, options) { + const opts = options || {}; + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const ghosts = (nodes || []).filter(node => node && node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + if (!ghosts.length || !bodies.length) return { ghosts: ghosts.length, advanced: 0 }; + const centralSoftening = Math.max(0.1, Number(opts.centralSoftening) || opts.softening || 40); + const field = galaxyBlackHoleField(bodies, Object.assign({}, opts, { softening: centralSoftening })); + const anchor = field.anchor && field.anchor.anchor_role === 'global' ? field.anchor : null; + if (!anchor || !(field.gravitationalConstant > 0)) { + return { ghosts: ghosts.length, advanced: 0 }; + } + const envelope = galaxyFarFieldEnvelope(bodies, opts); + const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); + const direction = (seededHash(opts.layoutSeed, 'galaxy-spin') & 1) ? 1 : -1; + const anchorRadius = finitePositive(anchor.radius, + finitePositive(anchor.visual_radius, 3, 160), 160); + let advanced = 0; + ghosts.forEach(node => { + const ghostRadius = finitePositive(node.radius, + finitePositive(node.visual_radius, 2.5, 64), 64); + const inner = anchorRadius + ghostRadius + GALAXY_BLACK_HOLE_EXCLUSION_PADDING; + const outer = Math.max(inner, (Number(envelope.envelopeRadius) || inner) - ghostRadius); + let radius = Number(node.__galaxyGhostOrbitRadius); + if (!(Number.isFinite(radius) && radius >= inner && radius <= outer)) { + radius = Math.max(inner, Math.min(outer, Math.hypot(node.x - anchor.x, node.y - anchor.y))); + if (!(radius > 1e-9)) radius = inner; + Object.defineProperty(node, '__galaxyGhostOrbitRadius', { + value: radius, writable: true, configurable: true, enumerable: false, + }); + } + let angle = Math.atan2(node.y - anchor.y, node.x - anchor.x); + if (!Number.isFinite(angle)) { + angle = (seededHash(opts.layoutSeed, 'ghost-orbit:' + String(node.id)) / 0x100000000) + * Math.PI * 2; + } + const coreDenominator = Math.pow(radius * radius + centralSoftening * centralSoftening, 1.5); + const haloDenominator = Math.pow(radius * radius + field.haloScale * field.haloScale, 1.5); + const omegaSquared = field.gravitationalConstant * ( + field.coreMass / coreDenominator + + (field.haloMass > 0 ? field.haloMass / haloDenominator : 0) + ); + const omega = Math.min(Math.sqrt(Math.max(0, omegaSquared)) * orbitalSpeed, + GALAXY_SYSTEM_ORBIT_SEED_SPEED_LIMIT * orbitalSpeed / Math.max(1e-6, radius)); + angle += direction * omega * timestep; + node.x = anchor.x + Math.cos(angle) * radius; + node.y = anchor.y + Math.sin(angle) * radius; + const speed = omega * radius; + node.vx = -Math.sin(angle) * speed * direction; + node.vy = Math.cos(angle) * speed * direction; + Object.defineProperty(node, '__galaxyGhostOrbitSeeded', { + value: true, writable: true, configurable: true, enumerable: false, + }); + advanced++; + }); + return { ghosts: ghosts.length, advanced }; + } + + /* Complete/oversized Galaxy views deliberately bypass the O(n²) live solver. They still + need to look alive: a static galaxy with thousands of painted bodies reads as a failure, + not as a performance policy. This O(n) clock advances cached hierarchical phases exactly: + each dominant star sweeps the black hole, then each satellite sweeps that star. It is + kinematic only—no mass, contact, link, or recoil is introduced into the evidence model. */ + function advanceGalaxyKinematicLocalMembers(members, carrier, carrierTarget, options) { + const opts = options || {}; + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const localSoftening = Math.max(0.1, Number(opts.localSoftening) || opts.softening || 40); + const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); + const localOrbitCache = opts.localOrbitCache || '__galaxyKinematicLocalOrbit'; + const nodeRadius = node => finitePositive(node.radius, + finitePositive(node.visual_radius, 3, 160), 160); + const byId = new Map((members || []).map(node => [String(node.id), node])); + const targets = new Map([[carrier, carrierTarget]]); + const visiting = new Set(); + let satellites = 0; + const visit = node => { + if (!node || node === carrier) return carrierTarget; + const existingTarget = targets.get(node); + if (existingTarget) return existingTarget; + if (visiting.has(node)) return carrierTarget; + visiting.add(node); + const parent = galaxyLocalOrbitParent(node, members, carrier, byId) || carrier; + const parentTarget = visit(parent); + const parentId = String(parent.id); + const parentX = Number.isFinite(parent.x) ? parent.x : 0; + const parentY = Number.isFinite(parent.y) ? parent.y : 0; + const currentRadius = Math.hypot(node.x - parentX, node.y - parentY); + const minimumRadius = nodeRadius(parent) + nodeRadius(node) + + GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING; + let local = node[localOrbitCache]; + if (!local || local.anchorId !== parentId) { + local = setGalaxyKinematicPhase(node, localOrbitCache, { + anchorId: parentId, + radius: Math.max(minimumRadius, currentRadius), + angle: currentRadius > 1e-9 + ? Math.atan2(node.y - parentY, node.x - parentX) + : seededHash(opts.layoutSeed, 'kinematic-local:' + String(node.id)) + / 0x100000000 * Math.PI * 2, + direction: (seededHash(opts.layoutSeed, 'system:' + parentId) & 1) ? 1 : -1, + }); + } + if (!Number.isFinite(local.angle)) local.angle = seededHash( + opts.layoutSeed, 'kinematic-local:' + String(node.id)) / 0x100000000 * Math.PI * 2; + const localRadius = Math.max(minimumRadius, Number(local.radius) || currentRadius || 1); + local.radius = localRadius; + const localGravityMultiplier = galaxyLocalGravityMultiplier(parent, opts); + const localGravity = galaxySystemGravityConstant(parent, opts.gravity) + * localGravityMultiplier; + const denominator = Math.pow(localRadius * localRadius + localSoftening * localSoftening, 1.5); + const rawAcceleration = localGravity * finitePositive(parent.gravity_mass, 1, 1000) + * localRadius / Math.max(1e-9, denominator); + const acceleration = Math.min( + defaultGalaxySystemAccelerationCap(parent, opts.gravity) + * Math.max(0.25, localGravityMultiplier), rawAcceleration); + const omega = Math.min( + Math.sqrt(Math.max(0, acceleration / localRadius)) * orbitalSpeed, + GALAXY_LOCAL_RELATIVE_SPEED_LIMIT * orbitalSpeed / localRadius); + local.angle += local.direction * omega * timestep; + const localSpeed = omega * localRadius; + const offsetX = Math.cos(local.angle) * localRadius; + const offsetY = Math.sin(local.angle) * localRadius; + const target = { + x: parentTarget.x + offsetX, + y: parentTarget.y + offsetY, + vx: parentTarget.vx - Math.sin(local.angle) * localSpeed * local.direction, + vy: parentTarget.vy + Math.cos(local.angle) * localSpeed * local.direction, + }; + targets.set(node, target); + visiting.delete(node); + satellites++; + return target; + }; + (members || []).forEach(node => { if (node !== carrier) visit(node); }); + targets.forEach((target, node) => { + if (node === carrier) return; + node.x = target.x; node.y = target.y; node.vx = target.vx; node.vy = target.vy; + if (Number.isFinite(node.fx)) node.fx = target.x; + if (Number.isFinite(node.fy)) node.fy = target.y; + }); + return { targets, satellites }; + } + + function setGalaxyKinematicPhase(node, name, value) { + try { + Object.defineProperty(node, name, { + value, writable: true, configurable: true, enumerable: false, + }); + } catch (error) { node[name] = value; } + return value; + } + + function advanceGalaxyKinematicOrbits(nodes, options) { + const opts = options || {}; + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const empty = { bodies: bodies.length, systems: 0, satellites: 0, + systemPacking: { systems: 0, overlaps: 0, adjustedSystems: 0, + remainingOverlaps: 0, infeasiblePairs: 0, gap: 0 }, + ghostOrbit: { ghosts: 0, advanced: 0 } }; + if (!bodies.length) return empty; + const centralSoftening = Math.max(0.1, + Number(opts.centralSoftening) || opts.softening || 40); + const localSoftening = Math.max(0.1, + Number(opts.localSoftening) || opts.softening || 40); + const field = galaxyBlackHoleField(bodies, Object.assign({}, opts, { softening: centralSoftening })); + const anchor = field.anchor && field.anchor.anchor_role === 'global' ? field.anchor : null; + if (!anchor || !(field.gravitationalConstant > 0)) return empty; + const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); + const direction = (seededHash(opts.layoutSeed, 'galaxy-spin') & 1) ? 1 : -1; + const envelope = galaxyFarFieldEnvelope(bodies, opts); + const centers = galaxyOrbitGroups(bodies); + const coreKey = String(anchor.id); + const nodeRadius = node => finitePositive(node.radius, + finitePositive(node.visual_radius, 3, 160), 160); + const setPhase = (node, name, value) => { + try { + Object.defineProperty(node, name, { + value, writable: true, configurable: true, enumerable: false, + }); + } catch (error) { node[name] = value; } + return value; + }; + const moveNode = (node, x, y, vx, vy) => { + node.x = x; node.y = y; node.vx = vx; node.vy = vy; + if (Number.isFinite(node.fx)) node.fx = x; + if (Number.isFinite(node.fy)) node.fy = y; + }; + const angularFrequency = radius => { + const coreDenominator = Math.pow(radius * radius + centralSoftening * centralSoftening, 1.5); + const haloDenominator = Math.pow(radius * radius + field.haloScale * field.haloScale, 1.5); + return Math.sqrt(Math.max(0, field.gravitationalConstant * ( + field.coreMass / coreDenominator + + (field.haloMass > 0 ? field.haloMass / haloDenominator : 0) + ))) * orbitalSpeed; + }; + const boundedRadius = (radius, extent) => { + const inner = nodeRadius(anchor) + Math.max(0, extent) + + GALAXY_BLACK_HOLE_EXCLUSION_PADDING; + const outer = Math.max(inner, (Number(envelope.envelopeRadius) || inner) - Math.max(0, extent)); + return Math.max(inner, Math.min(outer, radius)); + }; + let systems = 0, satellites = 0; + field.systems.forEach(item => { + const members = item.center.nodes; + if (!members.length || members.some(node => node.id === opts.fixedNodeId)) return; + const star = galaxySystemAnchor(members); + if (!star) return; + /* The star, rather than the changing system COM, owns both hierarchy frames. Its cached + black-hole phase is unaffected by the current distribution of planets, and its local + position never receives an opposite barycentric wobble. */ + const extent = members.reduce((maximum, node) => Math.max(maximum, + Math.hypot(node.x - star.x, node.y - star.y) + nodeRadius(node)), 0); + const starRadius = Math.hypot(star.x - anchor.x, star.y - anchor.y); + let orbit = star.__galaxyKinematicGlobalOrbit; + if (!orbit || orbit.anchorId !== String(anchor.id) || orbit.systemId !== String(item.center.id)) { + orbit = setPhase(star, '__galaxyKinematicGlobalOrbit', { + anchorId: String(anchor.id), systemId: String(item.center.id), + radius: boundedRadius(starRadius, extent), + angle: Math.atan2(star.y - anchor.y, star.x - anchor.x), + }); + } + orbit.radius = boundedRadius(Number(orbit.radius) || starRadius, extent); + if (!Number.isFinite(orbit.angle)) { + orbit.angle = seededHash(opts.layoutSeed, 'kinematic-system:' + item.center.id) + / 0x100000000 * Math.PI * 2; + } + const omega = Math.min(angularFrequency(orbit.radius), + GALAXY_SYSTEM_ORBIT_SEED_SPEED_LIMIT * orbitalSpeed + / Math.max(1e-6, orbit.radius)); + orbit.angle += direction * omega * timestep; + const targetX = anchor.x + Math.cos(orbit.angle) * orbit.radius; + const targetY = anchor.y + Math.sin(orbit.angle) * orbit.radius; + const globalSpeed = omega * orbit.radius; + const globalVx = -Math.sin(orbit.angle) * globalSpeed * direction; + const globalVy = Math.cos(orbit.angle) * globalSpeed * direction; + moveNode(star, targetX, targetY, globalVx, globalVy); + const localMotion = advanceGalaxyKinematicLocalMembers(members, star, { + x: targetX, y: targetY, vx: globalVx, vy: globalVy, + }, opts); + satellites += localMotion.satellites; + const carrierContact = nodeRadius(anchor) + nodeRadius(star) + + GALAXY_BLACK_HOLE_EXCLUSION_PADDING; + const carrierOuter = galaxyEventHorizonOuterRadius( + nodeRadius(anchor), carrierContact, GALAXY_EVENT_HORIZON_INFLUENCE_SCALE); + const systemWarp = Math.max(0, Math.min(1, + (carrierOuter - orbit.radius) / Math.max(1e-9, carrierOuter - carrierContact))); + members.forEach(node => setGalaxySpacetimeWarp(node, galaxySmoothstep(systemWarp))); + systems++; + }); + /* Direct black-hole children are compact individual carriers. A community child keeps its + own planets in a local frame, while the child itself follows its admitted core lane; this + prevents a far authored coordinate from becoming the oversized kinematic radius. */ + const core = centers.get(coreKey); + if (core) galaxyBlackHoleCoreSystems(core.nodes, anchor).forEach(members => { + if (!members.length || members.some(node => String(node.id) === String(opts.fixedNodeId))) return; + const carrier = galaxySystemAnchor(members) || members[0]; + if (!carrier) return; + const extent = members.reduce((maximum, node) => Math.max(maximum, + Math.hypot(node.x - carrier.x, node.y - carrier.y) + nodeRadius(node)), 0); + let orbit = carrier.__galaxyKinematicCoreOrbit; + if (!orbit || orbit.anchorId !== String(anchor.id)) { + const seededRadius = Number(carrier.__galaxyCoreLaneRadius); + const carrierRadius = Number.isFinite(seededRadius) && seededRadius > 0 + ? seededRadius : Math.hypot(carrier.x - anchor.x, carrier.y - anchor.y); + orbit = setPhase(carrier, '__galaxyKinematicCoreOrbit', { + anchorId: String(anchor.id), radius: boundedRadius(carrierRadius, extent), + angle: Math.atan2(carrier.y - anchor.y, carrier.x - anchor.x), + }); + } + orbit.radius = boundedRadius(Number(orbit.radius) || + Math.hypot(carrier.x - anchor.x, carrier.y - anchor.y), extent); + const omega = Math.min(angularFrequency(orbit.radius), + GALAXY_SYSTEM_ORBIT_SEED_SPEED_LIMIT * orbitalSpeed + / Math.max(1e-6, orbit.radius)); + orbit.angle += direction * omega * timestep; + setPhase(carrier, '__galaxyCoreLaneRadius', orbit.radius); + setPhase(carrier, '__galaxyCoreLaneAngle', orbit.angle); + const speed = omega * orbit.radius; + const carrierX = anchor.x + Math.cos(orbit.angle) * orbit.radius; + const carrierY = anchor.y + Math.sin(orbit.angle) * orbit.radius; + const carrierVx = -Math.sin(orbit.angle) * speed * direction; + const carrierVy = Math.cos(orbit.angle) * speed * direction; + moveNode(carrier, carrierX, carrierY, carrierVx, carrierVy); + const localMotion = advanceGalaxyKinematicLocalMembers(members, carrier, { + x: carrierX, y: carrierY, vx: carrierVx, vy: carrierVy, + }, Object.assign({}, opts, { localOrbitCache: '__galaxyKinematicCoreLocalOrbit' })); + satellites += localMotion.satellites; + const satelliteContact = nodeRadius(anchor) + nodeRadius(carrier) + + GALAXY_BLACK_HOLE_EXCLUSION_PADDING; + const satelliteOuter = galaxyEventHorizonOuterRadius( + nodeRadius(anchor), satelliteContact, GALAXY_EVENT_HORIZON_INFLUENCE_SCALE); + const warp = galaxySmoothstep(Math.max(0, Math.min(1, + (satelliteOuter - orbit.radius) + / Math.max(1e-9, satelliteOuter - satelliteContact)))); + members.forEach(node => setGalaxySpacetimeWarp(node, warp)); + }); + const systemPacking = opts.includeSystemPacking === true + ? applyGalaxySystemPacking(bodies, Object.assign({}, opts, { + gap: opts.systemPackingGap, + strength: opts.systemPackingStrength, + maxCorrection: opts.systemPackingMaxCorrection, + fixedNodeId: opts.fixedNodeId, + updateKinematicPhase: true, + })) + : { systems: 0, overlaps: 0, adjustedSystems: 0, remainingOverlaps: 0, + infeasiblePairs: 0, gap: 0 }; + const blackHoleSpinAngle = advanceGalaxyBlackHoleSpin(nodes, opts); + return { bodies: bodies.length, systems, satellites, systemPacking, + blackHoleSpinAngle, ghostOrbit: integrateGalaxyGhostOrbits(nodes, opts) }; + } + + function recenterGalaxyOnAnchor(nodes) { + const anchor = galaxyGlobalAnchor(nodes); + if (!anchor) return null; + const shiftX = Number.isFinite(anchor.x) ? anchor.x : 0; + const shiftY = Number.isFinite(anchor.y) ? anchor.y : 0; + const shiftVx = Number.isFinite(anchor.vx) ? anchor.vx : 0; + const shiftVy = Number.isFinite(anchor.vy) ? anchor.vy : 0; + (nodes || []).forEach(node => { + if (Number.isFinite(node.x)) node.x -= shiftX; + if (Number.isFinite(node.y)) node.y -= shiftY; + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) - shiftVx; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) - shiftVy; + }); + anchor.x = 0; anchor.y = 0; anchor.vx = 0; anchor.vy = 0; + return anchor; + } + + function applyCommunityBridgeGravity(nodes, bridges, options) { + const opts = options || {}; + const centers = communityCenters(nodes); + const gravitationalConstant = GALAXY_BRIDGE_SCALE + * galaxyLocalGravityConstant(opts.gravity); + const softening = Math.max(0.1, Number(opts.softening) || 32); + const alphaValue = Number.isFinite(opts.alpha) ? Math.max(0, opts.alpha) : 1; + let applied = 0; + (bridges || []).forEach(bridge => { + if (!bridge || bridge.ghost) return; + const sourceId = idOf(bridge.source_community !== undefined + ? bridge.source_community : bridge.source); + const targetId = idOf(bridge.target_community !== undefined + ? bridge.target_community : bridge.target); + const source = centers.get(String(sourceId)), target = centers.get(String(targetId)); + if (!source || !target || source === target) return; + const physicsStrength = Math.max(0, Math.min(1, + Number.isFinite(Number(bridge.physics_strength)) + ? Number(bridge.physics_strength) : Number(bridge.strength) || 0)); + if (!physicsStrength) return; + const dx = target.x - source.x, dy = target.y - source.y; + const denominator = Math.pow(dx * dx + dy * dy + softening * softening, 1.5); + if (!Number.isFinite(denominator) || denominator <= 0) return; + const scale = gravitationalConstant * physicsStrength * alphaValue / denominator; + source.nodes.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + scale * target.mass * dx; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + scale * target.mass * dy; + }); + target.nodes.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) - scale * source.mass * dx; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) - scale * source.mass * dy; + }); + applied++; + }); + return { bridges: applied, communities: centers.size }; + } + function galaxySpringStrength(link, nodesById) { + if (!link || link.ghost || link.suggested || Number(link.physics_strength) === 0) return 0; + const source = typeof link.source === 'object' ? link.source : nodesById.get(linkEndpoint(link, 'source')); + const target = typeof link.target === 'object' ? link.target : nodesById.get(linkEndpoint(link, 'target')); + if (!source || !target || source.ghost || target.ghost + || communityKey(source) !== communityKey(target)) return 0; + return Math.max(0, Math.min(0.25, + Number.isFinite(Number(link.spring_strength)) ? Number(link.spring_strength) : 0.05)); + } + function galaxySpringDistance(link, orbitScale) { + const base = finitePositive(link && link.rest_length, 24, 240); + return base * Math.max(1 / 16, Math.min(25, Number(orbitScale) || 1)); + } + function galaxySafeSpringDistance(link, orbitScale, left, right, padding = 1.5) { + const radius = node => finitePositive(node && node.radius, + finitePositive(node && node.visual_radius, + radiusFromGravityMass(node && node.gravity_mass), 80), 160); + return Math.max(galaxySpringDistance(link, orbitScale), + radius(left) + radius(right) + Math.max(0, Number(padding) || 0)); + } + /* The scene contract marks every member of a server-authored solar system with the same + non-empty anchor id. Those links remain useful evidence to paint and traverse, but their + length is not a second orbital law: dominant-star gravity owns the shared system's phase + and radius. Compatibility callers without this explicit metadata retain relation physics. */ + function galaxySameExplicitOrbitalSystem(left, right) { + if (!left || !right || communityKey(left) !== communityKey(right)) return false; + const leftAnchor = left.system_anchor_id === undefined + || left.system_anchor_id === null ? '' : String(left.system_anchor_id).trim(); + const rightAnchor = right.system_anchor_id === undefined + || right.system_anchor_id === null ? '' : String(right.system_anchor_id).trim(); + return leftAnchor !== '' && leftAnchor === rightAnchor; + } + function applyGalaxyRelationSprings(nodes, links, options) { + const opts = options || {}; + const byId = new Map((nodes || []).map(node => [node.id, node])); + const systemAnchors = new Map(); + if (opts.skipSystemAnchorRelations === true) { + const groups = new Map(); + (nodes || []).forEach(node => { + const key = communityKey(node); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(node); + }); + groups.forEach((members, key) => systemAnchors.set(key, galaxySystemAnchor(members))); + } + const alphaValue = Number.isFinite(opts.alpha) ? Math.max(0, opts.alpha) : 1; + const orbitScale = Math.max(1 / 16, Math.min(25, Number(opts.orbitScale) || 1)); + const strengthMultiplier = Math.max(0, Math.min(4, + Number.isFinite(Number(opts.strengthMultiplier)) ? Number(opts.strengthMultiplier) : 1)); + const forceCap = Math.max(0, Number.isFinite(Number(opts.forceCap)) + ? Number(opts.forceCap) : 0.8); + const accelerationCap = Math.max(0, Number.isFinite(Number(opts.accelerationCap)) + ? Number(opts.accelerationCap) : Number.POSITIVE_INFINITY); + const initialVelocity = new Map((nodes || []).map(node => [node, { + vx: Number.isFinite(node.vx) ? node.vx : 0, + vy: Number.isFinite(node.vy) ? node.vy : 0, + }])); + let applied = 0, skippedOrbitalSystem = 0; + (links || []).forEach(link => { + const left = byId.get(linkEndpoint(link, 'source')); + const right = byId.get(linkEndpoint(link, 'target')); + const strength = galaxySpringStrength(link, byId) * strengthMultiplier; + if (!left || !right || left === right || strength <= 0) return; + if (opts.skipFixedNodeRelations === true + && (left.id === opts.fixedNodeId || right.id === opts.fixedNodeId)) return; + if (opts.skipOrbitalSystemRelations === true + && galaxySameExplicitOrbitalSystem(left, right)) { + skippedOrbitalSystem++; + return; + } + const systemAnchor = systemAnchors.get(communityKey(left)); + if (opts.skipSystemAnchorRelations === true + && communityKey(left) === communityKey(right) + && (left === systemAnchor || right === systemAnchor)) return; + const dx = right.x - left.x, dy = right.y - left.y; + const distance = Math.hypot(dx, dy); + if (!Number.isFinite(distance) || distance <= 1e-9) return; + let force = (distance - galaxySafeSpringDistance( + link, orbitScale, left, right, opts.padding + )) * strength * alphaValue; + if (forceCap > 0) force = Math.max(-forceCap, Math.min(forceCap, force)); + const fx = force * dx / distance, fy = force * dy / distance; + const leftMass = finitePositive(left.gravity_mass, 1, 1000); + const rightMass = finitePositive(right.gravity_mass, 1, 1000); + left.vx = (Number.isFinite(left.vx) ? left.vx : 0) + fx / leftMass; + left.vy = (Number.isFinite(left.vy) ? left.vy : 0) + fy / leftMass; + right.vx = (Number.isFinite(right.vx) ? right.vx : 0) - fx / rightMass; + right.vy = (Number.isFinite(right.vy) ? right.vy : 0) - fy / rightMass; + applied++; + }); + /* A hub can own many valid relations. Cap the aggregate relation acceleration with one + common scale rather than clipping nodes independently; this preserves the springs' + equal-and-opposite evidence-mass momentum while preventing a dense hub slingshot. */ + let maximumAcceleration = 0; + initialVelocity.forEach((before, node) => { + maximumAcceleration = Math.max(maximumAcceleration, + Math.hypot((Number(node.vx) || 0) - before.vx, (Number(node.vy) || 0) - before.vy)); + }); + const accelerationScale = accelerationCap > 0 && maximumAcceleration > accelerationCap + ? accelerationCap / maximumAcceleration : 1; + if (accelerationScale < 1) initialVelocity.forEach((before, node) => { + node.vx = before.vx + ((Number(node.vx) || 0) - before.vx) * accelerationScale; + node.vy = before.vy + ((Number(node.vy) || 0) - before.vy) * accelerationScale; + }); + return { + applied, + skippedOrbitalSystem, + maximumAcceleration, + accelerationCapped: accelerationScale < 1, + }; + } + + /* Spring acceleration alone became visually inert as the fixed timestep was repeatedly + reduced. This position-based companion resolves a bounded fraction of relation error per + wall-clock frame. It only acts inside a solar system; mass-weighted inverse corrections + preserve that system's centre of mass, while the black-hole boundary remains responsible + for system-scale motion. */ + function applyGalaxyRelationDistanceConstraints(nodes, links, options) { + const opts = options || {}; + const byId = new Map((nodes || []).map(node => [node.id, node])); + const systemAnchors = new Map(); + if (opts.skipSystemAnchorRelations === true) { + const groups = new Map(); + (nodes || []).forEach(node => { + const key = communityKey(node); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(node); + }); + groups.forEach((members, key) => systemAnchors.set(key, galaxySystemAnchor(members))); + } + const orbitScale = Math.max(1 / 16, Math.min(25, Number(opts.orbitScale) || 1)); + const strengthMultiplier = Math.max(0, Math.min(2, + Number.isFinite(Number(opts.strengthMultiplier)) ? Number(opts.strengthMultiplier) : 1)); + const responseMultiplier = Math.max(0, Math.min(2, + Number.isFinite(Number(opts.responseMultiplier)) ? Number(opts.responseMultiplier) : 1)); + const wallClockSeconds = Math.max(0, Number.isFinite(Number(opts.wallClockSeconds)) + ? Number(opts.wallClockSeconds) : GALAXY_FRAME_INTERVAL_MS / 1000); + const rate = Math.max(0, Number.isFinite(Number(opts.rate)) + ? Number(opts.rate) : GALAXY_RELATION_CONSTRAINT_RATE); + const maximumCorrection = Math.max(0, Number.isFinite(Number(opts.maxCorrection)) + ? Number(opts.maxCorrection) : GALAXY_RELATION_CONSTRAINT_MAX_CORRECTION); + const shifts = new Map((nodes || []).map(node => [node, { x: 0, y: 0 }])); + let applied = 0, skippedFixedEndpoint = 0, skippedSystemAnchor = 0; + let skippedOrbitalSystem = 0; + let maximumError = 0, requestedDistance = 0; + (links || []).forEach(link => { + const left = byId.get(linkEndpoint(link, 'source')); + const right = byId.get(linkEndpoint(link, 'target')); + if (!left || !right || left === right || left.ghost || right.ghost + || communityKey(left) !== communityKey(right)) return; + /* A pointer-owned node is an externally imposed moving source, not a spring endpoint. + Otherwise the fixed-endpoint correction assigns the entire (up to 4-unit) Link error + to its connected peer every physics slice, which turns a long pointer move into a + rapid positional slingshot. The bounded drag gravity below is the sole follower path + during a gesture; ordinary fixed-node callers retain the legacy constraint behavior. */ + if (opts.skipFixedNodeRelations === true + && (left.id === opts.fixedNodeId || right.id === opts.fixedNodeId)) { + skippedFixedEndpoint++; + return; + } + if (opts.skipOrbitalSystemRelations === true + && galaxySameExplicitOrbitalSystem(left, right)) { + skippedOrbitalSystem++; + return; + } + const systemAnchor = systemAnchors.get(communityKey(left)); + if (opts.skipSystemAnchorRelations === true + && (left === systemAnchor || right === systemAnchor)) { + /* The dominant star/planet radius belongs to the central potential, not Link PBD. + Re-projecting it to a slider target every tick erases the orbital phase. */ + skippedSystemAnchor++; + return; + } + const strength = galaxySpringStrength(link, byId) * strengthMultiplier; + if (!(strength > 0)) return; + const dx = right.x - left.x, dy = right.y - left.y; + const distance = Math.hypot(dx, dy); + if (!Number.isFinite(distance) || distance <= 1e-9) return; + const error = distance - galaxySafeSpringDistance( + link, orbitScale, left, right, opts.padding + ); + /* Response multipliers belong inside the exponential. Multiplying the completed + displacement can exceed one, cross the requested rest length and reverse on the next + frame. Scaling the exponent changes the continuous convergence rate while preserving + the solver's invariant 0 <= response < 1 for every Link setting and frame duration. */ + const response = 1 - Math.exp( + -rate * strength * wallClockSeconds * responseMultiplier + ); + let correction = error * response; + if (maximumCorrection > 0) correction = Math.max( + -maximumCorrection, Math.min(maximumCorrection, correction)); + if (!Number.isFinite(correction) || Math.abs(correction) <= 1e-12) return; + const leftMass = finitePositive(left.gravity_mass, 1, 1000); + const rightMass = finitePositive(right.gravity_mass, 1, 1000); + const leftInverseMass = left.anchor_role === 'global' || left.id === opts.fixedNodeId + ? 0 : 1 / leftMass; + const rightInverseMass = right.anchor_role === 'global' || right.id === opts.fixedNodeId + ? 0 : 1 / rightMass; + const inverseMass = leftInverseMass + rightInverseMass; + if (!(inverseMass > 0)) return; + const unitX = dx / distance, unitY = dy / distance; + const leftShift = shifts.get(left), rightShift = shifts.get(right); + leftShift.x += unitX * correction * leftInverseMass / inverseMass; + leftShift.y += unitY * correction * leftInverseMass / inverseMass; + rightShift.x -= unitX * correction * rightInverseMass / inverseMass; + rightShift.y -= unitY * correction * rightInverseMass / inverseMass; + applied++; + maximumError = Math.max(maximumError, Math.abs(error)); + requestedDistance += Math.abs(correction); + }); + /* Apply one Jacobi-style update from the unchanged phase snapshot. Sequential mutation + made high-degree hubs order-dependent: their last edge undid their first edge and the + cycle restarted next frame. One common aggregate cap preserves every pair's mass-weighted + balance while preventing a hub with many links from moving N times farther than a leaf. */ + let maximumNodeShift = 0; + shifts.forEach(shift => { + maximumNodeShift = Math.max(maximumNodeShift, Math.hypot(shift.x, shift.y)); + }); + const aggregateScale = maximumCorrection > 0 && maximumNodeShift > maximumCorrection + ? maximumCorrection / maximumNodeShift : 1; + shifts.forEach((shift, node) => { + node.x += shift.x * aggregateScale; + node.y += shift.y * aggregateScale; + }); + return { + applied, + skippedFixedEndpoint, + skippedSystemAnchor, + skippedOrbitalSystem, + maximumError, + correctedDistance: requestedDistance * aggregateScale, + maximumNodeShift: maximumNodeShift * aggregateScale, + aggregateLimited: aggregateScale < 1, + strengthMultiplier, + responseMultiplier, + }; + } + + /* A pointer temporarily makes the dragged body an externally positioned gravitational + source. Every live body responds to the same evidence mass and softened inverse-square law + as the persistent Galaxy solver; topology can strengthen a relation but never decides + whether gravity exists. The relation's safe orbital distance is a periapsis boundary, not + a copied offset: nearby unlinked stars follow because the moved mass attracts them, while + distant systems receive only the naturally weaker tail. */ + function applyDraggedNodeGravity(source, followers, options) { + const opts = options || {}; + if (!source || !Number.isFinite(source.x) || !Number.isFinite(source.y)) { + return { applied: 0, maximumAcceleration: 0, maximumPull: 0 }; + } + const sourceMass = finitePositive(source.gravity_mass, 1, 1000); + const gravityMultiplier = Math.max(0, Number.isFinite(Number(opts.gravityMultiplier)) + ? Number(opts.gravityMultiplier) : 1); + const gravity = galaxyLocalGravityConstant(opts.gravity) * gravityMultiplier; + const softening = finitePositive(opts.softening, + GALAXY_DRAG_GRAVITY_SOFTENING, 240); + const duration = finitePositive(opts.duration, GALAXY_DRAG_GRAVITY_TIME, 60); + const maximumPull = finitePositive(opts.maximumPull, + GALAXY_DRAG_GRAVITY_MAX_PULL, 240); + const explicitMaximumImpulse = Number(opts.maximumImpulse); + const maximumImpulse = Number.isFinite(explicitMaximumImpulse) && explicitMaximumImpulse >= 0 + ? Math.min(MAX_NODE_SPEED, explicitMaximumImpulse) + : GALAXY_DRAG_GRAVITY_MAX_IMPULSE; + const orbitScale = galaxyRelationOrbitScale(opts.linkSetting); + let applied = 0, maximumAcceleration = 0, largestPull = 0; + (followers || []).forEach(entry => { + const node = entry && entry.node ? entry.node : entry; + const link = entry && entry.link ? entry.link : null; + if (!node || node === source || node.ghost || node.anchor_role === 'global' + || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + const dx = source.x - node.x, dy = source.y - node.y; + const distance = Math.hypot(dx, dy); + if (!Number.isFinite(distance) || distance <= 1e-9) return; + const byId = new Map([[source.id, source], [node.id, node]]); + /* Evidence-backed relations strengthen capture, but even compatibility links without + spring metadata retain half coupling so old payloads still behave physically. */ + const relationStrength = link ? galaxySpringStrength(link, byId) : 0.125; + /* Nearby and same-system bodies follow ordinary unit gravity. An explicit evidence edge + can strengthen capture up to 1.5x, but never turns topology into a teleport spring. */ + const coupling = Math.max(0.5, Math.min(1.5, 0.5 + relationStrength * 4)); + const softened = distance * distance + softening * softening; + const acceleration = gravity * sourceMass * coupling * distance + / Math.pow(softened, 1.5); + if (!Number.isFinite(acceleration) || acceleration <= 0) return; + const unitX = dx / distance, unitY = dy / distance; + const safeDistance = link + ? galaxySafeSpringDistance(link, orbitScale, source, node, opts.padding) + : finitePositive(source.radius, 2, 160) + finitePositive(node.radius, 2, 160) + + Math.max(0, Number(opts.padding) || 0); + const radialError = Math.max(0, distance - safeDistance); + const response = 1 - Math.exp(-acceleration * duration); + const pull = Math.min(maximumPull, radialError * response); + if (pull > 0) { + node.x += unitX * pull; + node.y += unitY * pull; + } + /* Preserve the existing tangential orbit and add only the gravitational impulse. The + impulse has its own local bound; the ordinary Galaxy emergency ceiling is applied only + if repeated pointer events would otherwise accumulate an unsafe release velocity. */ + if (opts.applyImpulse !== false && maximumImpulse > 0) { + const impulse = Math.min(maximumImpulse, acceleration * duration); + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + unitX * impulse; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + unitY * impulse; + const speed = Math.hypot(node.vx, node.vy); + if (speed > MAX_NODE_SPEED) { + const scale = MAX_NODE_SPEED / speed; + node.vx *= scale; + node.vy *= scale; + } + } + applied++; + maximumAcceleration = Math.max(maximumAcceleration, acceleration); + largestPull = Math.max(largestPull, pull); + if (entry && entry.node) { + entry.lastAcceleration = acceleration; + entry.lastPull = pull; + } + }); + return { applied, maximumAcceleration, maximumPull: largestPull }; + } + + /* Live dragging samples a force, never a pointer-event displacement. Pointermove frequency + varies wildly by browser and input device; applying the positional helper above on every + event compounded eight small events into a violent 180-unit jump. This acceleration-only + field is sampled by the same fixed-step leapfrog clock as the rest of the Galaxy. Direct + evidence relations may strengthen capture, while every unlinked body still receives the + requested doubled local gravity without copying the pointer offset. */ + function applyDraggedNodeAcceleration(source, followers, options) { + const opts = options || {}; + if (!source || !Number.isFinite(source.x) || !Number.isFinite(source.y)) { + return { applied: 0, maximumAcceleration: 0, maximumPull: 0 }; + } + const sourceMass = finitePositive(source.gravity_mass, 1, 1000); + const gravity = galaxyLocalGravityConstant(opts.gravity) + * GALAXY_DRAG_GRAVITY_MULTIPLIER; + const softening = finitePositive(opts.softening, + GALAXY_DRAG_GRAVITY_SOFTENING, 240); + let applied = 0, maximumAcceleration = 0; + (followers || []).forEach(entry => { + const node = entry && entry.node ? entry.node : entry; + const link = entry && entry.link ? entry.link : null; + if (!node || node === source || node.ghost || node.anchor_role === 'global' + || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + const dx = source.x - node.x, dy = source.y - node.y; + const distance = Math.hypot(dx, dy); + if (!Number.isFinite(distance) || distance <= 1e-9) return; + const byId = new Map([[source.id, source], [node.id, node]]); + const relationStrength = link ? galaxySpringStrength(link, byId) : 0.125; + const coupling = Math.max(0.5, Math.min(1.5, 0.5 + relationStrength * 4)); + const softened = distance * distance + softening * softening; + const acceleration = gravity * sourceMass * coupling * distance + / Math.pow(softened, 1.5); + if (!Number.isFinite(acceleration) || acceleration <= 0) return; + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + dx / distance * acceleration; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + dy / distance * acceleration; + applied++; + maximumAcceleration = Math.max(maximumAcceleration, acceleration); + }); + return { applied, maximumAcceleration, maximumPull: 0 }; + } + + /* D3's stock collision force divides the correction by painted radius squared. Evidence + radius is not inertial mass, so a large star touching a small planet can inject momentum + and eject their whole solar system. This deterministic spatial-grid pass uses evidence + mass for the impulse split: m1*dv1 + m2*dv2 is exactly zero for every contact. The grid + keeps ordinary traversal near O(n); only genuinely crowded cells pay pairwise cost. */ + function applyGalaxyCollisions(nodes, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const padding = Math.max(0, Number.isFinite(Number(opts.padding)) + ? Number(opts.padding) : 1.5); + const strength = Math.max(0, Math.min(1, Number.isFinite(Number(opts.strength)) + ? Number(opts.strength) : 0.7)); + const settleNormal = opts.settleNormal === true; + const iterations = Math.max(1, Math.min(4, Math.floor(Number(opts.iterations) || 1))); + const stats = { + bodies: bodies.length, pairs: 0, overlaps: 0, cells: 0, correctionDistance: 0, + }; + if (bodies.length < 2 || strength <= 0) return stats; + const bodyRadius = node => finitePositive( + node.radius, finitePositive(node.visual_radius, radiusFromGravityMass(node.gravity_mass), 80), 160 + ); + const maximumRadius = bodies.reduce( + (maximum, node) => Math.max(maximum, bodyRadius(node)), 0 + ); + const cellSize = Math.max(1, maximumRadius * 2 + padding); + for (let iteration = 0; iteration < iterations; iteration++) { + const grid = new Map(); + bodies.forEach((node, index) => { + const x = node.x, y = node.y; + const cellX = Math.floor(x / cellSize), cellY = Math.floor(y / cellSize); + const key = cellX + ',' + cellY; + if (!grid.has(key)) grid.set(key, []); + grid.get(key).push({ node, index, x, y, radius: bodyRadius(node), cellX, cellY }); + }); + stats.cells = Math.max(stats.cells, grid.size); + grid.forEach(bucket => bucket.forEach(left => { + for (let offsetX = -1; offsetX <= 1; offsetX++) { + for (let offsetY = -1; offsetY <= 1; offsetY++) { + const candidates = grid.get( + (left.cellX + offsetX) + ',' + (left.cellY + offsetY) + ) || []; + candidates.forEach(right => { + if (right.index <= left.index) return; + if (opts.sameCommunityOnly === true + && communityKey(left.node) !== communityKey(right.node)) return; + stats.pairs++; + const minimumDistance = left.radius + right.radius + padding; + if (Math.hypot(right.x - left.x, right.y - left.y) >= minimumDistance) return; + let normalX = right.node.x - left.node.x; + let normalY = right.node.y - left.node.y; + let normalDistance = Math.hypot(normalX, normalY); + const separationDistance = normalDistance; + if (normalDistance <= 1e-9) { + const angle = seededHash(0, String(left.node.id) + '|' + String(right.node.id)) + / 0x100000000 * Math.PI * 2; + normalX = Math.cos(angle); + normalY = Math.sin(angle); + normalDistance = 1; + } + const relativeCorrection = (minimumDistance - separationDistance) * strength; + if (!(relativeCorrection > 0) || !Number.isFinite(relativeCorrection)) return; + stats.correctionDistance += relativeCorrection; + const leftMass = finitePositive(left.node.gravity_mass, 1, 1000); + const rightMass = finitePositive(right.node.gravity_mass, 1, 1000); + const leftInverseMass = left.node.anchor_role === 'global' ? 0 : 1 / leftMass; + const rightInverseMass = right.node.anchor_role === 'global' ? 0 : 1 / rightMass; + if (leftInverseMass + rightInverseMass <= 0) return; + const inverseMass = leftInverseMass + rightInverseMass; + const projection = relativeCorrection / inverseMass; + const unitX = normalX / normalDistance, unitY = normalY / normalDistance; + /* Resolve penetration geometrically. Turning overlap depth into velocity adds + kinetic energy every fixed step and eventually slingshots a member out of a + crowded system. The mass-weighted projection preserves the pair COM. */ + left.node.x -= unitX * projection * leftInverseMass; + left.node.y -= unitY * projection * leftInverseMass; + right.node.x += unitX * projection * rightInverseMass; + right.node.y += unitY * projection * rightInverseMass; + + /* Cancel only closing normal motion (zero restitution). Enlarging the lever arm + during projection would otherwise manufacture angular momentum even with no + impulse, so scale the pair's tangential relative speed by old/new separation. + This is the unique momentum-preserving remap of the projected phase point; its + factor is <= 1, hence it can only remove energy. */ + const leftVx = Number.isFinite(left.node.vx) ? left.node.vx : 0; + const leftVy = Number.isFinite(left.node.vy) ? left.node.vy : 0; + const rightVx = Number.isFinite(right.node.vx) ? right.node.vx : 0; + const rightVy = Number.isFinite(right.node.vy) ? right.node.vy : 0; + const tangentX = -unitY, tangentY = unitX; + const relativeVx = rightVx - leftVx, relativeVy = rightVy - leftVy; + const normalSpeed = relativeVx * unitX + relativeVy * unitY; + const tangentSpeed = relativeVx * tangentX + relativeVy * tangentY; + const projectedDistance = separationDistance + relativeCorrection; + const tangentScale = projectedDistance > 1e-9 + ? Math.min(1, separationDistance / projectedDistance) : 0; + const targetNormalSpeed = settleNormal ? 0 : Math.max(0, normalSpeed); + const deltaVx = (targetNormalSpeed - normalSpeed) * unitX + + (tangentSpeed * tangentScale - tangentSpeed) * tangentX; + const deltaVy = (targetNormalSpeed - normalSpeed) * unitY + + (tangentSpeed * tangentScale - tangentSpeed) * tangentY; + left.node.vx = leftVx - deltaVx * leftInverseMass / inverseMass; + left.node.vy = leftVy - deltaVy * leftInverseMass / inverseMass; + right.node.vx = rightVx + deltaVx * rightInverseMass / inverseMass; + right.node.vy = rightVy + deltaVy * rightInverseMass / inverseMass; + stats.overlaps++; + }); + } + } + })); + } + return stats; + } + + /* Stable Jacobi projection for the persistent Orbital-separation layer. The generic + collision helper above intentionally retains its pair-at-a-time contract for legacy + callers; the live Galaxy cannot use that ordering because a dense hub would be shifted + repeatedly within one frame. Every pair here samples one immutable phase, accumulates a + mass-balanced correction, and applies one globally bounded update. Local contacts use the + full adjustable pressure; an opt-in weaker cross-community pressure prevents painted nodes + from different systems bunching without turning the galaxy into hard billiards. A cross- + community contact translates each whole system, preserving its internal orbit geometry. */ + function applyGalaxyOrbitalSeparation(nodes, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const padding = Math.max(0, Number.isFinite(Number(opts.padding)) + ? Number(opts.padding) : 1.5); + const strength = Math.max(0, Math.min(1, Number.isFinite(Number(opts.strength)) + ? Number(opts.strength) : 0.7)); + const crossCommunityPadding = Math.max(0, + Number.isFinite(Number(opts.crossCommunityPadding)) + ? Number(opts.crossCommunityPadding) : 1.5); + const crossCommunityStrength = Math.max(0, Math.min(1, + Number.isFinite(Number(opts.crossCommunityStrength)) + ? Number(opts.crossCommunityStrength) : 0)); + const maximumCorrection = Math.max(0, Number.isFinite(Number(opts.maxCorrection)) + ? Number(opts.maxCorrection) : 4); + const maximumVelocityCorrection = Math.max(0, + Number.isFinite(Number(opts.maxVelocityCorrection)) + ? Number(opts.maxVelocityCorrection) : 8); + const stats = { + bodies: bodies.length, pairs: 0, overlaps: 0, cells: 0, + crossCommunityPairs: 0, crossCommunityOverlaps: 0, + correctionDistance: 0, crossCommunityCorrectionDistance: 0, + maximumNodeShift: 0, aggregateLimited: false, + radialPreservedContacts: 0, radiusPreservedNodes: 0, + }; + if (bodies.length < 2 || Math.max(strength, crossCommunityStrength) <= 0) return stats; + const bodyRadius = node => finitePositive( + node.radius, finitePositive(node.visual_radius, + radiusFromGravityMass(node.gravity_mass), 80), 160 + ); + const maximumRadius = bodies.reduce( + (maximum, node) => Math.max(maximum, bodyRadius(node)), 0 + ); + const cellSize = Math.max( + 1, maximumRadius * 2 + Math.max(padding, crossCommunityPadding) + ); + const grid = new Map(); + const shifts = new Map(bodies.map(node => [node, { x: 0, y: 0 }])); + const velocityShifts = new Map(bodies.map(node => [node, { x: 0, y: 0 }])); + const groups = new Map(); + const groupForNode = new Map(); + const contacts = []; + const phaseAdvances = new Map(); + const phaseAdvanceLimits = new Map(); + bodies.forEach((node, index) => { + const groupKey = communityKey(node); + if (!groups.has(groupKey)) { + groups.set(groupKey, { + nodes: [], mass: 0, fixed: false, shift: { x: 0, y: 0 }, + }); + } + const group = groups.get(groupKey); + const mass = finitePositive(node.gravity_mass, 1, 1000); + group.nodes.push(node); + group.mass += mass; + group.fixed = group.fixed || node.anchor_role === 'global' || node.id === opts.fixedNodeId; + groupForNode.set(node, group); + const cellX = Math.floor(node.x / cellSize), cellY = Math.floor(node.y / cellSize); + const key = cellX + ',' + cellY; + if (!grid.has(key)) grid.set(key, []); + grid.get(key).push({ + node, index, x: node.x, y: node.y, radius: bodyRadius(node), cellX, cellY, + }); + }); + groups.forEach(group => { group.anchor = galaxySystemAnchor(group.nodes); }); + stats.cells = grid.size; + grid.forEach(bucket => bucket.forEach(left => { + for (let offsetX = -1; offsetX <= 1; offsetX++) { + for (let offsetY = -1; offsetY <= 1; offsetY++) { + const candidates = grid.get( + (left.cellX + offsetX) + ',' + (left.cellY + offsetY) + ) || []; + candidates.forEach(right => { + if (right.index <= left.index) return; + const crossCommunity = communityKey(left.node) !== communityKey(right.node); + const leftGroup = groupForNode.get(left.node); + const rightGroup = groupForNode.get(right.node); + if (!crossCommunity && opts.skipSystemAnchorPairs === true + && (left.node === leftGroup.anchor || right.node === leftGroup.anchor)) return; + const pairStrength = crossCommunity ? crossCommunityStrength : strength; + if (!(pairStrength > 0)) return; + const pairPadding = crossCommunity ? crossCommunityPadding : padding; + stats.pairs++; + if (crossCommunity) stats.crossCommunityPairs++; + let minimumDistance = left.radius + right.radius + pairPadding; + let preservedOrbitPair = null; + /* Same-star planets are constrained to circular manifolds. A large Repel padding can + demand a centre distance greater than those two circles can ever supply (the + release moon fixture requested 46 on two 19.2-radius orbits whose absolute maximum + chord is 38.4). Do not run a permanent correction against impossible geometry. + Clamp the target to the maximum feasible chord, then solve the remaining chord + deficit as a bounded forward angular advance below. */ + if (!crossCommunity && opts.preserveSystemRadii === true && leftGroup.anchor) { + const anchor = leftGroup.anchor; + const explicitAnchorId = anchor.id === undefined || anchor.id === null + ? '' : String(anchor.id); + const explicitlyAnchored = explicitAnchorId + && [left.node, right.node].every(node => node.system_anchor_id !== undefined + && node.system_anchor_id !== null + && String(node.system_anchor_id) === explicitAnchorId); + if (explicitlyAnchored && left.node !== anchor && right.node !== anchor) { + const leftOrbit = Math.hypot(left.node.x - anchor.x, left.node.y - anchor.y); + const rightOrbit = Math.hypot(right.node.x - anchor.x, right.node.y - anchor.y); + if (leftOrbit > 1e-9 && rightOrbit > 1e-9) { + const maximumChord = (leftOrbit + rightOrbit) * (1 - 1e-6); + minimumDistance = Math.min(minimumDistance, maximumChord); + preservedOrbitPair = { anchor, leftOrbit, rightOrbit }; + } + } + } + let normalX = right.x - left.x, normalY = right.y - left.y; + let distance = Math.hypot(normalX, normalY); + if (distance >= minimumDistance) return; + if (distance <= 1e-9) { + const angle = seededHash(0, String(left.node.id) + '|' + String(right.node.id)) + / 0x100000000 * Math.PI * 2; + normalX = Math.cos(angle); + normalY = Math.sin(angle); + distance = 0; + } + const unitDistance = Math.max(1, Math.hypot(normalX, normalY)); + const unitX = normalX / unitDistance, unitY = normalY / unitDistance; + const correction = (minimumDistance - distance) * pairStrength; + if (!(correction > 0) || !Number.isFinite(correction)) return; + const leftMass = crossCommunity + ? leftGroup.mass : finitePositive(left.node.gravity_mass, 1, 1000); + const rightMass = crossCommunity + ? rightGroup.mass : finitePositive(right.node.gravity_mass, 1, 1000); + const leftFixed = crossCommunity ? leftGroup.fixed + : left.node.anchor_role === 'global' || left.node.id === opts.fixedNodeId; + const rightFixed = crossCommunity ? rightGroup.fixed + : right.node.anchor_role === 'global' || right.node.id === opts.fixedNodeId; + const leftInverseMass = leftFixed ? 0 : 1 / leftMass; + const rightInverseMass = rightFixed ? 0 : 1 / rightMass; + const inverseMass = leftInverseMass + rightInverseMass; + if (!(inverseMass > 0)) return; + if (preservedOrbitPair && !leftFixed && !rightFixed) { + const anchor = preservedOrbitPair.anchor; + const leftDx = left.node.x - anchor.x, leftDy = left.node.y - anchor.y; + const rightDx = right.node.x - anchor.x, rightDy = right.node.y - anchor.y; + const leftAngle = Math.atan2(leftDy, leftDx); + const rightAngle = Math.atan2(rightDy, rightDx); + const tangentDirection = (node, dx, dy, radius) => { + const relativeVx = (Number.isFinite(node.vx) ? node.vx : 0) + - (Number.isFinite(anchor.vx) ? anchor.vx : 0); + const relativeVy = (Number.isFinite(node.vy) ? node.vy : 0) + - (Number.isFinite(anchor.vy) ? anchor.vy : 0); + return Math.sign((-dy * relativeVx + dx * relativeVy) / radius); + }; + const leftDirection = tangentDirection( + left.node, leftDx, leftDy, preservedOrbitPair.leftOrbit); + const rightDirection = tangentDirection( + right.node, rightDx, rightDy, preservedOrbitPair.rightOrbit); + const direction = leftDirection && leftDirection === rightDirection + ? leftDirection : (leftDirection || rightDirection || 1); + const cosine = Math.max(-1, Math.min(1, + (preservedOrbitPair.leftOrbit * preservedOrbitPair.leftOrbit + + preservedOrbitPair.rightOrbit * preservedOrbitPair.rightOrbit + - minimumDistance * minimumDistance) + / (2 * preservedOrbitPair.leftOrbit * preservedOrbitPair.rightOrbit))); + const requiredAngle = Math.acos(cosine); + const fullTurn = Math.PI * 2; + const directedGap = ((direction * (rightAngle - leftAngle)) % fullTurn + + fullTurn) % fullTurn; + const currentAngle = Math.min(directedGap, fullTurn - directedGap); + const deficit = Math.max(0, requiredAngle - currentAngle); + if (deficit > 1e-12) { + /* Advance whichever body already leads in the common orbital direction. Moving + the trailer backward would satisfy the contact but visibly reverse a planet. */ + const leading = directedGap <= Math.PI ? right.node : left.node; + const previous = Number(phaseAdvances.get(leading)) || 0; + /* An isolated star/planet/moon contact can spend the larger phase budget without + interacting with another planet. Dense systems share the conservative release + budget so simultaneous contacts cannot aggregate into a visible jump. */ + const maximumDirectPhase = leftGroup.nodes.length <= 3 ? 0.158 : 0.072; + const advance = Math.min(deficit * pairStrength, maximumDirectPhase); + phaseAdvances.set(leading, direction * Math.min( + maximumDirectPhase, Math.abs(previous) + advance)); + phaseAdvanceLimits.set(leading, maximumDirectPhase); + } + contacts.push({ + left: left.node, right: right.node, oldDistance: distance, + leftInverseMass, rightInverseMass, inverseMass, + }); + stats.correctionDistance += correction; + stats.overlaps++; + return; + } + const projection = correction / inverseMass; + const leftShift = crossCommunity ? leftGroup.shift : shifts.get(left.node); + const rightShift = crossCommunity ? rightGroup.shift : shifts.get(right.node); + leftShift.x -= unitX * projection * leftInverseMass; + leftShift.y -= unitY * projection * leftInverseMass; + rightShift.x += unitX * projection * rightInverseMass; + rightShift.y += unitY * projection * rightInverseMass; + /* Rigid cross-system position projection is complete here. Do not enqueue those + dense contacts for the member-level velocity pass below: it is intentionally + reserved for dissipating local overlaps inside one solar system. */ + if (!crossCommunity) contacts.push({ + left: left.node, right: right.node, oldDistance: distance, + leftInverseMass, rightInverseMass, inverseMass, + }); + stats.correctionDistance += correction; + stats.overlaps++; + if (crossCommunity) { + stats.crossCommunityCorrectionDistance += correction; + stats.crossCommunityOverlaps++; + } + }); + } + } + })); + /* Generic planet/planet pressure should change orbital phase, not silently inflate the + orbit. For a free server-authored system, map each accumulated local correction onto the + circular manifold about its declared dominant star. Expressing the tangent displacement + as an arc (rather than adding the tangent vector as a chord) preserves radius exactly. + The dominant star is the system's external local frame and stays exact while its planets + move along their circles. A pointer-owned satellite and compatibility systems keep the + legacy Cartesian projection. Cross-system pressure remains a rigid group translation. */ + const preservedGroups = []; + if (opts.preserveSystemRadii === true) groups.forEach(group => { + const anchor = group.anchor; + const anchorId = anchor && anchor.id !== undefined && anchor.id !== null + ? String(anchor.id) : ''; + const explicitlyAnchored = anchorId && group.nodes.some(node => + node.system_anchor_id !== undefined && node.system_anchor_id !== null + && String(node.system_anchor_id) === anchorId); + const fixedMember = opts.fixedNodeId === undefined || opts.fixedNodeId === null + ? null : group.nodes.find(node => node.id === opts.fixedNodeId) || null; + const externallyFixedAnchor = !fixedMember || fixedMember === anchor; + if (!anchor || anchor.anchor_role === 'global' + || (group.fixed && !externallyFixedAnchor) || !explicitlyAnchored) return; + const entries = group.nodes.map(node => { + const mass = finitePositive(node.gravity_mass, 1, 1000); + if (node === anchor) return { node, mass, radius: 0, angle: 0, arc: 0 }; + const dx = node.x - anchor.x, dy = node.y - anchor.y; + const radius = Math.hypot(dx, dy); + if (!(radius > 1e-9)) return { node, mass, radius: 0, angle: 0, arc: 0 }; + const shift = shifts.get(node); + const tangentX = -dy / radius, tangentY = dx / radius; + const directPhase = Number(phaseAdvances.get(node)) || 0; + let arc = shift.x * tangentX + shift.y * tangentY + directPhase * radius; + const relativeVx = (Number.isFinite(node.vx) ? node.vx : 0) + - (Number.isFinite(anchor.vx) ? anchor.vx : 0); + const relativeVy = (Number.isFinite(node.vy) ? node.vy : 0) + - (Number.isFinite(anchor.vy) ? anchor.vy : 0); + const orbitalDirection = Math.sign(relativeVx * tangentX + relativeVy * tangentY); + /* Contact pressure may advance a planet along its established orbit, but it must never + step backward through the stationary-star frame. Blocking only the opposing arc keeps + dense separation dissipative without altering radius or manufacturing phase reversal. */ + if (orbitalDirection && arc * orbitalDirection < 0) { + arc = 0; + } + /* A contact correction is not an orbital clock. Ordinary projected pressure stays below + the 0.085-rad release gate; the explicit chord-deficit solve may use the larger bounded + advance needed to clear a deeply overlapping moon within 16 fixed slices. */ + const maximumPhase = directPhase + ? (phaseAdvanceLimits.get(node) || 0.072) : 0.072; + arc = Math.sign(arc) * Math.min(Math.abs(arc), radius * maximumPhase); + return { + node, mass, radius, angle: Math.atan2(dy, dx), + arc, + }; + }); + const totalMass = entries.reduce((sum, entry) => sum + entry.mass, 0); + const contactCount = contacts.reduce((count, contact) => + count + (groupForNode.get(contact.left) === group ? 1 : 0), 0); + if (!(totalMass > 0) || !contactCount) return; + stats.radialPreservedContacts += contactCount; + stats.radiusPreservedNodes += entries.filter(entry => + entry.radius > 0 && Math.abs(entry.arc) > 1e-12).length; + preservedGroups.push({ group, anchor, entries, totalMass, externallyFixedAnchor }); + const rotations = entries.map(entry => { + if (!(entry.radius > 0)) return { entry, x: 0, y: 0 }; + entry.appliedAngle = entry.arc / entry.radius; + const angle = entry.angle + entry.appliedAngle; + return { entry, + x: Math.cos(angle) * entry.radius - (entry.node.x - anchor.x), + y: Math.sin(angle) * entry.radius - (entry.node.y - anchor.y), + }; + }); + const driftX = externallyFixedAnchor ? 0 : rotations.reduce( + (sum, item) => sum + item.entry.mass * item.x, 0) / totalMass; + const driftY = externallyFixedAnchor ? 0 : rotations.reduce( + (sum, item) => sum + item.entry.mass * item.y, 0) / totalMass; + rotations.forEach(item => { + const shift = shifts.get(item.entry.node); + shift.x = item.x - driftX; + shift.y = item.y - driftY; + }); + }); + groups.forEach(group => group.nodes.forEach(node => { + const shift = shifts.get(node); + shift.x += group.shift.x; + shift.y += group.shift.y; + })); + let maximumNodeShift = 0; + shifts.forEach(shift => { + maximumNodeShift = Math.max(maximumNodeShift, Math.hypot(shift.x, shift.y)); + }); + const positionScale = maximumCorrection > 0 && maximumNodeShift > maximumCorrection + ? maximumCorrection / maximumNodeShift : 1; + const preservedNodes = new Set(); + if (positionScale < 1) preservedGroups.forEach(info => { + const rotations = info.entries.map(entry => { + preservedNodes.add(entry.node); + if (!(entry.radius > 0)) return { entry, x: 0, y: 0 }; + entry.appliedAngle = entry.arc * positionScale / entry.radius; + const angle = entry.angle + entry.appliedAngle; + return { entry, + x: Math.cos(angle) * entry.radius - (entry.node.x - info.anchor.x), + y: Math.sin(angle) * entry.radius - (entry.node.y - info.anchor.y), + }; + }); + const driftX = info.externallyFixedAnchor ? 0 : rotations.reduce( + (sum, item) => sum + item.entry.mass * item.x, 0) / info.totalMass; + const driftY = info.externallyFixedAnchor ? 0 : rotations.reduce( + (sum, item) => sum + item.entry.mass * item.y, 0) / info.totalMass; + rotations.forEach(item => { + const shift = shifts.get(item.entry.node); + shift.x = item.x - driftX + info.group.shift.x * positionScale; + shift.y = item.y - driftY + info.group.shift.y * positionScale; + }); + }); + shifts.forEach((shift, node) => { + const scale = preservedNodes.has(node) ? 1 : positionScale; + node.x += shift.x * scale; + node.y += shift.y * scale; + }); + stats.correctionDistance *= positionScale; + stats.crossCommunityCorrectionDistance *= positionScale; + stats.maximumNodeShift = maximumNodeShift * positionScale; + stats.aggregateLimited = positionScale < 1; + + /* The radius vector and its star-relative velocity are one phase-space state. Rotating only + the position turns a circular tangent partly radial and manufactures eccentricity on the + next kick. Apply the identical signed angle to each planet's velocity in the same + stationary star frame. The dominant star absorbs no local position or velocity correction; + black-hole-frame translation remains independent. */ + preservedGroups.forEach(info => { + const anchorVx = Number.isFinite(info.anchor.vx) ? info.anchor.vx : 0; + const anchorVy = Number.isFinite(info.anchor.vy) ? info.anchor.vy : 0; + const rotations = info.entries.map(entry => { + if (!(entry.radius > 0) || !Number.isFinite(entry.appliedAngle)) { + return { entry, x: 0, y: 0 }; + } + const nodeVx = Number.isFinite(entry.node.vx) ? entry.node.vx : 0; + const nodeVy = Number.isFinite(entry.node.vy) ? entry.node.vy : 0; + const relativeVx = nodeVx - anchorVx, relativeVy = nodeVy - anchorVy; + const cosine = Math.cos(entry.appliedAngle), sine = Math.sin(entry.appliedAngle); + return { entry, + x: relativeVx * cosine - relativeVy * sine - relativeVx, + y: relativeVx * sine + relativeVy * cosine - relativeVy, + }; + }); + const driftX = info.externallyFixedAnchor ? 0 : rotations.reduce( + (sum, item) => sum + item.entry.mass * item.x, 0) / info.totalMass; + const driftY = info.externallyFixedAnchor ? 0 : rotations.reduce( + (sum, item) => sum + item.entry.mass * item.y, 0) / info.totalMass; + rotations.forEach(item => { + const shift = velocityShifts.get(item.entry.node); + shift.x += item.x - driftX; + shift.y += item.y - driftY; + }); + }); + + /* Recompute same-system normals after the simultaneous projection, then remove only the + local contact's relative radial motion and the angular momentum manufactured by its + enlarged lever arm. Cross-system geometry never reaches this velocity pass, so dense + contacts cannot drain the solar-system COM orbits around the black hole. Velocity + deltas are accumulated from the unchanged phase and share one cap. */ + const preservedGroupSet = new Set(preservedGroups.map(info => info.group)); + contacts.forEach(contact => { + /* The circular-manifold solve already resolved this contact without changing orbital + energy. A Cartesian pair-normal impulse here would reintroduce a star-relative radial + velocity immediately after the phase-space rotation. */ + if (preservedGroupSet.has(groupForNode.get(contact.left))) return; + const dx = contact.right.x - contact.left.x; + const dy = contact.right.y - contact.left.y; + const distance = Math.hypot(dx, dy); + if (!(distance > 1e-9)) return; + const unitX = dx / distance, unitY = dy / distance; + const tangentX = -unitY, tangentY = unitX; + const leftDelta = velocityShifts.get(contact.left); + const rightDelta = velocityShifts.get(contact.right); + const leftVx = (Number.isFinite(contact.left.vx) ? contact.left.vx : 0) + leftDelta.x; + const leftVy = (Number.isFinite(contact.left.vy) ? contact.left.vy : 0) + leftDelta.y; + const rightVx = (Number.isFinite(contact.right.vx) ? contact.right.vx : 0) + rightDelta.x; + const rightVy = (Number.isFinite(contact.right.vy) ? contact.right.vy : 0) + rightDelta.y; + const relativeVx = rightVx - leftVx, relativeVy = rightVy - leftVy; + const normalSpeed = relativeVx * unitX + relativeVy * unitY; + const tangentSpeed = relativeVx * tangentX + relativeVy * tangentY; + const tangentScale = opts.preserveTangentialVelocity === true + ? 1 : Math.min(1, contact.oldDistance / distance); + const targetNormalSpeed = Math.max(0, normalSpeed); + const deltaVx = (targetNormalSpeed - normalSpeed) * unitX + + (tangentSpeed * tangentScale - tangentSpeed) * tangentX; + const deltaVy = (targetNormalSpeed - normalSpeed) * unitY + + (tangentSpeed * tangentScale - tangentSpeed) * tangentY; + leftDelta.x -= deltaVx * contact.leftInverseMass / contact.inverseMass; + leftDelta.y -= deltaVy * contact.leftInverseMass / contact.inverseMass; + rightDelta.x += deltaVx * contact.rightInverseMass / contact.inverseMass; + rightDelta.y += deltaVy * contact.rightInverseMass / contact.inverseMass; + }); + let maximumVelocityShift = 0; + velocityShifts.forEach(shift => { + maximumVelocityShift = Math.max(maximumVelocityShift, Math.hypot(shift.x, shift.y)); + }); + const velocityScale = maximumVelocityCorrection > 0 + && maximumVelocityShift > maximumVelocityCorrection + ? maximumVelocityCorrection / maximumVelocityShift : 1; + velocityShifts.forEach((shift, node) => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + shift.x * velocityScale; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + shift.y * velocityScale; + }); + stats.maximumVelocityShift = maximumVelocityShift * velocityScale; + stats.velocityLimited = velocityScale < 1; + return stats; + } + + /* Build one conservative painted circle per independent solar system. The dominant star is + the circle centre and every member contributes its complete painted edge. Using the star + rather than the evidence-mass COM is load-bearing: a lopsided planetary system may have a + displaced COM, but translating this envelope still leaves every local radius and phase + exactly unchanged. */ + function galaxySystemEnvelopes(nodes, options) { + const opts = options || {}; + const envelopePadding = Math.max(0, Number(opts.envelopePadding) || 0); + const fixedNodeId = opts.fixedNodeId === undefined || opts.fixedNodeId === null + ? null : String(opts.fixedNodeId); + const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); + const bodyRadius = node => finitePositive( + node.radius, finitePositive(node.visual_radius, + radiusFromGravityMass(node.gravity_mass), 80), 160 + ); + return [...galaxyOrbitGroups(nodes).values()].map(center => { + const members = center.nodes.slice(); + const anchor = galaxySystemAnchor(members); + if (!anchor) return null; + const radius = members.reduce((outer, node) => Math.max(outer, + Math.hypot(node.x - anchor.x, node.y - anchor.y) + bodyRadius(node) + ), bodyRadius(anchor)) + envelopePadding; + const mass = members.reduce((sum, node) => sum + + finitePositive(node.gravity_mass, 1, 1000), 0); + const fixed = anchor.anchor_role === 'global' || members.some(node => + (fixedNodeId !== null && String(node.id) === fixedNodeId) + || (opts.respectFixedCoordinates !== false + && Number.isFinite(node.fx) && Number.isFinite(node.fy))); + return { + id: center.id, nodes: members, anchor, + x: anchor.x, y: anchor.y, radius, mass, fixed, + }; + }).filter(Boolean).sort((left, right) => + Number(right.fixed) - Number(left.fixed) + || Number(right.anchor.anchor_role === 'global') + - Number(left.anchor.anchor_role === 'global') + || right.radius - left.radius + || String(left.id).localeCompare(String(right.id)) + ); + } + + /* Assign permanent non-intersecting radial lanes to external solar-system envelopes. Two + circles whose carrier radii differ by at least the sum of their painted extents can never + collide at any orbital phase, so this admission solve removes the need to teleport systems + apart while they rotate. The chosen radius is cached on the dominant star and later calls + only admit newly revealed systems; existing phases remain untouched. */ + function establishGalaxyCarrierLanes(nodes, options) { + const opts = options || {}; + const gap = Math.max(0, Number.isFinite(Number(opts.gap)) + ? Number(opts.gap) : GALAXY_SYSTEM_PACKING_GAP); + const anchor = galaxyGlobalAnchor(nodes || []); + const systems = galaxySystemEnvelopes(nodes, Object.assign({}, opts, { + respectFixedCoordinates: false, + })).filter(system => anchor && !system.nodes.includes(anchor)); + const stats = { systems: systems.length, assigned: 0, moved: 0, maximumShift: 0 }; + if (!anchor || anchor.anchor_role !== 'global' || !systems.length) return stats; + const coreEnvelope = galaxySystemEnvelopes(nodes, Object.assign({}, opts, { + respectFixedCoordinates: false, + })).find(system => system.nodes.includes(anchor)); + systems.sort((left, right) => right.radius - left.radius + || String(left.id).localeCompare(String(right.id))); + const coreRadius = Math.max(finitePositive(anchor.radius, + evidenceNodeRadius(anchor, 3), 160), coreEnvelope ? coreEnvelope.radius : 0); + let cursor = 0, previousLaneRadius = coreRadius, previousLaneExtent = 0, laneIndex = 0; + while (cursor < systems.length) { + /* Reserve enough slack for the full orbital-speed radius range without letting the + admission pass manufacture a wide empty halo around the black hole. */ + const laneSlack = Math.max(GALAXY_CARRIER_LANE_SLACK, + galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed) + 0.02); + const laneExtent = systems[cursor].radius * laneSlack; + let laneRadius = Math.max(coreRadius + laneExtent + gap + + GALAXY_BLACK_HOLE_EXCLUSION_PADDING, + previousLaneRadius + previousLaneExtent + laneExtent + gap); + /* Use the exact chord, not circumference approximation, to find how many conservative + maximum extents fit on this ring. Larger outer rings naturally carry more systems. */ + let capacity = 1; + while (capacity < systems.length - cursor) { + const nextCapacity = capacity + 1; + const chord = 2 * laneRadius * Math.sin(Math.PI / nextCapacity); + if (chord < laneExtent * 2 + gap - 1e-9) break; + capacity = nextCapacity; + } + const count = Math.min(capacity, systems.length - cursor); + const phaseOffset = seededHash(opts.layoutSeed, + 'carrier-ring:' + String(laneIndex)) / 0x100000000 * Math.PI * 2; + for (let slot = 0; slot < count; slot++) { + const system = systems[cursor + slot]; + /* Re-evaluate with the largest member of the next lane only; sorting makes every + remaining extent no larger than this ring's conservative laneExtent. */ + const angle = phaseOffset + slot * Math.PI * 2 / count; + const unitX = Math.cos(angle), unitY = Math.sin(angle); + const shiftX = anchor.x + unitX * laneRadius - system.x; + const shiftY = anchor.y + unitY * laneRadius - system.y; + if (Math.hypot(shiftX, shiftY) > 1e-9) { + system.nodes.forEach(node => { node.x += shiftX; node.y += shiftY; }); + stats.moved++; + stats.maximumShift = Math.max(stats.maximumShift, Math.hypot(shiftX, shiftY)); + } + try { + Object.defineProperty(system.anchor, '__galaxyCarrierLaneRadius', { + value: laneRadius, writable: true, configurable: true, enumerable: false, + }); + Object.defineProperty(system.anchor, '__galaxyCarrierLaneAngle', { + value: angle, writable: true, configurable: true, enumerable: false, + }); + } catch (error) { + system.anchor.__galaxyCarrierLaneRadius = laneRadius; + system.anchor.__galaxyCarrierLaneAngle = angle; + } + stats.assigned++; + } + cursor += count; + previousLaneRadius = laneRadius; + previousLaneExtent = laneExtent; + laneIndex++; + } + stats.lanes = laneIndex; + stats.outerRadius = previousLaneRadius + previousLaneExtent; + return stats; + } + + /* Deterministic rigid carrier-frame packing. A sequential golden-angle search finds a clear + target for each complete system envelope; the live response moves only a bounded fraction + toward that target. No member velocity is changed, so packing cannot inject heat or alter + total momentum, and a star-relative planet vector survives bit-for-bit apart from ordinary + floating-point translation. Direct/bootstrap callers may pass strength=1 and an infinite + maxCorrection to complete the same solve in one call. */ + function applyGalaxySystemPacking(nodes, options) { + const opts = options || {}; + const gap = Math.max(0, Number.isFinite(Number(opts.gap)) + ? Number(opts.gap) : GALAXY_SYSTEM_PACKING_GAP); + const strength = Math.max(0, Math.min(1, Number.isFinite(Number(opts.strength)) + ? Number(opts.strength) : GALAXY_SYSTEM_PACKING_STRENGTH)); + const requestedMaximum = Number(opts.maxCorrection); + const maximumCorrection = Number.isFinite(requestedMaximum) + ? Math.max(0, requestedMaximum) : (opts.maxCorrection === Infinity + ? Infinity : GALAXY_SYSTEM_PACKING_MAX_CORRECTION); + const maximumAttempts = Math.max(32, Math.min(16384, + Number.isFinite(Number(opts.maximumAttempts)) ? Number(opts.maximumAttempts) : 4096)); + const envelopes = galaxySystemEnvelopes(nodes, opts); + /* Standalone bootstrap packing intentionally has open space. The finite annulus belongs to + the live/kinematic solver and is opt-in here through its explicit confinement option. */ + const boundaryField = opts.includeFarFieldConfinement === true + ? galaxyFarFieldEnvelope(nodes, opts) : null; + const boundaryAnchor = boundaryField && boundaryField.anchor + && boundaryField.anchor.anchor_role === 'global' ? boundaryField.anchor : null; + const boundaryAnchorRadius = boundaryAnchor && boundaryField + ? boundaryField.bodyRadius(boundaryAnchor) : 0; + const boundaryPadding = Math.max(0, + Number.isFinite(Number(opts.blackHoleExclusionPadding)) + ? Number(opts.blackHoleExclusionPadding) : GALAXY_BLACK_HOLE_EXCLUSION_PADDING); + const stats = { + systems: envelopes.length, pairs: 0, overlaps: 0, adjustedSystems: 0, + correctionDistance: 0, maximumShift: 0, remainingOverlaps: 0, + infeasiblePairs: 0, boundaryViolations: 0, + minimumBlackHoleClearance: null, minimumOuterClearance: null, + envelopeRadius: boundaryField ? boundaryField.envelopeRadius : 0, gap, + }; + if (envelopes.length < 2 || !(strength > 0) || !(maximumCorrection > 0)) return stats; + const occupied = []; + const maximumEnvelopeRadius = envelopes.reduce((maximum, system) => + Math.max(maximum, system.radius), 0); + const cellSize = Math.max(1, maximumEnvelopeRadius * 2 + gap); + const occupiedGrid = new Map(); + const targets = new Map(); + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const boundaryRange = system => { + if (!boundaryAnchor || system.nodes.includes(boundaryAnchor)) return null; + return { + minimum: boundaryAnchorRadius + system.radius + boundaryPadding, + maximum: Math.max(0, boundaryField.envelopeRadius - system.radius), + }; + }; + const projectIntoBoundary = (system, x, y, salt) => { + const range = boundaryRange(system); + if (!range || !(range.maximum >= range.minimum)) return { x, y, feasible: !range }; + const dx = x - boundaryAnchor.x, dy = y - boundaryAnchor.y; + const distance = Math.hypot(dx, dy); + let unitX, unitY; + if (distance > 1e-9) { + unitX = dx / distance; + unitY = dy / distance; + } else { + const angle = seededHash(0, 'system-pack-boundary:' + String(system.id) + + ':' + String(salt || 0)) / 0x100000000 * Math.PI * 2; + unitX = Math.cos(angle); + unitY = Math.sin(angle); + } + const boundedDistance = Math.max(range.minimum, Math.min(range.maximum, distance)); + return { + x: boundaryAnchor.x + unitX * boundedDistance, + y: boundaryAnchor.y + unitY * boundedDistance, + feasible: true, + }; + }; + const insideBoundary = (system, x, y) => { + const range = boundaryRange(system); + if (!range) return true; + if (!(range.maximum >= range.minimum)) return false; + const distance = Math.hypot(x - boundaryAnchor.x, y - boundaryAnchor.y); + return distance >= range.minimum - 1e-9 && distance <= range.maximum + 1e-9; + }; + const clearAt = (system, x, y) => { + if (!insideBoundary(system, x, y)) return false; + const cellX = Math.floor(x / cellSize), cellY = Math.floor(y / cellSize); + const reach = Math.max(1, Math.ceil( + (system.radius + maximumEnvelopeRadius + gap) / cellSize)); + for (let offsetX = -reach; offsetX <= reach; offsetX++) { + for (let offsetY = -reach; offsetY <= reach; offsetY++) { + const bucket = occupiedGrid.get( + (cellX + offsetX) + ',' + (cellY + offsetY)) || []; + for (const other of bucket) { + stats.pairs++; + if (Math.hypot(x - other.x, y - other.y) + < system.radius + other.radius + gap - 1e-9) return false; + } + } + } + return true; + }; + envelopes.forEach(system => { + const initialTarget = system.fixed + ? { x: system.x, y: system.y, feasible: insideBoundary(system, system.x, system.y) } + : projectIntoBoundary(system, system.x, system.y, 0); + let targetX = initialTarget.x, targetY = initialTarget.y; + const initiallyClear = clearAt(system, targetX, targetY); + if (!initiallyClear && !system.fixed) { + stats.overlaps++; + const seedAngle = seededHash(0, 'system-pack:' + String(system.id)) + / 0x100000000 * Math.PI * 2; + const radialStep = Math.max(4, system.radius + gap * 0.5); + let found = false; + for (let attempt = 1; attempt <= maximumAttempts; attempt++) { + const reach = radialStep * Math.sqrt(attempt); + const angle = seedAngle + goldenAngle * attempt; + const projected = projectIntoBoundary(system, + system.x + Math.cos(angle) * reach, + system.y + Math.sin(angle) * reach, attempt); + if (!projected.feasible) continue; + const candidateX = projected.x, candidateY = projected.y; + if (!clearAt(system, candidateX, candidateY)) continue; + targetX = candidateX; + targetY = candidateY; + found = true; + break; + } + if (!found) stats.infeasiblePairs++; + } else if (!initiallyClear && system.fixed) { + /* Multiple fixed/pointer-owned systems cannot be separated without violating explicit + ownership. Keep them exact and report the unresolved geometry to diagnostics. */ + stats.overlaps++; + stats.infeasiblePairs++; + } + targets.set(system, { x: targetX, y: targetY }); + const occupiedSystem = { x: targetX, y: targetY, radius: system.radius, system }; + occupied.push(occupiedSystem); + const cellKey = Math.floor(targetX / cellSize) + ',' + Math.floor(targetY / cellSize); + if (!occupiedGrid.has(cellKey)) occupiedGrid.set(cellKey, []); + occupiedGrid.get(cellKey).push(occupiedSystem); + }); + envelopes.forEach(system => { + if (system.fixed) return; + const target = targets.get(system); + let shiftX = (target.x - system.x) * strength; + let shiftY = (target.y - system.y) * strength; + const requested = Math.hypot(shiftX, shiftY); + if (!(requested > 1e-12)) return; + const scale = requested > maximumCorrection ? maximumCorrection / requested : 1; + shiftX *= scale; + shiftY *= scale; + system.nodes.forEach(node => { + node.x += shiftX; + node.y += shiftY; + }); + if (opts.updateKinematicPhase === true && system.anchor.__galaxyKinematicGlobalOrbit) { + const globalAnchor = galaxyGlobalAnchor(nodes); + if (globalAnchor && globalAnchor !== system.anchor) { + const dx = system.anchor.x - globalAnchor.x; + const dy = system.anchor.y - globalAnchor.y; + system.anchor.__galaxyKinematicGlobalOrbit.radius = Math.hypot(dx, dy); + system.anchor.__galaxyKinematicGlobalOrbit.angle = Math.atan2(dy, dx); + } + } + const applied = Math.hypot(shiftX, shiftY); + stats.adjustedSystems++; + stats.correctionDistance += applied; + stats.maximumShift = Math.max(stats.maximumShift, applied); + }); + const finalEnvelopes = galaxySystemEnvelopes(nodes, opts); + const finalGrid = new Map(); + finalEnvelopes.forEach((system, index) => { + const range = boundaryRange(system); + if (range) { + const distance = Math.hypot(system.x - boundaryAnchor.x, + system.y - boundaryAnchor.y); + const rawBlackHoleClearance = distance - range.minimum; + const rawOuterClearance = range.maximum - distance; + const blackHoleClearance = Math.abs(rawBlackHoleClearance) <= 1e-10 + ? 0 : rawBlackHoleClearance; + const outerClearance = Math.abs(rawOuterClearance) <= 1e-10 + ? 0 : rawOuterClearance; + stats.minimumBlackHoleClearance = stats.minimumBlackHoleClearance === null + ? blackHoleClearance : Math.min(stats.minimumBlackHoleClearance, blackHoleClearance); + stats.minimumOuterClearance = stats.minimumOuterClearance === null + ? outerClearance : Math.min(stats.minimumOuterClearance, outerClearance); + if (blackHoleClearance < -1e-7 || outerClearance < -1e-7) { + stats.boundaryViolations++; + } + } + const cellX = Math.floor(system.x / cellSize), cellY = Math.floor(system.y / cellSize); + for (let offsetX = -1; offsetX <= 1; offsetX++) { + for (let offsetY = -1; offsetY <= 1; offsetY++) { + const bucket = finalGrid.get( + (cellX + offsetX) + ',' + (cellY + offsetY)) || []; + bucket.forEach(other => { + if (Math.hypot(system.x - other.system.x, system.y - other.system.y) + < system.radius + other.system.radius + gap - 1e-7) { + stats.remainingOverlaps++; + } + }); + } + } + const key = cellX + ',' + cellY; + if (!finalGrid.has(key)) finalGrid.set(key, []); + finalGrid.get(key).push({ system, index }); + }); + return stats; + } + + /* The black hole is an impenetrable visual boundary, not a generic collision partner. + External solar systems cross that boundary as one rigid translation so their local + geometry and relative velocities survive the contact. Members of the black-hole system + are handled individually because translating that system would move the anchor itself. + + This is a zero-restitution contact constraint: project only the penetration, remove inward + radial velocity, and scale BH-frame tangential speed by old/new radius. A grazing body keeps + essentially all of its orbit, while a deep correction cannot manufacture angular momentum + or a repulsive slingshot. */ + function applyGalaxyBlackHoleExclusion(nodes, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const candidate = galaxyGlobalAnchor(bodies); + /* Compatibility payloads can omit anchor roles. They still receive a smooth central field, + but no node is painted as a black hole, so inventing a collision disc would rewrite their + server coordinates. The hard horizon belongs only to the explicit global anchor. */ + const anchor = candidate && candidate.anchor_role === 'global' ? candidate : null; + const stats = { + anchorId: anchor ? anchor.id : null, + contacts: 0, systems: 0, coreNodes: 0, fixedSystemNodes: 0, repelledNodes: 0, + correctedDistance: 0, maximumShift: 0, inwardVelocityRemoved: 0, + tangentialVelocityRemoved: 0, + minimumClearance: null, + }; + if (!anchor || bodies.length < 2) return stats; + const padding = Math.max(0, Number.isFinite(Number(opts.padding)) + ? Number(opts.padding) : GALAXY_BLACK_HOLE_EXCLUSION_PADDING); + const bodyRadius = node => finitePositive( + node.radius, evidenceNodeRadius(node, 3), 160 + ); + const anchorRadius = bodyRadius(anchor); + const anchorX = anchor.x, anchorY = anchor.y; + const anchorVx = Number.isFinite(anchor.vx) ? anchor.vx : 0; + const anchorVy = Number.isFinite(anchor.vy) ? anchor.vy : 0; + const coreKey = communityKey(anchor); + const radialUnit = (key, dx, dy) => { + const distance = Math.hypot(dx, dy); + if (distance > 1e-9) return { x: dx / distance, y: dy / distance, distance }; + const angle = seededHash(0, 'black-hole-horizon:' + String(key)) + / 0x100000000 * Math.PI * 2; + return { x: Math.cos(angle), y: Math.sin(angle), distance: 0 }; + }; + const stabilizeSystemContactVelocity = ( + members, unitX, unitY, oldDistance, newDistance + ) => { + let totalMass = 0, velocityX = 0, velocityY = 0; + members.forEach(node => { + const mass = finitePositive(node.gravity_mass, 1, 1000); + totalMass += mass; + velocityX += mass * (Number.isFinite(node.vx) ? node.vx : 0); + velocityY += mass * (Number.isFinite(node.vy) ? node.vy : 0); + }); + if (!(totalMass > 0)) return { inward: 0, tangential: 0 }; + const relativeVx = velocityX / totalMass - anchorVx; + const relativeVy = velocityY / totalMass - anchorVy; + const tangentX = -unitY, tangentY = unitX; + const radialSpeed = relativeVx * unitX + relativeVy * unitY; + const tangentialSpeed = relativeVx * tangentX + relativeVy * tangentY; + const tangentScale = newDistance > 1e-9 + ? Math.max(0, Math.min(1, oldDistance / newDistance)) : 0; + const targetRadialSpeed = Math.max(0, radialSpeed); + const targetTangentialSpeed = tangentialSpeed * tangentScale; + const targetVx = targetRadialSpeed * unitX + targetTangentialSpeed * tangentX; + const targetVy = targetRadialSpeed * unitY + targetTangentialSpeed * tangentY; + const shiftVx = targetVx - relativeVx, shiftVy = targetVy - relativeVy; + members.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + shiftVx; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + shiftVy; + }); + return { + inward: Math.max(0, -radialSpeed), + tangential: Math.abs(tangentialSpeed) * (1 - tangentScale), + }; + }; + const projectIndividualNode = node => { + const radial = radialUnit(node.id, node.x - anchorX, node.y - anchorY); + const minimumDistance = anchorRadius + bodyRadius(node) + padding; + const correction = minimumDistance - radial.distance; + if (!(correction > 0) || !Number.isFinite(correction)) return false; + node.x = anchorX + radial.x * minimumDistance; + node.y = anchorY + radial.y * minimumDistance; + if (Number.isFinite(node.fx)) node.fx = node.x; + if (Number.isFinite(node.fy)) node.fy = node.y; + const velocity = stabilizeSystemContactVelocity( + [node], radial.x, radial.y, radial.distance, minimumDistance + ); + stats.inwardVelocityRemoved += velocity.inward; + stats.tangentialVelocityRemoved += velocity.tangential; + stats.contacts++; + stats.repelledNodes++; + stats.correctedDistance += correction; + stats.maximumShift = Math.max(stats.maximumShift, correction); + return true; + }; + + communityCenters(bodies).forEach(center => { + if (center.id === coreKey) { + center.nodes.forEach(node => { + if (node === anchor) return; + if (projectIndividualNode(node)) stats.coreNodes++; + }); + return; + } + + /* A dragged node is a cursor-owned external source. Rigidly translating its entire + community when that cursor touches the horizon creates positive feedback: restore + puts only the source back at the cursor, while every follower retains the displacement + and inflates the next system radius. Keep the horizon strict per painted member but + never move those followers as a group. */ + if (center.nodes.some(node => node.id === opts.fixedNodeId)) { + center.nodes.forEach(node => { + if (projectIndividualNode(node)) stats.fixedSystemNodes++; + }); + return; + } + + /* A circular envelope around the evidence-mass COM is conservative but exact as a + safety bound: once its near edge clears the black hole, every painted member does. */ + const systemRadius = center.nodes.reduce((maximum, node) => Math.max(maximum, + Math.hypot(node.x - center.x, node.y - center.y) + bodyRadius(node)), 0); + const radial = radialUnit(center.id, center.x - anchorX, center.y - anchorY); + const minimumDistance = anchorRadius + systemRadius + padding; + const correction = minimumDistance - radial.distance; + if (!(correction > 0) || !Number.isFinite(correction)) return; + const shiftX = radial.x * correction, shiftY = radial.y * correction; + center.nodes.forEach(node => { + node.x += shiftX; + node.y += shiftY; + if (Number.isFinite(node.fx)) node.fx += shiftX; + if (Number.isFinite(node.fy)) node.fy += shiftY; + }); + const velocity = stabilizeSystemContactVelocity( + center.nodes, radial.x, radial.y, radial.distance, minimumDistance + ); + stats.inwardVelocityRemoved += velocity.inward; + stats.tangentialVelocityRemoved += velocity.tangential; + stats.contacts++; + stats.systems++; + stats.repelledNodes += center.nodes.length; + stats.correctedDistance += correction; + stats.maximumShift = Math.max(stats.maximumShift, correction); + }); + + bodies.forEach(node => { + if (node === anchor) return; + const clearance = Math.hypot(node.x - anchorX, node.y - anchorY) + - anchorRadius - bodyRadius(node) - padding; + stats.minimumClearance = stats.minimumClearance === null + ? clearance : Math.min(stats.minimumClearance, clearance); + }); + return stats; + } + + function combineGalaxyBlackHoleExclusions(passes) { + const usable = (passes || []).filter(pass => pass && typeof pass === 'object'); + const last = usable[usable.length - 1] || { + anchorId: null, contacts: 0, systems: 0, coreNodes: 0, fixedSystemNodes: 0, + repelledNodes: 0, + correctedDistance: 0, maximumShift: 0, inwardVelocityRemoved: 0, + tangentialVelocityRemoved: 0, minimumClearance: null, + }; + return { + anchorId: usable.map(pass => pass.anchorId).find(Boolean) || null, + contacts: usable.reduce((sum, pass) => sum + (pass.contacts || 0), 0), + systems: usable.reduce((sum, pass) => sum + (pass.systems || 0), 0), + coreNodes: usable.reduce((sum, pass) => sum + (pass.coreNodes || 0), 0), + fixedSystemNodes: usable.reduce((sum, pass) => sum + (pass.fixedSystemNodes || 0), 0), + repelledNodes: usable.reduce((sum, pass) => sum + (pass.repelledNodes || 0), 0), + correctedDistance: usable.reduce((sum, pass) => sum + (pass.correctedDistance || 0), 0), + maximumShift: usable.reduce((maximum, pass) => Math.max(maximum, + pass.maximumShift || 0), 0), + inwardVelocityRemoved: usable.reduce((sum, pass) => sum + (pass.inwardVelocityRemoved || 0), 0), + tangentialVelocityRemoved: usable.reduce((sum, pass) => sum + + (pass.tangentialVelocityRemoved || 0), 0), + minimumClearance: last.minimumClearance, + }; + } + + /* Bound only anomalous motion inside each solar system. Explicit systems are scaled about the + dominant star's carrier velocity, keeping that local origin exact while limiting only planet + motion. Compatibility groups retain their mass-COM reference. One non-negative per-system + scale preserves every relative direction and cannot manufacture a new radial kick. */ + function stabilizeGalaxySystemVelocities(nodes, options) { + const opts = options || {}; + const limit = Math.max(0.01, Number.isFinite(Number(opts.limit)) + ? Number(opts.limit) : GALAXY_LOCAL_RELATIVE_SPEED_LIMIT); + const absoluteLimit = Math.max(0.01, Number.isFinite(Number(opts.absoluteLimit)) + ? Number(opts.absoluteLimit) : Infinity); + const systems = new Map(); + (nodes || []).forEach(node => { + if (!node || node.ghost || !Number.isFinite(node.vx) || !Number.isFinite(node.vy)) return; + const key = communityKey(node); + if (!systems.has(key)) systems.set(key, []); + systems.get(key).push(node); + }); + let limitedSystems = 0, maximumRelativeSpeed = 0, minimumScale = 1; + systems.forEach(members => { + if (members.length < 2) return; + const anchor = members.find(node => node.anchor_role === 'global') + || members.find(node => node.id === opts.fixedNodeId) + || members.find(node => node.anchor_role === 'community'); + let referenceVx = 0, referenceVy = 0; + if (anchor) { + referenceVx = Number.isFinite(anchor.vx) ? anchor.vx : 0; + referenceVy = Number.isFinite(anchor.vy) ? anchor.vy : 0; + } else { + let totalMass = 0; + members.forEach(node => { + const mass = finitePositive(node.gravity_mass, 1, 1000); + totalMass += mass; + referenceVx += mass * node.vx; + referenceVy += mass * node.vy; + }); + referenceVx /= Math.max(1e-9, totalMass); + referenceVy /= Math.max(1e-9, totalMass); + } + let systemMaximum = 0, scale = 1; + members.forEach(node => { + if (node === anchor) return; + const relativeVx = node.vx - referenceVx, relativeVy = node.vy - referenceVy; + const relativeSpeed = Math.hypot(relativeVx, relativeVy); + systemMaximum = Math.max(systemMaximum, relativeSpeed); + if (relativeSpeed > limit) scale = Math.min(scale, limit / relativeSpeed); + /* A planet's local tangent rides on top of the star's galactic carrier velocity. + Bound that composition in the local frame before the global emergency guard, which + would otherwise scale every solar system and repeatedly erase orbital phase. One + common non-negative scale preserves all relative directions inside this system. */ + if (anchor && Number.isFinite(absoluteLimit) && relativeSpeed > 1e-12 + && Math.hypot(referenceVx, referenceVy) <= absoluteLimit + 1e-12) { + const a = relativeVx * relativeVx + relativeVy * relativeVy; + const b = 2 * (referenceVx * relativeVx + referenceVy * relativeVy); + const c = referenceVx * referenceVx + referenceVy * referenceVy + - absoluteLimit * absoluteLimit; + const discriminant = Math.max(0, b * b - 4 * a * c); + const maximumScale = Math.max(0, (-b + Math.sqrt(discriminant)) / (2 * a)); + scale = Math.min(scale, maximumScale); + } + }); + maximumRelativeSpeed = Math.max(maximumRelativeSpeed, systemMaximum); + /* A fast galactic carrier can exceed the absolute budget even when every planet's local + tangent is healthy. Translate the whole velocity frame inward before touching local + motion. This preserves every star-relative velocity exactly and prevents the later + scene-wide emergency scale from erasing orbital phase in unrelated systems. */ + let carrierAdjusted = false; + if (anchor && Number.isFinite(absoluteLimit)) { + const retainedRelative = systemMaximum * scale; + const carrierAllowance = Math.max(0, absoluteLimit - retainedRelative); + const carrierSpeed = Math.hypot(referenceVx, referenceVy); + if (carrierSpeed > carrierAllowance + 1e-12) { + const carrierScale = carrierSpeed > 1e-12 ? carrierAllowance / carrierSpeed : 0; + const targetVx = referenceVx * carrierScale; + const targetVy = referenceVy * carrierScale; + const shiftX = targetVx - referenceVx; + const shiftY = targetVy - referenceVy; + members.forEach(node => { + node.vx += shiftX; + node.vy += shiftY; + }); + referenceVx = targetVx; + referenceVy = targetVy; + carrierAdjusted = true; + minimumScale = Math.min(minimumScale, carrierScale); + } + } + if (!(scale < 1 - 1e-12) && !carrierAdjusted) return; + members.forEach(node => { + if (node === anchor) { + node.vx = referenceVx; + node.vy = referenceVy; + return; + } + node.vx = referenceVx + (node.vx - referenceVx) * scale; + node.vy = referenceVy + (node.vy - referenceVy) * scale; + }); + limitedSystems++; + minimumScale = Math.min(minimumScale, scale); + }); + return { + systems: systems.size, limitedSystems, maximumRelativeSpeed, minimumScale, limit, + absoluteLimit, + }; + } + + /* Galaxy owns its time integration instead of donating it to D3's alpha clock. The + force helpers above are deliberately still useful on their own (and are tested as + such), so this small adapter samples their acceleration field with a clean velocity + buffer. That lets a browser run a fixed kick-drift-kick step without treating an + alpha decay or a render cadence as physical time. + + `vx`/`vy` are the integrator's velocity slots. The browser adapter may mirror them + into private fields before calling this helper, but keeping the pure function on the + familiar node shape makes deterministic tests and non-DOM embeds straightforward. */ + function galaxyAccelerations(nodes, links, bridges, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const saved = new Map(bodies.map(node => [node, { + vx: Number.isFinite(node.vx) ? node.vx : 0, + vy: Number.isFinite(node.vy) ? node.vy : 0, + }])); + bodies.forEach(node => { node.vx = 0; node.vy = 0; }); + const gravity = Math.max(0, Number(opts.gravity) || 0); + const softening = Math.max(0.1, Number(opts.softening) || 8); + const anchor = galaxyGlobalAnchor(bodies); + const systemGravity = applyGalaxySystemAnchorGravity(bodies, { + gravity, softening, alpha: 1, + gravitationalConstant: opts.gravitationalConstant, + localGravitationalConstant: opts.localGravitationalConstant, + accelerationCap: opts.localAccelerationCap, + fixedNodeId: opts.fixedNodeId, + repulsionPadding: opts.systemAnchorExclusionPadding, + repulsionRange: opts.systemAnchorRepulsionRange, + repulsionAcceleration: opts.systemAnchorRepulsionAcceleration, + }); + if (opts.central !== false) { + applyGalaxyBlackHoleGravity(bodies, { + gravity, + gravitationalConstant: opts.gravitationalConstant, + blackHoleMass: opts.blackHoleMass, + softening: Math.max(36, Number(opts.centralSoftening) || softening * 5), + accelerationCap: opts.centralAccelerationCap, + }); + } + const mutualGravity = opts.includeMutualSystems === true + ? applyGalaxyMutualSystemGravity(bodies, { + gravity, + gravitationalConstant: opts.gravitationalConstant, + strengthFraction: opts.mutualSystemGravityFraction, + softening: opts.mutualSystemSoftening, + accelerationCap: opts.mutualSystemAccelerationCap, + exactLimit: opts.exactLimit, + theta: opts.theta, + alpha: 1, + }) + : { systems: 0, interactions: 0, traversals: 0, approximations: 0, + maximumAcceleration: 0, capScale: 1 }; + /* Sample the outer restoring field in both leapfrog kicks. External systems receive one + shared COM acceleration; anchor-community satellites receive their own radial sample so + neither population can drift through the finite painted edge. */ + const farFieldGravity = opts.includeFarFieldConfinement === false + ? { anchorId: null, envelopeRadius: 0, softRadius: 0, + acceleratedSystems: 0, acceleratedCoreNodes: 0, acceleratedFixedFollowers: 0, + maximumAcceleration: 0 } + : applyGalaxyFarFieldGravity(bodies, opts); + /* Cross-system bridges and relation springs are intentionally opt-in at the + integrator boundary. A caller that wants the evidence layout enables bridges; + relation springs stay a weak visual constraint, never an accidental replacement for + gravity in a pure orbital simulation. */ + if (opts.includeBridges === true) { + applyCommunityBridgeGravity(bodies, bridges || [], { + gravity, + softening: Math.max(24, Number(opts.bridgeSoftening) || softening * 4), + alpha: 1, + }); + } + if (opts.includeRelations === true && opts.includeRelationSprings !== false) { + applyGalaxyRelationSprings(bodies, links || [], { + alpha: 1, + orbitScale: opts.orbitScale, + forceCap: opts.relationForceCap, + strengthMultiplier: (Number(opts.relationStrengthMultiplier) || 1) + * galaxyPhysicsMultiplier(opts.springStiffness, + GALAXY_SPRING_STIFFNESS_MULTIPLIER, 8), + accelerationCap: opts.relationAccelerationCap, + padding: opts.relationPadding, + fixedNodeId: opts.fixedNodeId, + skipFixedNodeRelations: !!opts.dragSource, + skipSystemAnchorRelations: opts.skipSystemAnchorRelations === true, + skipOrbitalSystemRelations: opts.skipOrbitalSystemRelations === true, + }); + } + const dragGravity = opts.dragSource ? applyDraggedNodeAcceleration( + opts.dragSource, opts.dragFollowers || [], { + gravity, + softening: opts.dragSoftening, + } + ) : { applied: 0, maximumAcceleration: 0, maximumPull: 0 }; + const spacetime = opts.includeSpacetime !== true + ? { anchorId: null, systems: 0, coreNodes: 0, warpedNodes: 0, + maximumWarp: 0, maximumFrameDragAcceleration: 0, + maximumHorizonAcceleration: 0, tidalSystems: 0, tidalPlanets: 0, + maximumTidalAcceleration: 0, accelerations: new Map() } + : applyGalaxySpacetimeAcceleration(bodies, opts); + spacetime.accelerations.forEach((acceleration, node) => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + acceleration.ax; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + acceleration.ay; + }); + delete spacetime.accelerations; + if (anchor && (opts.central !== false || anchor.anchor_role === 'global')) { + /* The global evidence node is the chart's black-hole potential, not a light particle + that its own bulge can kick. Satellites still receive the local equal field; fixing + the source prevents that recoil from becoming a fictitious uniform acceleration when + the next step is expressed in the black-hole frame. */ + anchor.vx = 0; + anchor.vy = 0; + } + const accelerations = new Map(bodies.map(node => [node, { + ax: Number.isFinite(node.vx) ? node.vx : 0, + ay: Number.isFinite(node.vy) ? node.vy : 0, + }])); + bodies.forEach(node => { + const velocity = saved.get(node); + node.vx = velocity.vx; + node.vy = velocity.vy; + }); + accelerations.dragGravity = dragGravity; + accelerations.systemGravity = systemGravity; + accelerations.mutualGravity = mutualGravity; + accelerations.farFieldGravity = farFieldGravity; + accelerations.spacetime = spacetime; + return accelerations; + } - /* `zoomToFit()` derives its bounds from force-graph's default node geometry rather than - our custom canvas radius. A compact, nearly-linear graph can therefore produce a 10×+ - fit zoom even though its rendered nodes already fill the canvas. At that scale a normal - drag maps to a tiny world-space movement and reheating makes the rest of the layout look - like it is racing away. Keep auto-fit useful without letting its scale become unstable. */ - const MAX_AUTO_FIT_ZOOM = 4; - const SETTINGS_ALPHA_TARGET = 0.12; - const ALPHA_TARGET_HOLD_MS = 180; - const DRAG_ALPHA_TARGET = 0.04; - const DRAG_SETTLE_DELAY_MS = 80; + function galaxyInwardConvergenceFactor(wallClockSeconds, gravitySetting) { + const elapsed = Number.isFinite(Number(wallClockSeconds)) + ? Math.max(0, Number(wallClockSeconds)) + : GALAXY_FRAME_INTERVAL_MS / 1000; + return Math.pow(1 - galaxyInwardConvergencePerMinute(gravitySetting), + elapsed / GALAXY_INWARD_CONVERGENCE_SECONDS); + } - /* Physics is allowed to respond live, but one bad force update must never turn a - settled graph into a high-speed slingshot. Keep the bounds in world units so they - remain meaningful at every camera zoom. */ - const MIN_NODE_SPEED = 8; - const MAX_NODE_SPEED = 48; - const MIN_DRAG_PULL = 4; - const MAX_DRAG_PULL = 18; + /* Project solar-system centres into a monotone, slowly contracting black-hole frame. The + leapfrog field remains responsible for orbital phase and local structure; every member + receives the same position/velocity translation, so Link distance can tighten or loosen + connected nodes without the central boundary crushing their internal orbit. A late outward + kick can never make an external system fall away from the centre. Each ordinary step follows + the controlled track exactly. We retain the candidate angle and system tangential velocity. + An outward attempt receives at least a 110% counter-projection, and only the system COM's + radial velocity is changed. - /* The classic renderer's *dense* signal (`GPERF.dense`, `links>1500` in dashboard.js). Past - it the classic path turns off the two per-edge costs that scale with the link count and - buy nothing at that density: link curvature (a quadratic bezier per relation instead of a - straight line) and the directional arrowhead (a filled triangle per relation, recomputed - every frame). Relation labels get the same treatment unless one node is highlighted. Same - thresholds and same behaviour here — a second signal would only drift. */ - const DENSE_LINK_LIMIT = 1500; + This intentionally does not conserve whole-scene momentum: the global evidence anchor + is an external black-hole frame, already pinned by `recenterGalaxyOnAnchor`, not a light + particle that recoils. Keeping that caveat here prevents a future "conservative" cleanup + from silently restoring outward drift. */ + function applyGalaxyInwardConvergence(bodies, anchor, initialRadii, options) { + const opts = options || {}; + if (!anchor || !initialRadii || typeof initialRadii.get !== 'function') { + return { applied: 0, outwardCandidates: 0, overrides: 0, factor: 1 }; + } + const anchorX = Number.isFinite(anchor.x) ? anchor.x : 0; + const anchorY = Number.isFinite(anchor.y) ? anchor.y : 0; + const factor = galaxyInwardConvergenceFactor(opts.wallClockSeconds, opts.gravity); + const timestep = Number.isFinite(Number(opts.timestep)) + ? Math.max(0.001, Number(opts.timestep)) : GALAXY_FIXED_TIMESTEP; + let applied = 0, outwardCandidates = 0, overrides = 0; + communityCenters(bodies).forEach(center => { + if (!center || center.nodes.includes(anchor) + || center.nodes.some(node => node.anchor_role === 'global' + || node.id === opts.fixedNodeId)) return; + const initialState = initialRadii.get(center.id); + const initialRadius = Number(initialState && typeof initialState === 'object' + ? initialState.radius : initialState); + if (!Number.isFinite(initialRadius) + || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return; + const dx = center.x - anchorX, dy = center.y - anchorY; + const candidateRadius = Math.hypot(dx, dy); + if (!Number.isFinite(candidateRadius)) return; + const scheduledRadius = initialRadius * factor; + const outwardDistance = Math.max(0, candidateRadius - initialRadius); + /* Follow the gravity-selected track exactly. For an outward attempted move, require + a final position at least 10% of that attempted distance inward from the starting + radius, even when that is more inward than the scheduled track. */ + const outwardCeiling = initialRadius - outwardDistance * GALAXY_OUTWARD_OVERRIDE; + const finalRadius = Math.max(0, outwardDistance > 0 + ? Math.min(scheduledRadius, outwardCeiling) : scheduledRadius); + const unitX = candidateRadius > 1e-9 ? dx / candidateRadius : 1; + const unitY = candidateRadius > 1e-9 ? dy / candidateRadius : 0; + const finalX = anchorX + unitX * finalRadius; + const finalY = anchorY + unitY * finalRadius; + const shiftX = finalX - center.x, shiftY = finalY - center.y; + let centerVx = 0, centerVy = 0; + center.nodes.forEach(node => { + const mass = finitePositive(node.gravity_mass, 1, 1000); + centerVx += mass * (Number.isFinite(node.vx) ? node.vx : 0); + centerVy += mass * (Number.isFinite(node.vy) ? node.vy : 0); + }); + centerVx /= Math.max(1e-9, center.mass); + centerVy /= Math.max(1e-9, center.mass); + const tangentVelocity = centerVx * -unitY + centerVy * unitX; + /* The system radial component follows the projection's actual displacement. Relative + positions and velocities are untouched, preserving local gravity and link springs. */ + const radialVelocity = (finalRadius - initialRadius) / timestep; + const targetVx = radialVelocity * unitX - tangentVelocity * unitY; + const targetVy = radialVelocity * unitY + tangentVelocity * unitX; + const velocityShiftX = targetVx - centerVx; + const velocityShiftY = targetVy - centerVy; + center.nodes.forEach(node => { + node.x += shiftX; + node.y += shiftY; + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + velocityShiftX; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + velocityShiftY; + }); + if (outwardDistance > 0) { + outwardCandidates++; + overrides++; + } + applied += center.nodes.length; + }); + return { applied, outwardCandidates, overrides, factor }; + } - /* Relation labels are the noisiest layer on the canvas, so — exactly as the classic - `linkCanvasObject` does — they only appear once the user has zoomed in past this scale. */ - const LINK_LABEL_MIN_SCALE = 2.4; + /* Preserve the angular momentum that defines a galaxy after constraint projection and tiny + numerical damping. Gravity remains the radial force; this is a bounded carrier-frame + insertion controller that supplies only missing prograde tangent and removes radial lane + drift. Every member of an external solar system receives the same velocity delta, so no + star/planet relative orbit or link velocity is changed. Core children are independent + black-hole satellites and receive the same support one body at a time. */ + function supportGalaxyCarrierOrbits(nodes, options) { + const opts = options || {}; + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const field = galaxyBlackHoleField(bodies, opts); + const anchor = field.anchor && field.anchor.anchor_role === 'global' ? field.anchor : null; + const stats = { + anchorId: anchor ? anchor.id : null, eligible: 0, supported: 0, + coreEligible: 0, coreSupported: 0, minTangentialSpeed: null, + coreMinTangentialSpeed: null, maximumRadialSpeed: 0, + maximumVelocityCorrection: 0, corrected: 0, meanAngularVelocity: 0, + maximumPositionCorrection: 0, + }; + if (!anchor || !(field.gravitationalConstant > 0)) return stats; + const direction = (seededHash(opts.layoutSeed, 'galaxy-spin') & 1) ? 1 : -1; + const centralSoftening = Math.max(0.1, + Number(opts.centralSoftening) || opts.softening || 40); + const anchorVx = Number.isFinite(anchor.vx) ? anchor.vx : 0; + const anchorVy = Number.isFinite(anchor.vy) ? anchor.vy : 0; + const fixedNodeId = opts.fixedNodeId === undefined || opts.fixedNodeId === null + ? null : String(opts.fixedNodeId); + const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); + let angularVelocitySum = 0; + const circularSpeedAt = radius => { + const coreDenominator = Math.pow( + radius * radius + centralSoftening * centralSoftening, 1.5); + const haloDenominator = Math.pow( + radius * radius + field.haloScale * field.haloScale, 1.5); + const omega = Math.sqrt(Math.max(0, field.gravitationalConstant * ( + field.coreMass / coreDenominator + + (field.haloMass > 0 ? field.haloMass / haloDenominator : 0) + ))); + return Math.min(GALAXY_SYSTEM_ORBIT_SEED_SPEED_LIMIT * orbitalSpeed, + omega * radius * orbitalSpeed); + }; + const coreCircularSpeedAt = radius => { + const localGravityMultiplier = galaxyLocalGravityMultiplier(anchor, opts); + const localGravity = galaxySystemGravityConstant(anchor, opts.gravity) + * localGravityMultiplier; + const denominator = Math.pow( + radius * radius + Math.max(0.1, Number(opts.softening) || 8) ** 2, 1.5); + const rawAcceleration = denominator > 0 + ? localGravity * finitePositive(anchor.gravity_mass, 1, 1000) + * radius / denominator : 0; + const acceleration = Math.min(defaultGalaxySystemAccelerationCap(anchor, opts.gravity) + * Math.max(0.25, localGravityMultiplier), rawAcceleration); + return Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT * orbitalSpeed, + Math.sqrt(Math.max(0, acceleration * radius)) * orbitalSpeed); + }; + const support = (group, carrier, targetSpeed, core) => { + let dx = carrier.x - anchor.x, dy = carrier.y - anchor.y; + let radius = Math.hypot(dx, dy); + if (!(radius > 1e-9) || !(targetSpeed > 0)) return; + const laneRadius = Number(core + ? carrier.__galaxyCoreLaneRadius : carrier.__galaxyCarrierLaneRadius); + const laneAngleKey = core ? '__galaxyCoreLaneAngle' : '__galaxyCarrierLaneAngle'; + if (Number.isFinite(laneRadius) && laneRadius > 0) { + radius = laneRadius; + targetSpeed = core ? coreCircularSpeedAt(radius) : circularSpeedAt(radius); + /* Contact and boundary projections run before carrier support. Their positional + correction is a legitimate phase change; restarting from the cached pre-contact + angle would snap the body backward, then repeat that snap on every frame. Reconcile + from the carrier's current post-correction angle and retain the cache only for the + degenerate coincident fallback. */ + const currentAngle = Math.atan2(dy, dx); + const cachedAngle = Number(carrier[laneAngleKey]); + const advance = direction * targetSpeed / radius * timestep; + let angle; + if (Number.isFinite(cachedAngle) && Number.isFinite(currentAngle)) { + const expectedAngle = cachedAngle + advance; + const phaseError = Math.atan2( + Math.sin(currentAngle - expectedAngle), Math.cos(currentAngle - expectedAngle)); + const correctionDistance = 2 * radius * Math.abs(Math.sin(phaseError * 0.5)); + /* Normal leapfrog drift is expected to land near the next cached phase. Only a + materially displaced carrier represents an impact/boundary correction; adopt that + phase once and do not add a second orbital step on top of it. */ + angle = correctionDistance > GALAXY_LANE_PHASE_CORRECTION_DISTANCE + ? currentAngle : expectedAngle; + } else { + angle = Number.isFinite(currentAngle) ? currentAngle + advance : cachedAngle; + } + if (!Number.isFinite(angle)) angle = 0; + carrier[laneAngleKey] = angle; + const targetX = anchor.x + Math.cos(angle) * radius; + const targetY = anchor.y + Math.sin(angle) * radius; + const shiftX = targetX - carrier.x, shiftY = targetY - carrier.y; + group.forEach(node => { node.x += shiftX; node.y += shiftY; }); + stats.maximumPositionCorrection = Math.max(stats.maximumPositionCorrection, + Math.hypot(shiftX, shiftY)); + dx = carrier.x - anchor.x; dy = carrier.y - anchor.y; + } + const carrierVx = (Number.isFinite(carrier.vx) ? carrier.vx : 0) - anchorVx; + const carrierVy = (Number.isFinite(carrier.vy) ? carrier.vy : 0) - anchorVy; + const existingAngular = dx * carrierVy - dy * carrierVx; + const orbitDirection = core && !(Number.isFinite(laneRadius) && laneRadius > 0) + && Math.abs(existingAngular) > 1e-9 ? Math.sign(existingAngular) : direction; + const unitX = dx / radius, unitY = dy / radius; + const tangentX = -unitY * orbitDirection, tangentY = unitX * orbitDirection; + const radialSpeed = carrierVx * unitX + carrierVy * unitY; + const signedTangent = carrierVx * tangentX + carrierVy * tangentY; + /* Admission assigns collision-free circular lanes. Exact circular carrier velocity keeps + every member of a shared ring at one angular frequency, so phase gaps and envelope + clearance cannot drift. This changes only the external carrier frame; local eccentric + star/planet motion remains entirely in the unchanged relative velocities. */ + const supportedTangent = targetSpeed; + const supportedRadial = 0; + const deltaX = (supportedRadial - radialSpeed) * unitX + + (supportedTangent - signedTangent) * tangentX; + const deltaY = (supportedRadial - radialSpeed) * unitY + + (supportedTangent - signedTangent) * tangentY; + group.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY; + }); + const correction = Math.hypot(deltaX, deltaY); + stats.supported++; + if (core) stats.coreSupported++; + if (correction > 1e-12) stats.corrected++; + stats.maximumRadialSpeed = Math.max(stats.maximumRadialSpeed, Math.abs(supportedRadial)); + stats.maximumVelocityCorrection = Math.max(stats.maximumVelocityCorrection, correction); + stats.minTangentialSpeed = stats.minTangentialSpeed === null + ? supportedTangent : Math.min(stats.minTangentialSpeed, supportedTangent); + if (core) stats.coreMinTangentialSpeed = stats.coreMinTangentialSpeed === null + ? supportedTangent : Math.min(stats.coreMinTangentialSpeed, supportedTangent); + angularVelocitySum += supportedTangent / radius; + }; + const coreKey = String(anchor.id); + galaxyOrbitGroups(bodies).forEach(center => { + if (center.id === coreKey) { + galaxyBlackHoleCoreSystems(center.nodes, anchor).forEach(group => { + const carrier = galaxySystemAnchor(group) || group[0]; + if (!carrier || (fixedNodeId !== null && group.some(node => + String(node.id) === fixedNodeId))) return; + stats.eligible++; + stats.coreEligible++; + support(group, carrier, + coreCircularSpeedAt(Math.hypot(carrier.x - anchor.x, carrier.y - anchor.y)), true); + }); + return; + } + if (center.nodes.some(node => node.anchor_role === 'global' + || (fixedNodeId !== null && String(node.id) === fixedNodeId))) return; + const carrier = galaxySystemAnchor(center.nodes) || center.nodes[0]; + stats.eligible++; + support(center.nodes, carrier, + circularSpeedAt(Math.hypot(carrier.x - anchor.x, carrier.y - anchor.y)), false); + }); + stats.meanAngularVelocity = stats.eligible > 0 + ? angularVelocitySum / stats.eligible : 0; + return stats; + } - function hasOwn(value, key) { - return value != null && Object.prototype.hasOwnProperty.call(value, key); + /* The black-hole Plummer field deliberately stays gentle at the outer edge so seeded + tangential motion remains legible. This separate field is an equally smooth, *system* + level restoring term in the narrow outer band. It is not fitted from live coordinates: + the painted extent is derived once from scene hints and retained on the explicit global + anchor, so one bad outward kick cannot make the galaxy's permitted radius grow with it. */ + function galaxyFarFieldEnvelope(nodes, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const candidate = galaxyGlobalAnchor(bodies); + const anchor = candidate && candidate.anchor_role === 'global' ? candidate : null; + const empty = { + anchor: null, centers: [], coreKey: null, envelopeRadius: 0, softRadius: 0, + }; + if (!anchor) return empty; + const centers = [...galaxyOrbitGroups(bodies).values()]; + const coreKey = String(anchor.id); + const bodyRadius = node => finitePositive(node.radius, evidenceNodeRadius(node, 3), 160); + const systemRadius = center => center.nodes.reduce((maximum, node) => Math.max(maximum, + Math.hypot(node.x - center.x, node.y - center.y) + bodyRadius(node)), 0); + const seededRadius = node => ['galactic_target_radius', 'galactic_radius', 'orbit_radius'] + .reduce((maximum, key) => { + const value = Number(node[key]); + return Number.isFinite(value) && value > 0 ? Math.max(maximum, value) : maximum; + }, 0); + const anchorRadius = bodyRadius(anchor); + let hintedExtent = 0, observedExtent = 0, horizonExtent = anchorRadius; + let hasHint = false; + centers.forEach(center => { + const extent = systemRadius(center); + const radial = Math.hypot(center.x - anchor.x, center.y - anchor.y); + const hint = center.nodes.reduce((maximum, node) => Math.max(maximum, seededRadius(node)), 0); + if (center.id === coreKey) { + center.nodes.forEach(node => { + if (node === anchor) return; + const radius = bodyRadius(node); + const nodeHint = seededRadius(node); + if (nodeHint > 0) { + hintedExtent = Math.max(hintedExtent, nodeHint + radius); + hasHint = true; + } + observedExtent = Math.max(observedExtent, + Math.hypot(node.x - anchor.x, node.y - anchor.y) + radius); + /* The eventual outer edge must leave enough radial room for both sides of this + satellite's painted body at the inner horizon. */ + horizonExtent = Math.max(horizonExtent, + anchorRadius + radius * 2 + GALAXY_BLACK_HOLE_EXCLUSION_PADDING); + }); + } else { + /* A declared system orbit plus the real painted system radius is a hard geometric + seed. Include the radius even when a stale payload claims a tiny orbit. */ + if (hint > 0) { + hintedExtent = Math.max(hintedExtent, hint + extent); + hasHint = true; + } + observedExtent = Math.max(observedExtent, radial + extent); + /* A rigid system that is just clear of the black hole extends one full system radius + again on its far side. Reserve that geometry before caching the finite envelope. */ + horizonExtent = Math.max(horizonExtent, + anchorRadius + extent * 2 + GALAXY_BLACK_HOLE_EXCLUSION_PADDING); + } + }); + const configuredMinimum = Number.isFinite(Number(opts.farFieldMinimumRadius)) + ? Number(opts.farFieldMinimumRadius) : GALAXY_FAR_FIELD_MIN_RADIUS; + const minimumRadius = Math.max(1, configuredMinimum, horizonExtent); + const scale = Math.max(1, Number.isFinite(Number(opts.farFieldEnvelopeScale)) + ? Number(opts.farFieldEnvelopeScale) : GALAXY_FAR_FIELD_ENVELOPE_SCALE); + const explicitRadius = Number(opts.farFieldEnvelopeRadius); + const weakCached = galaxyFarFieldEnvelopeCache + ? galaxyFarFieldEnvelopeCache.get(anchor) : undefined; + const propCached = anchor.__galaxyFarFieldEnvelope; + const cachedRadius = Number( + Number.isFinite(Number(weakCached)) && Number(weakCached) > 0 ? weakCached : propCached + ); + /* Hints describe preferred carrier radii, not the capacity required after exact admission + packing. Never let a stale compact hint hide the collision-free observed extent. */ + const seedExtent = Math.max(minimumRadius, hintedExtent, observedExtent); + const envelopeRadius = Number.isFinite(explicitRadius) && explicitRadius > 0 + ? Math.max(minimumRadius, explicitRadius) + : Number.isFinite(cachedRadius) && cachedRadius > 0 ? cachedRadius + : Math.max(minimumRadius, seedExtent * scale); + if (!(Number.isFinite(cachedRadius) && cachedRadius > 0) + && !(Number.isFinite(explicitRadius) && explicitRadius > 0)) { + if (galaxyFarFieldEnvelopeCache) galaxyFarFieldEnvelopeCache.set(anchor, envelopeRadius); + try { + Object.defineProperty(anchor, '__galaxyFarFieldEnvelope', { + value: envelopeRadius, writable: false, configurable: true, enumerable: false, + }); + } catch (error) { /* Frozen compatibility nodes keep the WeakMap value above. */ } + } + const softFraction = Math.max(0, Math.min(1, Number.isFinite(Number(opts.farFieldSoftFraction)) + ? Number(opts.farFieldSoftFraction) : GALAXY_FAR_FIELD_SOFT_FRACTION)); + const requestedBand = Number(opts.farFieldSoftBand); + const softBand = Number.isFinite(requestedBand) && requestedBand > 0 + ? Math.min(envelopeRadius, requestedBand) + : Math.max(16, Math.min(32, envelopeRadius * (1 - softFraction))); + return { + anchor, centers, coreKey, bodyRadius, systemRadius, + envelopeRadius, softRadius: Math.max(0, envelopeRadius - softBand), + }; } - function idOf(value) { return value && typeof value === 'object' ? value.id : value; } - function nodeName(node) { - if (node === undefined || node === null) return ''; - if (typeof node !== 'object' && typeof node !== 'function') return String(node); - return String(node.name || node.label || node.id || ''); + + function applyGalaxyFarFieldGravity(nodes, options) { + const opts = options || {}; + const field = galaxyFarFieldEnvelope(nodes, opts); + const stats = { + anchorId: field.anchor ? field.anchor.id : null, + envelopeRadius: field.envelopeRadius, softRadius: field.softRadius, + acceleratedSystems: 0, acceleratedCoreNodes: 0, acceleratedFixedFollowers: 0, + maximumAcceleration: 0, + }; + if (!field.anchor || opts.includeFarFieldConfinement === false) return stats; + const acceleration = Math.max(0, Number.isFinite(Number(opts.farFieldAcceleration)) + ? Number(opts.farFieldAcceleration) : GALAXY_FAR_FIELD_ACCELERATION); + const accelerationCap = Math.max(0, Number.isFinite(Number(opts.farFieldMaxAcceleration)) + ? Number(opts.farFieldMaxAcceleration) : GALAXY_FAR_FIELD_MAX_ACCELERATION); + const band = Math.max(1e-9, field.envelopeRadius - field.softRadius); + const accelerate = (members, key, dx, dy, outerRadius, scope) => { + if (!(outerRadius > field.softRadius)) return; + const distance = Math.hypot(dx, dy); + let unitX = 1, unitY = 0; + if (distance > 1e-9) { + unitX = dx / distance; + unitY = dy / distance; + } else { + const angle = seededHash(0, 'far-field:' + String(key)) / 0x100000000 * Math.PI * 2; + unitX = Math.cos(angle); + unitY = Math.sin(angle); + } + const ratio = (outerRadius - field.softRadius) / band; + const magnitude = Math.min(acceleration, + accelerationCap > 0 ? accelerationCap : acceleration, + acceleration * galaxySmoothstep(ratio)); + if (!(magnitude > 0) || !Number.isFinite(magnitude)) return; + members.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) - unitX * magnitude; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) - unitY * magnitude; + }); + if (scope === 'core') stats.acceleratedCoreNodes += members.length; + else if (scope === 'fixed') stats.acceleratedFixedFollowers += members.length; + else stats.acceleratedSystems++; + stats.maximumAcceleration = Math.max(stats.maximumAcceleration, magnitude); + }; + field.centers.forEach(center => { + if (center.id === field.coreKey) { + center.nodes.forEach(node => { + if (node === field.anchor || node.id === opts.fixedNodeId) return; + const dx = node.x - field.anchor.x, dy = node.y - field.anchor.y; + accelerate([node], node.id, dx, dy, + Math.hypot(dx, dy) + field.bodyRadius(node), 'core'); + }); + return; + } + if (center.nodes.some(node => node.id === opts.fixedNodeId)) { + /* Preserve the cursor-owned source exactly, but do not make its companions immune to + the smooth outer well. They get their own radial sample until the hard cap is needed. */ + center.nodes.forEach(node => { + if (node.id === opts.fixedNodeId) return; + const dx = node.x - field.anchor.x, dy = node.y - field.anchor.y; + accelerate([node], node.id, dx, dy, + Math.hypot(dx, dy) + field.bodyRadius(node), 'fixed'); + }); + return; + } + const dx = center.x - field.anchor.x, dy = center.y - field.anchor.y; + accelerate(center.nodes, center.id, dx, dy, + Math.hypot(dx, dy) + field.systemRadius(center), 'system'); + }); + return stats; } - function showRelationLabel(label) { - return Boolean(label) && String(label).toLowerCase() !== 'co_occurs'; + + /* Exact outer counterpart to the black-hole contact. External systems are translated as + rigid bodies; anchor-community satellites are projected one at a time so the anchor never + moves. In either case only outward radial COM velocity is removed. Because this correction + moves inward, tangential speed is retained rather than increased (a cap must not inject + angular energy). An oversized system has a rare per-member fallback, since no rigid + translation can fit a radius larger than the finite envelope. */ + /* Boundary projections are deliberately bounded per integration slice. A just-released + pointer can leave a stretched system outside the cached annulus; completing that correction + in one member-wise teleport makes the first release frame visibly jump even though velocity + is capped. Track the budget across the alternating outer-boundary passes so the next fixed + slice can finish the projection without exceeding the 48-unit positional contract. */ + function reserveGalaxyBoundaryCorrection(options, members, requested, scope) { + const budget = options && options.__positionCorrectionBudget; + /* A direct annulus projection is the authoritative hard closure for pathological scenes; + only a feasible rigid carrier correction is deliberately spread across later slices when + no pointer owns the system. Fixed-node follower projections remain bounded during drag. */ + if (!budget || !Array.isArray(members) + || (scope !== 'rigid' && options.fixedNodeId == null) + || members.some(node => node && node.id === options.fixedNodeId)) return requested; + const limit = Number.isFinite(Number(budget.limit)) ? Math.max(0, Number(budget.limit)) : 48; + const used = budget.used || (budget.used = new Map()); + const remaining = members.reduce((available, node) => Math.min(available, + Math.max(0, limit - (used.get(node) || 0))), limit); + const applied = Math.min(Math.max(0, requested), remaining); + members.forEach(node => used.set(node, (used.get(node) || 0) + applied)); + return applied; } - /* Replace force-graph's round flow particles with a small directional glyph. The vendor - callback supplies the particle's current position and its link; the context already has - the resolved particle colour, so this only changes the silhouette and orientation. */ - function paintFlowArrow(x, y, link, ctx, globalScale) { - const source = link && link.source; - const target = link && link.target; - if (!source || !target || !Number.isFinite(source.x) || !Number.isFinite(target.x)) return; - const dx = target.x - source.x; - const dy = target.y - source.y; - if (!dx && !dy) return; - const size = 1 / Math.sqrt(Math.max(0.01, Number(globalScale) || 1)); - const angle = Math.atan2(dy, dx); - ctx.save(); - ctx.translate(x, y); - ctx.rotate(angle); - ctx.beginPath(); - ctx.moveTo(size * 0.55, 0); - ctx.lineTo(-size * 0.45, size * 0.32); - ctx.lineTo(-size * 0.45, -size * 0.32); - ctx.closePath(); - ctx.fill(); - ctx.restore(); + + function applyGalaxyFarFieldConfinement(nodes, options) { + const opts = options || {}; + const field = galaxyFarFieldEnvelope(nodes, opts); + const stats = { + anchorId: field.anchor ? field.anchor.id : null, + envelopeRadius: field.envelopeRadius, softRadius: field.softRadius, + acceleratedSystems: 0, boundedSystems: 0, boundedCoreNodes: 0, + boundedFixedSource: 0, boundedFixedFollowers: 0, boundedDeformedSystems: 0, + boundedOversizedNodes: 0, + correctedDistance: 0, maximumShift: 0, outwardVelocityRemoved: 0, + tangentialVelocityRemoved: 0, + annulus: { anchorId: null, innerCorrectedNodes: 0, outerCorrectedNodes: 0, + infeasibleNodes: 0 }, + }; + if (!field.anchor || opts.includeFarFieldConfinement === false) return stats; + const anchorX = field.anchor.x, anchorY = field.anchor.y; + const anchorVx = Number.isFinite(field.anchor.vx) ? field.anchor.vx : 0; + const anchorVy = Number.isFinite(field.anchor.vy) ? field.anchor.vy : 0; + const radial = (key, dx, dy) => { + const distance = Math.hypot(dx, dy); + if (distance > 1e-9) return { x: dx / distance, y: dy / distance, distance }; + const angle = seededHash(0, 'far-field-boundary:' + String(key)) + / 0x100000000 * Math.PI * 2; + return { x: Math.cos(angle), y: Math.sin(angle), distance: 0 }; + }; + const stabilizeVelocity = (members, unitX, unitY, oldDistance, newDistance) => { + let mass = 0, velocityX = 0, velocityY = 0; + members.forEach(node => { + const nodeMass = finitePositive(node.gravity_mass, 1, 1000); + mass += nodeMass; + velocityX += nodeMass * (Number.isFinite(node.vx) ? node.vx : 0); + velocityY += nodeMass * (Number.isFinite(node.vy) ? node.vy : 0); + }); + if (!(mass > 0)) return { outward: 0, tangential: 0 }; + const relativeX = velocityX / mass - anchorVx; + const relativeY = velocityY / mass - anchorVy; + const tangentX = -unitY, tangentY = unitX; + const radialSpeed = relativeX * unitX + relativeY * unitY; + const tangentSpeed = relativeX * tangentX + relativeY * tangentY; + const tangentScale = newDistance > 1e-9 + ? Math.max(0, Math.min(1, oldDistance / newDistance)) : 0; + const targetRadial = Math.min(0, radialSpeed); + const targetTangent = tangentSpeed * tangentScale; + const targetX = targetRadial * unitX + targetTangent * tangentX; + const targetY = targetRadial * unitY + targetTangent * tangentY; + const shiftX = targetX - relativeX, shiftY = targetY - relativeY; + members.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + shiftX; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + shiftY; + }); + return { + outward: Math.max(0, radialSpeed), + tangential: Math.abs(tangentSpeed) * (1 - tangentScale), + }; + }; + field.centers.forEach(center => { + if (center.id === field.coreKey) { + center.nodes.forEach(node => { + if (node === field.anchor || node.id === opts.fixedNodeId) return; + const unit = radial(node.id, node.x - anchorX, node.y - anchorY); + const targetDistance = Math.max(0, field.envelopeRadius - field.bodyRadius(node)); + const correction = unit.distance - targetDistance; + if (!(correction > 0)) return; + const appliedCorrection = reserveGalaxyBoundaryCorrection(opts, [node], correction); + if (!(appliedCorrection > 0)) return; + const boundedTargetDistance = unit.distance - appliedCorrection; + node.x = anchorX + unit.x * boundedTargetDistance; + node.y = anchorY + unit.y * boundedTargetDistance; + if (Number.isFinite(node.fx)) node.fx = node.x; + if (Number.isFinite(node.fy)) node.fy = node.y; + const velocity = stabilizeVelocity([node], unit.x, unit.y, + unit.distance, targetDistance); + stats.boundedCoreNodes++; + stats.correctedDistance += correction; + stats.maximumShift = Math.max(stats.maximumShift, correction); + stats.outwardVelocityRemoved += velocity.outward; + stats.tangentialVelocityRemoved += velocity.tangential; + }); + return; + } + if (center.nodes.some(node => node.id === opts.fixedNodeId)) { + /* Pointer coordinates are an input target, not permission to paint outside the finite + galaxy. Cap this stretched system one body at a time—including the source—so a long + outward hold cannot create release-only geometry. The next pointer event supplies a + fresh target; its final painted fx/fy remains on the outer annulus. */ + center.nodes.forEach(node => { + const unit = radial(node.id, node.x - anchorX, node.y - anchorY); + const targetDistance = Math.max(0, field.envelopeRadius - field.bodyRadius(node)); + const correction = unit.distance - targetDistance; + if (!(correction > 0)) return; + const appliedCorrection = reserveGalaxyBoundaryCorrection(opts, [node], correction); + if (!(appliedCorrection > 0)) return; + const boundedTargetDistance = unit.distance - appliedCorrection; + node.x = anchorX + unit.x * boundedTargetDistance; + node.y = anchorY + unit.y * boundedTargetDistance; + if (Number.isFinite(node.fx)) node.fx = node.x; + if (Number.isFinite(node.fy)) node.fy = node.y; + const velocity = stabilizeVelocity([node], unit.x, unit.y, + unit.distance, targetDistance); + if (node.id === opts.fixedNodeId) stats.boundedFixedSource++; + else stats.boundedFixedFollowers++; + stats.correctedDistance += correction; + stats.maximumShift = Math.max(stats.maximumShift, correction); + stats.outwardVelocityRemoved += velocity.outward; + stats.tangentialVelocityRemoved += velocity.tangential; + }); + return; + } + const unit = radial(center.id, center.x - anchorX, center.y - anchorY); + const radius = field.systemRadius(center); + /* A compact system fits inside R after one COM translation. A just-released drag can + leave a source at the cursor and companions at the cap, making q_s >= R; translating + that stretched geometry by its COM would throw the already-safe follower hundreds of + units. Resolve that impossible rigid fit member-by-member for this slice instead. */ + if (radius >= field.envelopeRadius - 1e-9) { + let bounded = false; + center.nodes.forEach(node => { + const memberUnit = radial(node.id, node.x - anchorX, node.y - anchorY); + const targetDistance = Math.max(0, field.envelopeRadius - field.bodyRadius(node)); + const correction = memberUnit.distance - targetDistance; + if (!(correction > 1e-9)) return; + const appliedCorrection = reserveGalaxyBoundaryCorrection(opts, [node], correction); + if (!(appliedCorrection > 0)) return; + const boundedTargetDistance = memberUnit.distance - appliedCorrection; + node.x = anchorX + memberUnit.x * boundedTargetDistance; + node.y = anchorY + memberUnit.y * boundedTargetDistance; + if (Number.isFinite(node.fx)) node.fx = node.x; + if (Number.isFinite(node.fy)) node.fy = node.y; + const velocity = stabilizeVelocity([node], memberUnit.x, memberUnit.y, + memberUnit.distance, targetDistance); + stats.boundedOversizedNodes++; + stats.correctedDistance += correction; + stats.maximumShift = Math.max(stats.maximumShift, correction); + stats.outwardVelocityRemoved += velocity.outward; + stats.tangentialVelocityRemoved += velocity.tangential; + bounded = true; + }); + if (bounded) stats.boundedDeformedSystems++; + return; + } + const targetDistance = Math.max(0, field.envelopeRadius - radius); + const correction = unit.distance - targetDistance; + if (!(correction > 0)) return; + const appliedCorrection = reserveGalaxyBoundaryCorrection( + opts, center.nodes, correction, 'rigid' + ); + if (!(appliedCorrection > 0)) return; + const shiftX = -unit.x * appliedCorrection, shiftY = -unit.y * appliedCorrection; + center.nodes.forEach(node => { + node.x += shiftX; + node.y += shiftY; + if (Number.isFinite(node.fx)) node.fx += shiftX; + if (Number.isFinite(node.fy)) node.fy += shiftY; + }); + const velocity = stabilizeVelocity(center.nodes, unit.x, unit.y, + unit.distance, targetDistance); + stats.boundedSystems++; + stats.correctedDistance += correction; + stats.maximumShift = Math.max(stats.maximumShift, correction); + stats.outwardVelocityRemoved += velocity.outward; + stats.tangentialVelocityRemoved += velocity.tangential; + }); + /* The COM/system-radius projection above is exact whenever q_s <= R. If an extreme late + local deformation has made q_s > R, fitting it rigidly is mathematically impossible. + Finish with a member-level cap so the public invariant remains every free painted node + lies inside the cached envelope; normal systems never enter this branch. */ + field.centers.forEach(center => { + center.nodes.forEach(node => { + if (node === field.anchor || node.id === opts.fixedNodeId) return; + const unit = radial(node.id, node.x - anchorX, node.y - anchorY); + const targetDistance = Math.max(0, field.envelopeRadius - field.bodyRadius(node)); + const correction = unit.distance - targetDistance; + if (!(correction > 1e-9)) return; + const appliedCorrection = reserveGalaxyBoundaryCorrection(opts, [node], correction); + if (!(appliedCorrection > 0)) return; + const boundedTargetDistance = unit.distance - appliedCorrection; + node.x = anchorX + unit.x * boundedTargetDistance; + node.y = anchorY + unit.y * boundedTargetDistance; + if (Number.isFinite(node.fx)) node.fx = node.x; + if (Number.isFinite(node.fy)) node.fy = node.y; + const velocity = stabilizeVelocity([node], unit.x, unit.y, + unit.distance, targetDistance); + stats.boundedOversizedNodes++; + stats.correctedDistance += correction; + stats.maximumShift = Math.max(stats.maximumShift, correction); + stats.outwardVelocityRemoved += velocity.outward; + stats.tangentialVelocityRemoved += velocity.tangential; + }); + }); + return stats; } - /* Keep node geometry in the same compact world-space range as the Classic/Ledger renderer. - The previous overview formula used the full size-slider value plus a normalized degree - bonus, which made a seven-node workspace occupy only a small simulation area while each - node still had a dense-graph radius. `zoomToFit()` then magnified those radii into large - discs. Material style must not change geometry; it only changes the painted surface. */ - function graphNodeRadius(node, base, metric) { - const size = Number.isFinite(+base) && +base > 0 ? +base : 3; - if (node && node.cluster) { - const members = Math.max(1, Number(node.members) || 1); - const radius = size * 0.45 * (1.4 + Math.min(3, Math.sqrt(members) * 0.7)); - return Math.max(2, Math.min(size * 2.7, radius)); + + /* Last coordinate check after alternating the two system-level contacts. A normal scene is + already feasible (the cached envelope reserved its horizon geometry), so this is a no-op. + It exists for a pathological late deformation whose system radius grew beyond that cache: + individual members are then the only way to satisfy both painted edges at once. A dragged + source is likewise clamped here: its pointer target is preserved as input, while the final + painted coordinate always remains inside the finite annulus. */ + function applyGalaxyAnnularBounds(nodes, options) { + const opts = options || {}; + const field = galaxyFarFieldEnvelope(nodes, opts); + const stats = { anchorId: field.anchor ? field.anchor.id : null, + innerCorrectedNodes: 0, outerCorrectedNodes: 0, infeasibleNodes: 0 }; + if (!field.anchor || opts.includeFarFieldConfinement === false) return stats; + const anchorX = field.anchor.x, anchorY = field.anchor.y; + const anchorRadius = field.bodyRadius(field.anchor); + const padding = Math.max(0, Number.isFinite(Number(opts.blackHoleExclusionPadding)) + ? Number(opts.blackHoleExclusionPadding) : GALAXY_BLACK_HOLE_EXCLUSION_PADDING); + field.centers.forEach(center => center.nodes.forEach(node => { + if (node === field.anchor) return; + const dx = node.x - anchorX, dy = node.y - anchorY; + const distance = Math.hypot(dx, dy); + const radius = field.bodyRadius(node); + const lower = anchorRadius + radius + padding; + const upper = field.envelopeRadius - radius; + if (!(upper >= lower)) { + /* This can only arise from an externally forced, mathematically impossible geometry. + Keep the black-hole edge authoritative rather than emitting a non-finite position. */ + stats.infeasibleNodes++; + return; + } + const target = Math.max(lower, Math.min(upper, distance)); + if (!(Math.abs(target - distance) > 1e-9)) return; + let unitX = 1, unitY = 0; + if (distance > 1e-9) { + unitX = dx / distance; + unitY = dy / distance; + } else { + const angle = seededHash(0, 'galaxy-annulus:' + String(node.id)) + / 0x100000000 * Math.PI * 2; + unitX = Math.cos(angle); + unitY = Math.sin(angle); + } + const requestedCorrection = Math.abs(target - distance); + const appliedCorrection = reserveGalaxyBoundaryCorrection( + opts, [node], requestedCorrection + ); + if (!(appliedCorrection > 0)) return; + const boundedTarget = target > distance + ? distance + appliedCorrection : distance - appliedCorrection; + node.x = anchorX + unitX * boundedTarget; + node.y = anchorY + unitY * boundedTarget; + if (Number.isFinite(node.fx)) node.fx = node.x; + if (Number.isFinite(node.fy)) node.fy = node.y; + const vx = (Number.isFinite(node.vx) ? node.vx : 0) + - (Number.isFinite(field.anchor.vx) ? field.anchor.vx : 0); + const vy = (Number.isFinite(node.vy) ? node.vy : 0) + - (Number.isFinite(field.anchor.vy) ? field.anchor.vy : 0); + const tangentX = -unitY, tangentY = unitX; + const radialSpeed = vx * unitX + vy * unitY; + const tangentSpeed = vx * tangentX + vy * tangentY; + const tangentScale = boundedTarget > 1e-9 + ? Math.max(0, Math.min(1, distance / boundedTarget)) : 0; + const targetRadial = boundedTarget > distance ? Math.max(0, radialSpeed) + : Math.min(0, radialSpeed); + node.vx = (Number.isFinite(field.anchor.vx) ? field.anchor.vx : 0) + + targetRadial * unitX + tangentSpeed * tangentScale * tangentX; + node.vy = (Number.isFinite(field.anchor.vy) ? field.anchor.vy : 0) + + targetRadial * unitY + tangentSpeed * tangentScale * tangentY; + if (target > distance) stats.innerCorrectedNodes++; + else stats.outerCorrectedNodes++; + })); + return stats; + } + + /* One deterministic velocity-Verlet / leapfrog step. The time step is intentionally + dimensionless: the force constants were calibrated in force-graph tick units, so a + value of one is the physically equivalent fixed replacement for one former D3 tick. + A caller can substep at a stable wall-clock cadence without ever scaling force by D3 + alpha. Collision impulses happen after the second kick and the damping is a property + of this integrator, not a side effect of D3's simulation. */ + /* Keep the slider responsive after gravity has integrated a few frames. Seeding alone changes + the initial tangent, but the natural field would otherwise pull every orbit back toward its + unslaved angular rate. This controller changes only tangential velocity: radial gravity, + local geometry, and the cached outer envelope remain independent of the speed control. */ + function applyGalaxyOrbitalSpeedControl(nodes, options) { + const opts = options || {}; + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const field = galaxyBlackHoleField(bodies, opts); + const globalAnchor = field.anchor && field.anchor.anchor_role === 'global' ? field.anchor : null; + const stats = { systems: 0, localSatellites: 0, multiplier: orbitalSpeed }; + /* The midpoint is the shipped orbit rate. Leave the integrator's native velocity phase + untouched there; repeatedly correcting it introduces radial energy in the gravity-floor + path even though the user has not selected a speed adjustment. A zeroed compatibility + scene still needs the midpoint's ordinary seed velocity, so only bypass a neutral pass + after a meaningful phase already exists. */ + const neutralPhase = Math.abs(orbitalSpeed - 1) <= 1e-9 + && bodies.some(node => Math.hypot( + Number.isFinite(node.vx) ? node.vx : 0, + Number.isFinite(node.vy) ? node.vy : 0, + ) > 1e-8); + if (neutralPhase + || !globalAnchor || !(field.gravitationalConstant > 0)) return stats; + const direction = (seededHash(opts.layoutSeed, 'galaxy-spin') & 1) ? 1 : -1; + const centralSoftening = Math.max(0.1, + Number(opts.centralSoftening) || Number(opts.softening) || 40); + const globalTargetSpeed = radius => { + const coreDenominator = Math.pow(radius * radius + centralSoftening * centralSoftening, 1.5); + const haloDenominator = Math.pow(radius * radius + field.haloScale * field.haloScale, 1.5); + const omega = Math.sqrt(Math.max(0, field.gravitationalConstant * ( + field.coreMass / coreDenominator + + (field.haloMass > 0 ? field.haloMass / haloDenominator : 0) + ))); + return Math.min(GALAXY_SYSTEM_ORBIT_SEED_SPEED_LIMIT * orbitalSpeed, + omega * radius * orbitalSpeed); + }; + const supportCarrier = (members, carrier) => { + if (!carrier || carrier === globalAnchor) return; + const dx = carrier.x - globalAnchor.x, dy = carrier.y - globalAnchor.y; + const radius = Math.hypot(dx, dy); + if (!(radius > 1e-9)) return; + const relativeVx = (Number.isFinite(carrier.vx) ? carrier.vx : 0) + - (Number.isFinite(globalAnchor.vx) ? globalAnchor.vx : 0); + const relativeVy = (Number.isFinite(carrier.vy) ? carrier.vy : 0) + - (Number.isFinite(globalAnchor.vy) ? globalAnchor.vy : 0); + const unitX = dx / radius, unitY = dy / radius; + const tangentX = -unitY, tangentY = unitX; + const currentTangent = relativeVx * tangentX + relativeVy * tangentY; + const sign = Math.sign(currentTangent) || direction; + const desiredTangent = globalTargetSpeed(radius) * sign; + const delta = desiredTangent - currentTangent; + members.forEach(node => { + if (node.id === opts.fixedNodeId) return; + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + tangentX * delta; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + tangentY * delta; + }); + stats.systems++; + }; + const centers = galaxyOrbitGroups(bodies); + const coreKey = String(globalAnchor.id); + centers.forEach(center => { + const members = center.nodes; + const carrier = galaxySystemAnchor(members) || members[0]; + if (center.id !== coreKey) supportCarrier(members, carrier); + const localAnchor = carrier && carrier.anchor_role === 'global' ? globalAnchor : carrier; + if (!localAnchor) return; + const byId = new Map(members.map(node => [String(node.id), node])); + members.forEach(node => { + if (node === localAnchor || node.id === opts.fixedNodeId) return; + const parent = galaxyLocalOrbitParent(node, members, localAnchor, byId) + || localAnchor; + const dx = node.x - parent.x, dy = node.y - parent.y; + const radius = Math.hypot(dx, dy); + if (!(radius > 1e-9)) return; + const localGravityMultiplier = galaxyLocalGravityMultiplier(parent, opts); + const localGravity = galaxySystemGravityConstant(parent, opts.gravity) + * localGravityMultiplier; + const localAccelerationCap = defaultGalaxySystemAccelerationCap(parent, opts.gravity) + * Math.max(0.25, localGravityMultiplier); + const anchorMass = finitePositive(parent.gravity_mass, 1, 1000); + const denominator = Math.pow(radius * radius + + Math.max(0.1, Number(opts.softening) || 8) ** 2, 1.5); + const rawAcceleration = denominator > 0 + ? localGravity * anchorMass * radius / denominator : 0; + const acceleration = Math.min(localAccelerationCap, rawAcceleration); + const baseSpeed = Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, + Math.sqrt(Math.max(0, acceleration * radius))); + const unitX = dx / radius, unitY = dy / radius; + const tangentX = -unitY, tangentY = unitX; + const relativeVx = (Number.isFinite(node.vx) ? node.vx : 0) + - (Number.isFinite(parent.vx) ? parent.vx : 0); + const relativeVy = (Number.isFinite(node.vy) ? node.vy : 0) + - (Number.isFinite(parent.vy) ? parent.vy : 0); + const currentTangent = relativeVx * tangentX + relativeVy * tangentY; + const sign = Math.sign(currentTangent) + || ((seededHash(opts.layoutSeed, 'system:' + String(parent.id)) & 1) ? 1 : -1); + const delta = baseSpeed * orbitalSpeed * sign - currentTangent; + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + tangentX * delta; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + tangentY * delta; + stats.localSatellites++; + }); + }); + return stats; + } + + function integrateGalaxyLeapfrog(nodes, links, bridges, options) { + // kick-drift-kick: sample at x(t), drift from the half kick, then close at x(t + dt). + /* Boundary projections are allowed to converge over several fixed slices, but one slice + must not visibly teleport a released cluster. Keep the budget private to this call so + every alternating inner/outer projection shares the same positional limit. */ + const opts = Object.assign({}, options || {}, { + __positionCorrectionBudget: { limit: 48, used: new Map() }, + }); + /* Pointer coordinates are already expressed in the currently rendered chart frame. Do + not translate that frame underneath an active drag: it remains the source target while + every other body integrates around it. The final inner/outer annulus may clamp the + painted source edge; once released, the next ordinary step may recenter normally. */ + const requestedFixedNode = opts.fixedNodeId == null ? null : (nodes || []).find( + node => node && !node.ghost && node.id === opts.fixedNodeId + && Number.isFinite(node.x) && Number.isFinite(node.y) + ) || null; + const anchorFrame = opts.central !== false || (nodes || []).some( + node => node && !node.ghost && node.anchor_role === 'global' + ); + const recenterFrame = anchorFrame && !requestedFixedNode; + if (recenterFrame) recenterGalaxyOnAnchor(nodes); + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const fixedNode = requestedFixedNode && bodies.includes(requestedFixedNode) + ? requestedFixedNode : null; + const fixedPhase = fixedNode ? { x: fixedNode.x, y: fixedNode.y } : null; + const restoreFixedNode = () => { + if (!fixedNode || !fixedPhase) return; + fixedNode.x = fixedPhase.x; + fixedNode.y = fixedPhase.y; + fixedNode.vx = 0; + fixedNode.vy = 0; + }; + const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); + const velocityDecay = Math.max(0, Math.min(0.99, + Number.isFinite(Number(opts.velocityDecay)) ? Number(opts.velocityDecay) : 0.002)); + const speedLimit = Math.max(0.01, Number(opts.speedLimit) || MAX_NODE_SPEED); + if (!bodies.length) return { bodies: 0, collisions: 0, kinetic: 0 }; + const horizonEnabled = anchorFrame && opts.includeBlackHoleExclusion !== false; + const projectBlackHoleHorizon = () => horizonEnabled + ? applyGalaxyBlackHoleExclusion(bodies, { + padding: opts.blackHoleExclusionPadding, + fixedNodeId: opts.fixedNodeId, + }) + : { + anchorId: null, contacts: 0, systems: 0, coreNodes: 0, fixedSystemNodes: 0, + repelledNodes: 0, + correctedDistance: 0, maximumShift: 0, inwardVelocityRemoved: 0, + tangentialVelocityRemoved: 0, + minimumClearance: null, + }; + /* Fresh payloads and pointer updates may begin a slice inside the boundary. Repair that + phase before either acceleration sample or the convergence track observes it. */ + const initialHorizon = projectBlackHoleHorizon(); + const precomputedCenters = communityCenters(bodies); + /* System-envelope packing supersedes the legacy monotone inward projection. Running both + constraints in one slice makes them exact opponents: packing clears two systems, then + convergence contracts them back through one another. Black-hole gravity still owns the + radial orbit; this disables only the artificial per-slice carrier teleport. */ + const convergenceAnchor = opts.inwardConvergence === true + ? galaxyGlobalAnchor(bodies) : null; + const initialRadii = convergenceAnchor ? new Map( + [...precomputedCenters.entries()].map(([id, center]) => [id, { + radius: Math.hypot(center.x - convergenceAnchor.x, + center.y - convergenceAnchor.y), + }]) + ) : null; + + const start = galaxyAccelerations(bodies, links, bridges, opts); + bodies.forEach(node => { + if (node === fixedNode) { + node.vx = 0; + node.vy = 0; + return; + } + const acceleration = start.get(node) || { ax: 0, ay: 0 }; + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + acceleration.ax * timestep * 0.5; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + acceleration.ay * timestep * 0.5; + node.x += node.vx * timestep; + node.y += node.vy * timestep; + }); + /* Clamp before the second force sample so a tunnelling body never contributes an + acceleration from inside the painted black-hole disc. */ + const driftHorizon = projectBlackHoleHorizon(); + const end = galaxyAccelerations(bodies, links, bridges, opts); + bodies.forEach(node => { + if (node === fixedNode) return; + const acceleration = end.get(node) || { ax: 0, ay: 0 }; + node.vx += acceleration.ax * timestep * 0.5; + node.vy += acceleration.ay * timestep * 0.5; + }); + const collision = opts.includeCollisions === false ? { overlaps: 0 } + : applyGalaxyCollisions(bodies, { + padding: opts.collisionPadding, + strength: opts.collisionStrength, + iterations: opts.collisionIterations, + }); + /* Decay is expressed per full fixed tick, then exponentiated for substeps. This avoids + changing the physical settling rate merely because a slow frame consumed two steps. */ + const dampingFactor = Math.pow(1 - velocityDecay, timestep); + let maximumSpeed = 0; + bodies.forEach(node => { + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) * dampingFactor; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) * dampingFactor; + }); + const eventHorizonDecay = opts.includeSpacetime !== true + ? { anchorId: null, systems: 0, nodes: 0, maximumWarp: 0, + maximumVelocityRemoved: 0 } + : applyGalaxyEventHorizonDecay(bodies, opts); + /* Work in the chart's black-hole frame. Translation by the dominant node's phase changes + no relative orbit, while guaranteeing the visual/physical anchor is exactly 0/0/0/0. */ + if (recenterFrame) recenterGalaxyOnAnchor(nodes); + const relationConstraint = opts.includeRelations === true + ? applyGalaxyRelationDistanceConstraints(bodies, links || [], { + orbitScale: opts.orbitScale, + /* Standalone callers historically supplied one relation multiplier. The live engine + splits spring and PBD calibration, but the older option remains the fallback. */ + strengthMultiplier: Number.isFinite(Number(opts.relationConstraintStrengthMultiplier)) + ? Number(opts.relationConstraintStrengthMultiplier) + : opts.relationStrengthMultiplier, + responseMultiplier: opts.relationConstraintResponseMultiplier, + wallClockSeconds: opts.wallClockSeconds, + rate: opts.relationConstraintRate, + maxCorrection: opts.relationConstraintMaxCorrection, + padding: opts.relationPadding, + fixedNodeId: opts.fixedNodeId, + skipFixedNodeRelations: !!opts.dragSource, + skipSystemAnchorRelations: opts.skipSystemAnchorRelations === true, + skipOrbitalSystemRelations: opts.skipOrbitalSystemRelations === true, + }) + : { applied: 0, maximumError: 0, correctedDistance: 0 }; + /* Orbital separation is a dissipative close-range pressure, not negative gravity. It uses + full pressure inside a solar system and a weak contact-only pressure across systems, + preserves evidence-mass momentum, and removes closing energy instead of injecting a + repulsive slingshot. Applying it after Link constraints makes separation the final local + safety envelope before the strict black-hole horizon pass. */ + const orbitalSeparation = opts.includeOrbitalSeparation === true + ? applyGalaxyOrbitalSeparation(bodies, { + padding: opts.orbitalSeparationPadding, + strength: opts.orbitalSeparationStrength, + crossCommunityPadding: opts.crossCommunitySeparationPadding, + crossCommunityStrength: opts.crossCommunitySeparationStrength, + maxCorrection: opts.orbitalSeparationMaxCorrection, + maxVelocityCorrection: opts.orbitalSeparationMaxVelocityCorrection, + preserveTangentialVelocity: opts.preserveLocalTangentialVelocity === true, + preserveSystemRadii: opts.preserveSystemRadii === true, + skipSystemAnchorPairs: opts.skipSystemAnchorPairs === true, + fixedNodeId: opts.fixedNodeId, + }) + : { bodies: bodies.length, pairs: 0, overlaps: 0, cells: 0, correctionDistance: 0 }; + /* Leapfrog acceleration alone is intentionally gentle at the tiny live timestep. While a + pointer owns a mass, add one bounded wall-clock projection from that same softened field + so nearby unlinked bodies visibly follow instead of appearing frozen. This runs once per + physics slice (never per pointer event), injects no velocity, and remains inverse-square + and evidence-mass weighted. */ + const dragPositionGravity = opts.dragSource ? applyDraggedNodeGravity( + opts.dragSource, opts.dragFollowers || [], { + gravity: opts.gravity, + gravityMultiplier: GALAXY_DRAG_GRAVITY_MULTIPLIER, + softening: opts.dragSoftening, + duration: Number.isFinite(Number(opts.wallClockSeconds)) + ? Number(opts.wallClockSeconds) : GALAXY_FRAME_INTERVAL_MS / 1000, + maximumPull: GALAXY_DRAG_POSITION_MAX_PULL, + maximumImpulse: 0, + applyImpulse: false, + linkSetting: opts.linkSetting, + padding: opts.relationPadding, + } + ) : { applied: 0, maximumAcceleration: 0, maximumPull: 0 }; + const systemVelocity = stabilizeGalaxySystemVelocities(bodies, { + limit: opts.localRelativeSpeedLimit, + absoluteLimit: speedLimit, + fixedNodeId: opts.fixedNodeId, + }); + /* Restore the pointer target before the final contacts. The strict horizon and cached outer + annulus then clamp only an actual penetration/escape, so dragging cannot paint a node + through either boundary or leave a release-only stretched system. */ + restoreFixedNode(); + /* Relation PBD, local/cross-system contact and drag are all late positional corrections. + Project the solar-system COM track only after those layers, otherwise a constraint can + undo the monotone black-hole fall during the same slice. Pointer-owned systems remain + excluded by applyGalaxyInwardConvergence, and all strict painted boundaries still close + after this translation. */ + const convergence = convergenceAnchor + ? applyGalaxyInwardConvergence(bodies, convergenceAnchor, initialRadii, opts) + : { applied: 0, outwardCandidates: 0, overrides: 0, factor: 1 }; + /* Resolve at the carrier-frame level after local/link/convergence corrections. One + conservative circle represents the complete painted solar system, so a correction is a + rigid translation and can never stretch a planet away from its star. */ + const systemPackingPasses = []; + if (opts.includeSystemPacking === true) { + systemPackingPasses.push(applyGalaxySystemPacking(bodies, Object.assign({}, opts, { + gap: opts.systemPackingGap, + strength: opts.systemPackingStrength, + maxCorrection: opts.systemPackingMaxCorrection, + fixedNodeId: opts.fixedNodeId, + }))); } - const normalized = Math.max(0, Math.min(1, Number(metric) || 0)); - const radius = size * 0.45 * (0.55 + Math.min(1.6, normalized * 1.9)); - return Math.max(0.8, Math.min(size * 1.1, radius)); + /* Relations, cross-system contact and drag can all add a finite late displacement. Alternate + the strict inner and outer contacts, then verify their annulus member-by-member only for + a pathological oversized system that no rigid translation can satisfy. */ + const preOuterHorizon = projectBlackHoleHorizon(); + const farFieldConfinement = opts.includeFarFieldConfinement === false + ? { anchorId: null, envelopeRadius: 0, softRadius: 0, + acceleratedSystems: 0, boundedSystems: 0, boundedCoreNodes: 0, + boundedFixedSource: 0, boundedFixedFollowers: 0, boundedDeformedSystems: 0, + boundedOversizedNodes: 0, + correctedDistance: 0, maximumShift: 0, outwardVelocityRemoved: 0, + tangentialVelocityRemoved: 0 } + : applyGalaxyFarFieldConfinement(bodies, opts); + const outerHorizon = projectBlackHoleHorizon(); + const initialAnnulus = opts.includeFarFieldConfinement === false + ? { anchorId: null, innerCorrectedNodes: 0, outerCorrectedNodes: 0, infeasibleNodes: 0 } + : applyGalaxyAnnularBounds(bodies, opts); + /* Stellar contact and the member-wise outer annulus are coupled constraints: clamping an + outer planet can place it back through its star. Alternate the mass-balanced stellar + projection with the strict black-hole/annulus closures until a read-only audit confirms + the final painted phase satisfies all three. Normal scenes exit after one pass; the + bounded loop handles a late oversized or pointer-deformed system without feedback kicks. */ + const stellarPasses = [], closureConfinements = [], closureHorizons = []; + const annulusPasses = [initialAnnulus]; + let stellarAudit = galaxySystemAnchorClearance(bodies, { + padding: opts.systemAnchorExclusionPadding, + }); + let boundaryIterations = 0; + for (let iteration = 0; iteration < 24; iteration++) { + stellarPasses.push(applyGalaxySystemAnchorExclusion(bodies, { + padding: opts.systemAnchorExclusionPadding, + fixedNodeId: opts.fixedNodeId, + })); + /* Re-run the system-level outer solve before falling back to individual members. A + feasible external system is translated inward as one rigid body, preserving the + repaired star/planet separation and avoiding the slow mass-ratio recurrence produced + by repeatedly clamping only the light planet. */ + if (opts.includeFarFieldConfinement !== false) { + closureConfinements.push(applyGalaxyFarFieldConfinement(bodies, opts)); + } + closureHorizons.push(projectBlackHoleHorizon()); + annulusPasses.push(opts.includeFarFieldConfinement === false + ? { anchorId: null, innerCorrectedNodes: 0, outerCorrectedNodes: 0, + infeasibleNodes: 0 } + : applyGalaxyAnnularBounds(bodies, opts)); + stellarAudit = galaxySystemAnchorClearance(bodies, { + padding: opts.systemAnchorExclusionPadding, + }); + boundaryIterations = iteration + 1; + if (stellarAudit.minimumClearance === null + || stellarAudit.minimumClearance >= -1e-9) break; + } + /* Stellar exclusion moves only a penetrating planet in the star frame and can therefore + shift the evidence-mass COM by a few ulps after the controlled inward projection. Restore + the exact shared carrier track once after local closure, then reassert only the global + annulus. The rigid translation cannot reopen a star/planet overlap. */ + const closureConvergence = convergenceAnchor + ? applyGalaxyInwardConvergence(bodies, convergenceAnchor, initialRadii, opts) + : { applied: 0, outwardCandidates: 0, overrides: 0, factor: 1 }; + convergence.closureApplied = closureConvergence.applied; + if (opts.includeSystemPacking === true) { + systemPackingPasses.push(applyGalaxySystemPacking(bodies, Object.assign({}, opts, { + gap: opts.systemPackingGap, + strength: opts.systemPackingStrength, + maxCorrection: opts.systemPackingMaxCorrection, + fixedNodeId: opts.fixedNodeId, + }))); + } + if (opts.includeFarFieldConfinement !== false) { + closureConfinements.push(applyGalaxyFarFieldConfinement(bodies, opts)); + } + closureHorizons.push(projectBlackHoleHorizon()); + annulusPasses.push(opts.includeFarFieldConfinement === false + ? { anchorId: null, innerCorrectedNodes: 0, outerCorrectedNodes: 0, + infeasibleNodes: 0 } + : applyGalaxyAnnularBounds(bodies, opts)); + /* The strict BH/outer closures above can translate a carrier after the previous packing + pass. Close once more at system-envelope level, then reassert only the global boundaries. + This alternating projection is bounded and keeps local geometry rigid throughout. */ + if (opts.includeSystemPacking === true) { + /* Earlier response passes stay bounded. The final painted phase must satisfy its hard + envelope invariant in this same slice: leaving one deep penetration to future frames + makes the systems visibly stacked and repeats the collision work indefinitely. This + exact carrier translation changes no member-relative position or velocity, so it adds + no kinetic energy; pointer-owned systems remain fixed and any genuinely infeasible + fixed/boundary conflict is reported rather than moved. */ + const packingClosureLimit = Math.max(1, + Math.min(256, galaxySystemEnvelopes(bodies, opts).length + 1)); + for (let passIndex = 0; passIndex < packingClosureLimit; passIndex++) { + const packingPass = applyGalaxySystemPacking(bodies, Object.assign({}, opts, { + gap: opts.systemPackingGap, + strength: 1, + maxCorrection: Infinity, + fixedNodeId: opts.fixedNodeId, + })); + systemPackingPasses.push(packingPass); + if (!packingPass.remainingOverlaps || packingPass.infeasiblePairs) break; + } + } + /* The annulus can clamp an individual member after the normal stellar closure. Reassert + the local painted boundary as the final positional constraint so the last frame cannot + leave a planet intersecting its immediate carrier. */ + const finalStellarPass = applyGalaxySystemAnchorExclusion(bodies, { + padding: opts.systemAnchorExclusionPadding, + fixedNodeId: opts.fixedNodeId, + }); + stellarPasses.push(finalStellarPass); + stellarAudit = galaxySystemAnchorClearance(bodies, { + padding: opts.systemAnchorExclusionPadding, + }); + const combinedSystemAnchorExclusion = combineGalaxySystemAnchorExclusions(stellarPasses); + const systemPacking = { + systems: systemPackingPasses.reduce((maximum, pass) => Math.max(maximum, + pass.systems || 0), 0), + pairs: systemPackingPasses.reduce((sum, pass) => sum + (pass.pairs || 0), 0), + overlaps: systemPackingPasses.reduce((sum, pass) => sum + (pass.overlaps || 0), 0), + adjustedSystems: systemPackingPasses.reduce((sum, pass) => + sum + (pass.adjustedSystems || 0), 0), + correctionDistance: systemPackingPasses.reduce((sum, pass) => + sum + (pass.correctionDistance || 0), 0), + maximumShift: systemPackingPasses.reduce((maximum, pass) => Math.max(maximum, + pass.maximumShift || 0), 0), + remainingOverlaps: systemPackingPasses.length + ? systemPackingPasses[systemPackingPasses.length - 1].remainingOverlaps || 0 : 0, + infeasiblePairs: systemPackingPasses.reduce((sum, pass) => + sum + (pass.infeasiblePairs || 0), 0), + boundaryViolations: systemPackingPasses.length + ? systemPackingPasses[systemPackingPasses.length - 1].boundaryViolations || 0 : 0, + minimumBlackHoleClearance: systemPackingPasses.length + ? systemPackingPasses[systemPackingPasses.length - 1].minimumBlackHoleClearance : null, + minimumOuterClearance: systemPackingPasses.length + ? systemPackingPasses[systemPackingPasses.length - 1].minimumOuterClearance : null, + envelopeRadius: systemPackingPasses.length + ? systemPackingPasses[systemPackingPasses.length - 1].envelopeRadius || 0 : 0, + gap: systemPackingPasses.length + ? systemPackingPasses[systemPackingPasses.length - 1].gap || 0 : 0, + }; + const rawFinalStellarClearance = stellarAudit.minimumClearance; + const systemAnchorExclusion = Object.assign(combinedSystemAnchorExclusion, { + boundaryIterations, + rawMinimumClearance: rawFinalStellarClearance, + minimumClearance: rawFinalStellarClearance !== null + && rawFinalStellarClearance >= -1e-9 ? Math.max(0, rawFinalStellarClearance) + : rawFinalStellarClearance, + }); + const finalHorizon = closureHorizons[closureHorizons.length - 1]; + const annulus = { + anchorId: annulusPasses.map(pass => pass.anchorId).find(Boolean) || null, + innerCorrectedNodes: annulusPasses.reduce( + (sum, pass) => sum + (pass.innerCorrectedNodes || 0), 0), + outerCorrectedNodes: annulusPasses.reduce( + (sum, pass) => sum + (pass.outerCorrectedNodes || 0), 0), + infeasibleNodes: annulusPasses.reduce( + (sum, pass) => sum + (pass.infeasibleNodes || 0), 0), + }; + const confinementCountFields = [ + 'acceleratedSystems', 'boundedSystems', 'boundedCoreNodes', + 'boundedFixedSource', 'boundedFixedFollowers', 'boundedDeformedSystems', + 'boundedOversizedNodes', + ]; + closureConfinements.forEach(pass => { + confinementCountFields.forEach(field => { + farFieldConfinement[field] = (farFieldConfinement[field] || 0) + (pass[field] || 0); + }); + farFieldConfinement.correctedDistance += pass.correctedDistance || 0; + farFieldConfinement.maximumShift = Math.max( + farFieldConfinement.maximumShift || 0, pass.maximumShift || 0); + farFieldConfinement.outwardVelocityRemoved += pass.outwardVelocityRemoved || 0; + farFieldConfinement.tangentialVelocityRemoved += pass.tangentialVelocityRemoved || 0; + }); + farFieldConfinement.annulus = annulus; + const horizonPasses = [ + initialHorizon, driftHorizon, preOuterHorizon, outerHorizon, ...closureHorizons, + ]; + const blackHoleExclusion = { + anchorId: finalHorizon.anchorId || driftHorizon.anchorId || initialHorizon.anchorId, + contacts: horizonPasses.reduce((sum, pass) => sum + pass.contacts, 0), + systems: horizonPasses.reduce((sum, pass) => sum + pass.systems, 0), + coreNodes: horizonPasses.reduce((sum, pass) => sum + pass.coreNodes, 0), + fixedSystemNodes: horizonPasses.reduce( + (sum, pass) => sum + (pass.fixedSystemNodes || 0), 0 + ), + repelledNodes: horizonPasses.reduce((sum, pass) => sum + pass.repelledNodes, 0), + correctedDistance: horizonPasses.reduce( + (sum, pass) => sum + pass.correctedDistance, 0 + ), + maximumShift: Math.max(...horizonPasses.map(pass => pass.maximumShift)), + inwardVelocityRemoved: horizonPasses.reduce( + (sum, pass) => sum + pass.inwardVelocityRemoved, 0 + ), + tangentialVelocityRemoved: horizonPasses.reduce( + (sum, pass) => sum + pass.tangentialVelocityRemoved, 0 + ), + minimumClearance: finalHorizon.minimumClearance, + }; + /* Constraint projection can rotate a carrier's position without rotating its velocity. + Reconcile the final carrier tangent once, after packing and annulus closure, then compose + the unchanged local planet velocities against that supported star frame. */ + const carrierOrbitSupport = supportGalaxyCarrierOrbits(bodies, opts); + const finalSystemVelocity = stabilizeGalaxySystemVelocities(bodies, { + limit: opts.localRelativeSpeedLimit, + absoluteLimit: speedLimit, + fixedNodeId: opts.fixedNodeId, + }); + systemVelocity.limitedSystems += finalSystemVelocity.limitedSystems; + systemVelocity.maximumRelativeSpeed = Math.max(systemVelocity.maximumRelativeSpeed, + finalSystemVelocity.maximumRelativeSpeed); + systemVelocity.minimumScale = Math.min(systemVelocity.minimumScale, + finalSystemVelocity.minimumScale); + bodies.forEach(node => { + maximumSpeed = Math.max(maximumSpeed, Math.hypot(node.vx, node.vy)); + }); + /* A single scale preserves total momentum and differential directions. Per-node clipping + looks safer, but quietly makes a heavy star push a light one without receiving the + matching reaction. */ + const uncappedMaximumSpeed = maximumSpeed; + /* Leave a machine-epsilon margin so the common multiplication cannot round a capped + vector back above the caller's strict limit (for example 24.000000000000004). */ + const strictSpeedLimit = speedLimit * (1 - 4 * Number.EPSILON); + const speedScale = uncappedMaximumSpeed > speedLimit + ? strictSpeedLimit / uncappedMaximumSpeed : 1; + maximumSpeed = 0; + let kinetic = 0; + bodies.forEach(node => { + node.vx *= speedScale; + node.vy *= speedScale; + maximumSpeed = Math.max(maximumSpeed, Math.hypot(node.vx, node.vy)); + const mass = finitePositive(node.gravity_mass, 1, 1000); + kinetic += 0.5 * mass * (node.vx * node.vx + node.vy * node.vy); + }); + /* Ghosts are rendered history, not evidence mass. Advance their exact test-particle + phase only after live constraints and the common speed scale complete, so they cannot + trigger a contact/reheat or alter any live system's momentum. */ + const blackHoleSpinAngle = advanceGalaxyBlackHoleSpin(nodes, opts); + const ghostOrbit = integrateGalaxyGhostOrbits(nodes, opts); + const dragAcceleration = end.dragGravity || start.dragGravity + || { applied: 0, maximumAcceleration: 0, maximumPull: 0 }; + /* A leapfrog step samples the field twice. Keep both counts rather than overwriting the + first kick with the second, so live diagnostics can distinguish a dormant envelope from + a system that actually entered its smooth outer band during this physical slice. */ + const farFieldSamples = [start.farFieldGravity, end.farFieldGravity].filter(Boolean); + const farFieldGravity = { + anchorId: farFieldSamples.map(sample => sample.anchorId).find(Boolean) || null, + envelopeRadius: farFieldSamples.reduce((radius, sample) => Math.max(radius, + Number(sample.envelopeRadius) || 0), 0), + softRadius: farFieldSamples.reduce((radius, sample) => Math.max(radius, + Number(sample.softRadius) || 0), 0), + samples: farFieldSamples.length, + acceleratedSystems: farFieldSamples.reduce((sum, sample) => sum + + (sample.acceleratedSystems || 0), 0), + acceleratedCoreNodes: farFieldSamples.reduce((sum, sample) => sum + + (sample.acceleratedCoreNodes || 0), 0), + acceleratedFixedFollowers: farFieldSamples.reduce((sum, sample) => sum + + (sample.acceleratedFixedFollowers || 0), 0), + maximumAcceleration: farFieldSamples.reduce((maximum, sample) => Math.max(maximum, + sample.maximumAcceleration || 0), 0), + }; + return { + bodies: bodies.length, + collisions: collision.overlaps, + kinetic, + blackHoleSpinAngle, + ghostOrbit, + maximumSpeed, + uncappedMaximumSpeed, + speedCapped: speedScale < 1, + convergence, + relationConstraint, + orbitalSeparation, + systemPacking, + systemAnchorExclusion, + blackHoleExclusion, + farFieldConfinement, + farFieldGravity, + spacetime: end.spacetime || start.spacetime + || { anchorId: null, systems: 0, coreNodes: 0, warpedNodes: 0, + maximumWarp: 0, maximumFrameDragAcceleration: 0, + maximumHorizonAcceleration: 0, tidalSystems: 0, tidalPlanets: 0, + maximumTidalAcceleration: 0 }, + eventHorizonDecay, + carrierOrbitSupport, + systemVelocity, + systemGravity: end.systemGravity || start.systemGravity + || { systems: 0, anchors: 0, satellites: 0, + repulsions: 0, surfaceRepulsions: 0, + maximumRepulsion: 0, maximumSampledAttraction: 0, maximumNetRepulsion: 0, + minimumSurfaceNetRepulsion: null, + maximumAcceleration: 0, capScale: 1 }, + mutualGravity: end.mutualGravity || start.mutualGravity + || { systems: 0, interactions: 0, traversals: 0, approximations: 0, + maximumAcceleration: 0, capScale: 1 }, + dragGravity: { + applied: Math.max(dragAcceleration.applied, dragPositionGravity.applied), + maximumAcceleration: Math.max( + dragAcceleration.maximumAcceleration, dragPositionGravity.maximumAcceleration + ), + maximumPull: dragPositionGravity.maximumPull, + }, + }; + } + + /* Read-only motion telemetry shared by the browser API and deterministic tests. Evidence + mass weights every aggregate so a light planet moving quickly cannot masquerade as a heavy + system-wide kick. Invalid coordinates are reported, never allowed to poison the totals. */ + function galaxyMotionDiagnostics(nodes) { + const bodies = (nodes || []).filter(node => node && !node.ghost); + let totalMass = 0, centerX = 0, centerY = 0; + let momentumX = 0, momentumY = 0, kineticEnergy = 0, maxSpeed = 0; + let invalidBodies = 0; + bodies.forEach(node => { + const mass = finitePositive(node.gravity_mass, 1, 1000); + const positionFinite = Number.isFinite(node.x) && Number.isFinite(node.y); + const velocityFinite = Number.isFinite(node.vx) && Number.isFinite(node.vy); + if (!positionFinite || !velocityFinite) invalidBodies++; + const x = positionFinite ? node.x : 0, y = positionFinite ? node.y : 0; + const vx = velocityFinite ? node.vx : 0, vy = velocityFinite ? node.vy : 0; + const speedSquared = vx * vx + vy * vy; + totalMass += mass; + centerX += x * mass; + centerY += y * mass; + momentumX += vx * mass; + momentumY += vy * mass; + kineticEnergy += 0.5 * mass * speedSquared; + maxSpeed = Math.max(maxSpeed, Math.sqrt(speedSquared)); + }); + if (totalMass > 0) { + centerX /= totalMass; + centerY /= totalMass; + } + let angularMomentum = 0; + bodies.forEach(node => { + if (!Number.isFinite(node.x) || !Number.isFinite(node.y) + || !Number.isFinite(node.vx) || !Number.isFinite(node.vy)) return; + const mass = finitePositive(node.gravity_mass, 1, 1000); + angularMomentum += mass * ( + (node.x - centerX) * node.vy - (node.y - centerY) * node.vx + ); + }); + return { + bodies: bodies.length, invalidBodies, totalMass, + centerX, centerY, momentumX, momentumY, + momentum: Math.hypot(momentumX, momentumY), + angularMomentum, kineticEnergy, maxSpeed, + }; + } + + function fallbackCommunityBridges(nodes, links) { + const byId = new Map((nodes || []).map(node => [node.id, node])); + const grouped = new Map(); + (links || []).forEach(link => { + if (!link || link.ghost || Number(link.physics_strength) === 0) return; + const source = byId.get(linkEndpoint(link, 'source')); + const target = byId.get(linkEndpoint(link, 'target')); + if (!source || !target || source.ghost || target.ghost) return; + let left = communityKey(source), right = communityKey(target); + if (left === right) return; + if (right < left) { const swap = left; left = right; right = swap; } + const key = left + '|' + right; + let bridge = grouped.get(key); + if (!bridge) { + bridge = { + id: 'compat-bridge-' + seededHash(0, key), + source_community: left, target_community: right, + physics_strength: 0, edge_count: 0 + }; + grouped.set(key, bridge); + } + bridge.edge_count++; + bridge.physics_strength += Math.max(0, Math.min(1, + Number.isFinite(Number(link.strength)) ? Number(link.strength) : 0.2)); + }); + const bridges = [...grouped.values()]; + bridges.forEach(bridge => { + bridge.physics_strength = Math.max(0.05, Math.min(1, + bridge.physics_strength / Math.max(1, bridge.edge_count))); + }); + return bridges.sort((a, b) => a.id.localeCompare(b.id)); } function validNodeId(value) { const type = typeof value; @@ -745,13 +6599,13 @@ const s = linkEndpoint(l, 'source'), t = linkEndpoint(l, 'target'); if (adj[s]) adj[s].push(t); if (adj[t]) adj[t].push(s); - if (clustersAcross(l)) return; + if (l.ghost || clustersAcross(l)) return; if (clusterAdj[s]) clusterAdj[s].push(t); if (clusterAdj[t]) clusterAdj[t].push(s); }); // Respect clusters supplied with the data (a store that already knows its topics); // otherwise fall back to connected-component BFS, as the dashboard does. - if (nodes.length && nodes.every(n => typeof n.community === 'number')) return adj; + if (nodes.length && nodes.every(n => n.community !== undefined && n.community !== null)) return adj; const seen = new Set(); const groups = []; nodes.forEach(n => { @@ -890,6 +6744,55 @@ return bridges; } + function paintGalaxyAnchorAdornment(ctx, node, scale, accent, foreground) { + if (!ctx || !node || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return 0; + const role = node.anchor_role; + if (role !== 'global' && role !== 'community') return 0; + const radius = finitePositive(node.radius, 3, 160); + const color = accent || node.color || '#9d7bff'; + const inverseScale = 1 / Math.max(0.1, Number(scale) || 1); + if (role === 'community') { + if (foreground) return 0; + ctx.save(); + ctx.strokeStyle = alpha(color, 0.28); + ctx.lineWidth = 0.75 * inverseScale; + ctx.beginPath(); ctx.arc(node.x, node.y, radius * 1.42, 0, 6.2832); ctx.stroke(); + ctx.restore(); + return 1; + } + ctx.save(); + if (!foreground) { + if (typeof ctx.createRadialGradient === 'function') { + const halo = ctx.createRadialGradient( + node.x, node.y, radius * 0.55, node.x, node.y, radius * 3.2 + ); + halo.addColorStop(0, alpha(color, 0.38)); + halo.addColorStop(0.42, alpha(color, 0.16)); + halo.addColorStop(1, alpha(color, 0)); + ctx.fillStyle = halo; + } else ctx.fillStyle = alpha(color, 0.12); + ctx.beginPath(); ctx.arc(node.x, node.y, radius * 3.2, 0, 6.2832); ctx.fill(); + ctx.strokeStyle = alpha(color, 0.72); + ctx.lineWidth = 1.15 * inverseScale; + ctx.beginPath(); + if (typeof ctx.ellipse === 'function') { + ctx.ellipse(node.x, node.y, radius * 1.72, radius * 0.62, + -0.28 + galaxyBlackHoleSpinAngle(node), 0, 6.2832); + } else ctx.arc(node.x, node.y, radius * 1.45, 0, 6.2832); + ctx.stroke(); + } else { + /* The opaque event-horizon core is deliberately smaller than the evidence radius; the + material rim and hit area retain the canonical mass-authoritative geometry. */ + ctx.fillStyle = '#020308'; + ctx.beginPath(); ctx.arc(node.x, node.y, radius * 0.68, 0, 6.2832); ctx.fill(); + ctx.strokeStyle = alpha('#ffffff', 0.34); + ctx.lineWidth = 0.55 * inverseScale; + ctx.beginPath(); ctx.arc(node.x, node.y, radius * 0.78, 0, 6.2832); ctx.stroke(); + } + ctx.restore(); + return 1; + } + function create(el, options) { if (typeof ForceGraph === 'undefined') throw new Error('force-graph not loaded'); if (!el || typeof el.getAttribute !== 'function') throw new Error('graph container missing'); @@ -900,15 +6803,31 @@ // by the shorter name reads as one. The longer name keeps that gate honest. styleName: 'cyber', colorBy: 'community', palette: 'theme', overrides: Object.create(null), themeColors: Object.create(null), - settings: Object.assign({}, PRESETS.communities, { mode: 'communities', labels: false, flow: true, frozen: false }), - minDegree: 1, showUnlinked: false, focusId: null, depth: 2, layers: { temporal: true, entity: true, causal: true, semantic: true, code: false }, - path: null, asOf: null, ghost: true, sizeBy: 'degree', bridges: false, suggestions: false, + settings: Object.assign({}, PRESETS.galaxy, { + mode: 'galaxy', labels: false, flow: true, frozen: false, + gravitationalConstant: GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, + localGravitationalConstant: GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, + blackHoleMass: GALAXY_BLACK_HOLE_MASS_MULTIPLIER, + damping: 1, + springStiffness: GALAXY_SPRING_STIFFNESS_MULTIPLIER, + orbitPaused: false, + }), + minDegree: 1, showUnlinked: true, focusId: null, depth: 2, layers: { temporal: true, entity: true, causal: true, semantic: true, code: false }, + path: null, asOf: null, ghost: true, sizeBy: 'mass', bridges: false, suggestions: false, collapse: 'auto', renderMode: opts.renderMode === 'full' ? 'full' : 'overview' }; - let raw = { nodes: [], links: [], suggestions: [] }, adj = Object.create(null), hilite = null, hoverSet = null, maxDeg = 1; + let raw = { nodes: [], links: [], suggestions: [], communities: [], community_bridges: [], meta: {} }; + const galaxyServerPhase = new Map(); + const galaxySavedPhase = new Map(); + /* Mode restoration is a transactional hand-off: a same-task freeze must still expose the + saved phase byte-for-byte after the render's safety projections. */ + let galaxyPhaseRestorePending = false; + let adj = Object.create(null), liveAdj = Object.create(null), hilite = null, hoverSet = null, maxDeg = 1; + let legacySizeBy = 'degree'; // The classic renderer treats label density as a hard ranked cap, not merely a looser // degree threshold. Keeping chosen IDs outside the paint callback bounds fillText work. let labelIds = new Set(); + let pendingLabels = []; let zoom = 1, collapsed = false; /* Recomputed from the *rendered* data on every render, exactly as the classic path recomputes GPERF — filters and focus can take a huge store down to a small view. */ @@ -918,11 +6837,89 @@ the data in and d3 resets the simulation alpha to 1, so a paint-only change would restart the whole layout. See `sameData`/`render`. */ let seeded = null; + let clusterExpandTimer = 0; let destroyed = false, running = true, fitTimer = 0, suspended = 0, pendingRender = null; let physicsFrame = 0, physicsReheatPending = false; + let galaxyFrame = 0, galaxyLastFrameTime = null, galaxyAccumulator = 0; + let galaxyFrames = 0, galaxySteps = 0, galaxyLastSubsteps = 0; + let galaxyReheatStepsRemaining = 0, galaxyReheatActivations = 0; + let galaxyReheatStepsApplied = 0, galaxyLastReheatSubsteps = 0, galaxyKinematicSteps = 0; + let galaxyLastKinetic = 0, galaxyLastCollisions = 0, galaxyLastRelationCorrections = 0; + let galaxyLastRelationDistance = 0, galaxyLastOrbitalRelationSkips = 0; + let galaxyLastOrbitalSeparations = 0; + let galaxyLastCrossSystemSeparations = 0; + let galaxyLastSystemPacking = { + systems: 0, overlaps: 0, adjustedSystems: 0, remainingOverlaps: 0, + infeasiblePairs: 0, correctionDistance: 0, maximumShift: 0, + gap: GALAXY_SYSTEM_PACKING_GAP, + }; + let galaxyLastOrbitalCorrection = 0, galaxyLastLocalVelocityLimits = 0; + let galaxySpeedCaps = 0; + let galaxyLastBlackHoleExclusion = { + anchorId: null, contacts: 0, systems: 0, coreNodes: 0, fixedSystemNodes: 0, + repelledNodes: 0, + correctedDistance: 0, maximumShift: 0, inwardVelocityRemoved: 0, + tangentialVelocityRemoved: 0, + minimumClearance: null, + }; + let galaxyLastSystemAnchorExclusion = { + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + systems: 0, contacts: 0, correctedDistance: 0, maximumShift: 0, + inwardVelocityRemoved: 0, tangentialVelocityRemoved: 0, + minimumClearance: null, iterations: 0, + }; + let galaxyLastFarFieldConfinement = { + anchorId: null, envelopeRadius: 0, softRadius: 0, + acceleratedSystems: 0, boundedSystems: 0, boundedCoreNodes: 0, + boundedFixedSource: 0, boundedFixedFollowers: 0, boundedDeformedSystems: 0, + boundedOversizedNodes: 0, + correctedDistance: 0, maximumShift: 0, outwardVelocityRemoved: 0, + tangentialVelocityRemoved: 0, + annulus: { anchorId: null, innerCorrectedNodes: 0, outerCorrectedNodes: 0, + infeasibleNodes: 0 }, + }; + let galaxyLastFarFieldGravity = { + anchorId: null, envelopeRadius: 0, softRadius: 0, samples: 0, + acceleratedSystems: 0, acceleratedCoreNodes: 0, acceleratedFixedFollowers: 0, + maximumAcceleration: 0, + }; + let galaxyLastMutualGravity = { + systems: 0, interactions: 0, traversals: 0, approximations: 0, + maximumAcceleration: 0, capScale: 1, + }; + let galaxyLastSystemGravity = { + systems: 0, anchors: 0, satellites: 0, repulsions: 0, surfaceRepulsions: 0, + maximumRepulsion: 0, maximumSampledAttraction: 0, maximumNetRepulsion: 0, + minimumSurfaceNetRepulsion: null, + repulsionPadding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + repulsionRange: GALAXY_SYSTEM_ANCHOR_REPULSION_RANGE, + repulsionAcceleration: GALAXY_SYSTEM_ANCHOR_REPULSION_ACCELERATION, + maximumAcceleration: 0, capScale: 1, + }; + let galaxyLastGravityResponse = { + systems: 0, moved: 0, ratio: 1, maximumShift: 0, anchorId: null, + }; + let galaxyLastSpacetime = { + anchorId: null, systems: 0, coreNodes: 0, warpedNodes: 0, + maximumWarp: 0, maximumFrameDragAcceleration: 0, + maximumHorizonAcceleration: 0, tidalSystems: 0, tidalPlanets: 0, + maximumTidalAcceleration: 0, + }; + let galaxyLastEventHorizonDecay = { + anchorId: null, systems: 0, nodes: 0, maximumWarp: 0, + maximumVelocityRemoved: 0, + }; + let galaxyLastCarrierOrbitSupport = { + anchorId: null, eligible: 0, supported: 0, coreEligible: 0, coreSupported: 0, + minTangentialSpeed: null, coreMinTangentialSpeed: null, + maximumRadialSpeed: 0, maximumVelocityCorrection: 0, corrected: 0, + meanAngularVelocity: 0, + }; let softAlphaTimer = 0, initialFitFrame = 0; let suppressNodeClickAfterDrag = false, dragClickFrame = 0; - const requestFrame = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function' + const hasBrowserFrameClock = typeof window !== 'undefined' + && typeof window.requestAnimationFrame === 'function'; + const requestFrame = hasBrowserFrameClock ? window.requestAnimationFrame.bind(window) : callback => setTimeout(callback, 0); const cancelFrame = typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function' @@ -931,66 +6928,96 @@ let betweennessReady = false; const fg = ForceGraph()(el); const api = {}; + const visibilityDocument = typeof document !== 'undefined' ? document : null; + let detachVisibility = null; - let activeDragNode = null, activeDragLinks = [], dragFollowForce = null; - let dragIsolated = false, dragCenterForce = null; + let activeDragNode = null; + let galaxyGravityForce = null, galaxyCenterForce = null, communityBridgeForce = null; + let galaxyRelationForce = null, galaxyCollisionForce = null; + let dragFollowers = []; + let dragFollowerGravityReport = { applied: 0, maximumAcceleration: 0, maximumPull: 0 }; + let dragPreVelocity = null; + let dragReleaseVelocity = null; + let lastSlingshotRelease = null; function setActiveDragNode(node) { activeDragNode = node || null; - if (!activeDragNode) { - activeDragLinks = []; - return; - } - const activeId = activeDragNode.id; - activeDragLinks = (fg.graphData().links || []).filter(link => { - const source = linkEndpoint(link, 'source'), target = linkEndpoint(link, 'target'); - return source === activeId || target === activeId; - }); - isolateDragPhysics(); } - /* A pinned node is a local interaction. Keeping charge, centering, radial, and collision - forces active while that pin jumps makes every unrelated node respond to the pointer. - Leave only link attraction, the bounded one-hop follow force, and the final velocity - guard active until pointer-up. Settings renders can reinstall the normal forces; the - final isolation pass in applyForces() removes them again while the gesture is live. */ - function isolateDragPhysics() { - if (!dragIsolated) { - dragIsolated = true; - dragCenterForce = fg.d3Force('center'); - } - ['charge', 'x', 'y', 'radial', 'collide', 'center'].forEach(name => fg.d3Force(name, null)); + function galaxySoftening() { + const raw = Number(state.settings.repel); + const separation = Number.isFinite(raw) ? Math.max(0, Math.min(120, raw)) + : PRESETS.galaxy.repel; + return Math.max(3, separation * 0.16); + } + + /* Interactive evidence systems often contain several large stars at close range. Treating + those as point masses produces slingshots that a browser-sized fixed step cannot resolve. + Keep the live local potential smooth below the scale of a system orbit. */ + function galaxyLiveSoftening() { + return Math.max(32, galaxySoftening() * 4); + } + + function makeGalaxyGravityForce() { + const force = alphaValue => { + if (state.settings.frozen || staticFullLayout) return; + applyGalaxyGravity(force.nodes || fg.graphData().nodes || [], { + gravity: state.settings.gravity, + softening: galaxySoftening(), alpha: alphaValue, + exactLimit: GALAXY_EXACT_LIMIT, theta: GALAXY_BARNES_HUT_THETA + }); + }; + force.initialize = nodes => { force.nodes = nodes; }; + return force; + } + + function makeGalaxyRelationForce() { + const force = alphaValue => { + if (state.settings.frozen || staticFullLayout) return; + const orbitScale = galaxyRelationOrbitScale(state.settings.link); + applyGalaxyRelationSprings( + force.nodes || fg.graphData().nodes || [], fg.graphData().links || [], + { + alpha: alphaValue, orbitScale, + strengthMultiplier: GALAXY_RELATION_STRENGTH_MULTIPLIER, + forceCap: GALAXY_RELATION_FORCE_CAP, + accelerationCap: GALAXY_RELATION_ACCELERATION_CAP, + } + ); + }; + force.initialize = nodes => { force.nodes = nodes; }; + return force; + } + + function makeGalaxyCollisionForce() { + const force = () => { + if (state.settings.frozen || staticFullLayout) return; + applyGalaxyCollisions(force.nodes || fg.graphData().nodes || [], { + padding: 1.5, strength: 0.7, iterations: large ? 1 : 2 + }); + }; + force.initialize = nodes => { force.nodes = nodes; }; + return force; } - function restoreDragPhysics() { - if (!dragIsolated) return; - dragIsolated = false; - if (dragCenterForce) fg.d3Force('center', dragCenterForce); - dragCenterForce = null; - applyForces(); + function makeCommunityBridgeForce() { + const force = alphaValue => { + if (state.settings.frozen || staticFullLayout) return; + applyCommunityBridgeGravity(force.nodes || fg.graphData().nodes || [], raw.community_bridges, { + gravity: state.settings.gravity, + softening: Math.max(24, galaxySoftening() * 4), alpha: alphaValue + }); + }; + force.initialize = nodes => { force.nodes = nodes; }; + return force; } - function makeDragFollowForce() { - const force = alpha => { - if (!activeDragNode || state.settings.frozen || staticFullLayout) return; - const byId = new Map((fg.graphData().nodes || []).map(node => [node.id, node])); - const targetDistance = Math.max(8, Number(state.settings.link) || 16); - const strength = 0.28 + Math.min(0.16, (Number(state.settings.gravity) || 0) / 500); - activeDragLinks.forEach(link => { - const source = linkEndpoint(link, 'source'), target = linkEndpoint(link, 'target'); - const otherId = source === activeDragNode.id ? target - : target === activeDragNode.id ? source : null; - const other = otherId == null ? null : byId.get(otherId); - if (!other || other === activeDragNode - || !Number.isFinite(other.x) || !Number.isFinite(other.y) - || !Number.isFinite(activeDragNode.x) || !Number.isFinite(activeDragNode.y)) return; - const dx = activeDragNode.x - other.x, dy = activeDragNode.y - other.y; - const distance = Math.hypot(dx, dy), gap = distance - targetDistance; - if (distance < 1e-6 || gap <= 0) return; - const rawPull = gap * strength * (Number.isFinite(alpha) ? alpha : 1); - const pull = Math.max(MIN_DRAG_PULL, Math.min(MAX_DRAG_PULL, rawPull)); - other.vx = (other.vx || 0) + (dx / distance) * pull; - other.vy = (other.vy || 0) + (dy / distance) * pull; + function makeGalaxyCenterForce() { + const force = alphaValue => { + if (state.settings.frozen || staticFullLayout) return; + applyGalaxyCentralGravity(force.nodes || fg.graphData().nodes || [], { + gravity: state.settings.gravity, + softening: Math.max(36, galaxySoftening() * 5), alpha: alphaValue }); }; force.initialize = nodes => { force.nodes = nodes; }; @@ -1008,27 +7035,65 @@ const force = () => { const nodes = force.nodes || fg.graphData().nodes || []; const limit = nodeSpeedLimit(); + let maximumSpeed = 0; nodes.forEach(node => { - let vx = Number.isFinite(node.vx) ? node.vx : 0; - let vy = Number.isFinite(node.vy) ? node.vy : 0; - const speed = Math.hypot(vx, vy); - if (speed > limit) { - const scale = limit / speed; - vx *= scale; - vy *= scale; + if (node.ghost) { + node.vx = 0; + node.vy = 0; + return; } - node.vx = vx; - node.vy = vy; + node.vx = Number.isFinite(node.vx) ? node.vx : 0; + node.vy = Number.isFinite(node.vy) ? node.vy : 0; + maximumSpeed = Math.max(maximumSpeed, Math.hypot(node.vx, node.vy)); + }); + /* One common scale preserves every equal-and-opposite impulse and therefore total + evidence-mass momentum. Per-node clipping made the light side of a contact lose more + velocity than its star, manufacturing the same system drift the guard should prevent. */ + const scale = maximumSpeed > limit ? limit / maximumSpeed : 1; + if (scale < 1) nodes.forEach(node => { + if (node.ghost) return; + node.vx *= scale; + node.vy *= scale; }); }; force.initialize = nodes => { force.nodes = nodes; }; return force; } + function installVelocityGuard() { + if (!velocityGuardForce) velocityGuardForce = makeVelocityGuardForce(); + // Keep this boundary available to dependency-light callers too. In a browser D3 + // invokes it after the motion forces; in the Node/static harness it still provides + // the same finite-value and shared-scale contract when D3 is absent. + fg.d3Force('velocityGuard', null); + fg.d3Force('velocityGuard', velocityGuardForce); + } + function autoFit(duration, padding) { const bbox = fg.getGraphBbox && fg.getGraphBbox(); const width = el.clientWidth, height = el.clientHeight; if (!bbox || !bbox.x || !bbox.y || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return; + if (state.settings.mode === 'galaxy') { + const graph = fg.graphData ? fg.graphData() : null; + const nodes = graph && graph.nodes ? graph.nodes : []; + const anchor = galaxyGlobalAnchor(nodes); + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + /* Reserve each complete stellar envelope, not only every body's current phase. A + planet that starts on the inward side later sweeps to the outward side without + changing its system lane; fitting its current coordinate would clip that phase. */ + const diskRadius = galaxySystemEnvelopes(nodes, { + respectFixedCoordinates: false, + }).reduce((maximum, system) => Math.max(maximum, + Math.hypot(system.anchor.x - anchor.x, system.anchor.y - anchor.y) + + system.radius), 1); + const available = Math.max(1, Math.min(width, height) - 2 * padding); + fg.centerAt(anchor.x, anchor.y, duration); + /* Reserve a small paint/camera margin for trails, labels and sub-pixel transforms; + the physical lane projector keeps carriers inside this stable disk afterward. */ + fg.zoom(Math.min(MAX_AUTO_FIT_ZOOM, available / (diskRadius * 2.3)), duration); + return; + } + } const xSpan = bbox.x[1] - bbox.x[0], ySpan = bbox.y[1] - bbox.y[0]; if (!Number.isFinite(xSpan) || !Number.isFinite(ySpan)) return; const zoom = Math.min(MAX_AUTO_FIT_ZOOM, Math.max( @@ -1073,7 +7138,10 @@ to that change detection. Everywhere else, letting force-graph park the redraw is what keeps a settled graph off the CPU. */ function needsContinuousFrames() { - return !reduced() && state.styleName === 'galaxy' && !large; + /* The fixed Galaxy clock invalidates at its bounded cadence. Only a legacy layout wearing + the animated Galaxy paint needs force-graph's independent full-rate redraw loop. */ + return !reduced() && state.styleName === 'galaxy' + && state.settings.mode !== 'galaxy' && !large; } /* Betweenness is the one analysis that is superlinear in the store size, and nothing in the default view consumes it — the bridge overlay and betweenness-sizing are both off. @@ -1081,7 +7149,7 @@ function ensureBetweenness() { if (betweennessReady) return; betweennessReady = true; - betweenness(raw.nodes, adj); + betweenness(raw.nodes, liveAdj && Object.keys(liveAdj).length ? liveAdj : adj); } /* Apply a batch of setters with exactly one render at the end. Each public setter renders on its own, so a single dashboard sync used to cost six full re-simulations (and six @@ -1153,15 +7221,60 @@ } function collapsedData(nodes, links) { - const groups = Object.create(null); + const groups = new Map(); nodes.forEach(n => { - const c = n.community || 0; - if (!groups[c]) groups[c] = { id: 'cluster-' + c, cluster: true, community: c, name: (n.topic || 'Cluster ' + (c + 1)), etype: n.etype, members: 0, degree: 0, betweenness: 0 }; - groups[c].members++; - groups[c].degree += n.degree || 0; - groups[c].betweenness = Math.max(groups[c].betweenness, n.betweenness || 0); + const c = communityKey(n); + if (!groups.has(c)) groups.set(c, { + id: 'cluster-' + c, cluster: true, community: n.community || 0, + community_id: c, name: (n.topic || 'Cluster ' + (Number(n.community || 0) + 1)), + etype: n.etype, members: 0, degree: 0, betweenness: 0, + gravity_mass: 0, visual_radius: 0, x: 0, y: 0, + _position_mass: 0, _fallback_x: 0, _fallback_y: 0, _fallback_count: 0, + _live_members: 0, anchor_role: null + }); + const group = groups.get(c); + if (n.anchor_role === 'global') group.anchor_role = 'global'; + else if (n.anchor_role === 'community' && group.anchor_role !== 'global') { + group.anchor_role = 'community'; + } + group.members++; + if (!n.ghost) group._live_members++; + group.degree += n.degree || 0; + const mass = n.ghost ? 0 : finitePositive(n.gravity_mass, 1, 1000); + group.gravity_mass += mass; + if (Number.isFinite(n.x) && Number.isFinite(n.y)) { + if (mass) { + group.x += n.x * mass; + group.y += n.y * mass; + group._position_mass += mass; + } else { + group._fallback_x += n.x; + group._fallback_y += n.y; + group._fallback_count++; + } + } + group.betweenness = Math.max(group.betweenness, n.betweenness || 0); + }); + const cnodes = [...groups.values()]; + cnodes.forEach(node => { + node.ghost = node._live_members === 0; + node.visual_radius = node.ghost ? 0 : radiusFromGravityMass(node.gravity_mass); + if (node._position_mass) { + node.x /= node._position_mass; + node.y /= node._position_mass; + } else if (node._fallback_count) { + node.x = node._fallback_x / node._fallback_count; + node.y = node._fallback_y / node._fallback_count; + } else { + node.x = undefined; + node.y = undefined; + } + delete node._position_mass; + delete node._fallback_x; + delete node._fallback_y; + delete node._fallback_count; + delete node._live_members; }); - const cnodes = Object.values(groups); const seen = Object.create(null); const clinks = []; // Indexed lookup, not Array#find per endpoint: auto-collapse fires on every zoom-out, @@ -1171,7 +7284,7 @@ const s = byId.get(linkEndpoint(l, 'source')); const t = byId.get(linkEndpoint(l, 'target')); if (!s || !t) return; - const a = 'cluster-' + (s.community || 0), b = 'cluster-' + (t.community || 0); + const a = 'cluster-' + communityKey(s), b = 'cluster-' + communityKey(t); if (a === b) return; const key = a < b ? a + '|' + b : b + '|' + a; if (seen[key]) { seen[key].weight++; return; } @@ -1197,12 +7310,13 @@ .includes(state.repo)); } if (state.asOf !== null) { - const live = nodes.filter(n => aliveAt(n, state.asOf)); - const ghosts = state.ghost ? nodes.filter(n => !aliveAt(n, state.asOf) && born(n) <= state.asOf).map(n => Object.assign(n, { ghost: true })) : []; + const live = nodes.filter(n => aliveAt(n, state.asOf) && !n._historyGhost); + const ghosts = state.ghost ? nodes.filter(n => (n._historyGhost || !aliveAt(n, state.asOf)) && born(n) <= state.asOf).map(n => Object.assign(n, { ghost: true })) : []; live.forEach(n => { n.ghost = false; }); nodes = live.concat(ghosts); } else { - nodes.forEach(n => { n.ghost = false; }); + nodes.forEach(n => { n.ghost = n._historyGhost === true; }); + if (!state.ghost) nodes = nodes.filter(n => !n.ghost); } if (state.focusId != null) { const keep = new Set([state.focusId]); @@ -1217,11 +7331,12 @@ const ids = new Set(nodes.map(n => n.id)); let links = raw.links.filter(l => keepLayer(l) && ids.has(linkEndpoint(l, 'source')) && ids.has(linkEndpoint(l, 'target'))); if (state.asOf !== null) { - links.forEach(l => { l.ghost = !aliveAt(l, state.asOf); }); + links.forEach(l => { l.ghost = l._historyGhost === true || !aliveAt(l, state.asOf); }); if (!state.ghost) links = links.filter(l => !l.ghost); links = links.filter(l => born(l) <= state.asOf); } else { - links.forEach(l => { l.ghost = false; }); + links.forEach(l => { l.ghost = l._historyGhost === true; }); + if (!state.ghost) links = links.filter(l => !l.ghost); } if (state.suggestions && raw.suggestions) { raw.suggestions.forEach(s => { @@ -1233,44 +7348,75 @@ return { nodes, links }; } + function disableD3GalaxyIntegration() { + ['charge', 'link', 'center', 'x', 'y', 'radial', 'galaxy', 'galaxyCenter', + 'galaxyRelations', 'communityBridges', 'collide', 'velocityGuard'] + .forEach(name => fg.d3Force(name, null)); + setSimulationBudget(false, true); + } + function applyForces() { /* Extremely large complete snapshots use the deterministic fallback, but a normal full graph remains a live layout. The previous `renderMode === 'full'` guard removed every force and pinned every node, which is why the gravity slider could read 98 while the canvas stayed on a wide ring. */ if (staticFullLayout) { + if ((state.settings.mode || 'compact') === 'galaxy') { + disableD3GalaxyIntegration(); + return; + } fg.d3Force('charge', null); + fg.d3Force('galaxy', null); + fg.d3Force('galaxyCenter', null); + fg.d3Force('galaxyRelations', null); + fg.d3Force('communityBridges', null); fg.d3Force('link', null); fg.d3Force('x', null); fg.d3Force('y', null); fg.d3Force('radial', null); fg.d3Force('collide', null); fg.d3Force('velocityGuard', null); - fg.d3Force('dragFollow', null); return; } const s = state.settings, mode = s.mode || 'compact'; - let charge = fg.d3Force('charge'); let link = fg.d3Force('link'); - if (!charge && typeof d3 !== 'undefined' && d3.forceManyBody) { - charge = d3.forceManyBody(); - fg.d3Force('charge', charge); - } if (!link && typeof d3 !== 'undefined' && d3.forceLink) { link = d3.forceLink().id(node => node.id); fg.d3Force('link', link); } - if (charge && charge.strength) charge.strength(-(mode === 'communities' ? Math.max(10, s.repel * 0.68) : s.repel)); - if (link && link.distance) link.distance(s.link); - if (typeof d3 === 'undefined') return; - if (!dragFollowForce) dragFollowForce = makeDragFollowForce(); - fg.d3Force('dragFollow', dragFollowForce); - /* D3 applies forces in insertion order. Register the guard after every motion force so - it is the final velocity boundary, including while a node is being dragged. */ - if (!velocityGuardForce) velocityGuardForce = makeVelocityGuardForce(); - fg.d3Force('velocityGuard', velocityGuardForce); fg.d3Force('radial', null); const layoutNodes = fg.graphData().nodes || []; + const layoutById = new Map(layoutNodes.map(node => [node.id, node])); + if (mode === 'galaxy') { + /* Galaxy is integrated by the fixed physical clock below. Leaving even one D3 force or + its velocity/position tick installed would apply the field twice and reintroduce alpha + decay, global reheats, and frame-rate-dependent motion. force-graph remains the canvas + and hit-test host only. */ + disableD3GalaxyIntegration(); + return; + } + fg.d3Force('galaxy', null); + fg.d3Force('galaxyCenter', null); + fg.d3Force('galaxyRelations', null); + fg.d3Force('communityBridges', null); + let charge = fg.d3Force('charge'); + if (!charge && typeof d3 !== 'undefined' && d3.forceManyBody) { + charge = d3.forceManyBody(); + fg.d3Force('charge', charge); + } + if (charge && charge.strength) charge.strength(-(mode === 'communities' ? Math.max(10, s.repel * 0.68) : s.repel)); + if (link && link.distance) link.distance(s.link); + if (link && link.strength) link.strength(edge => { + const source = typeof edge.source === 'object' ? edge.source : layoutById.get(linkEndpoint(edge, 'source')); + const target = typeof edge.target === 'object' ? edge.target : layoutById.get(linkEndpoint(edge, 'target')); + return 1 / Math.max(1, Math.min( + source && source.degree || 1, target && target.degree || 1 + )); + }); + if (typeof d3 === 'undefined') { + installVelocityGuard(); + return; + } /* The layout buttons are arrangements, not just five nearby slider presets. Keep the ordinary force settings as the local texture, then give each named mode its own geometry so switching modes is visible even when the graph has only one component. @@ -1333,7 +7479,9 @@ per node on every tick, and a large store pays that on the initial layout and on every reheat, which is exactly where it is least affordable. */ if (d3.forceCollide) fg.d3Force('collide', d3.forceCollide(n => n.radius + 1.5).iterations(large ? 1 : 2)); - if (dragIsolated) isolateDragPhysics(); + /* D3 applies forces in insertion order. Register the guard after every motion force so + it is the final velocity boundary. A drag then removes it with every other global force. */ + installVelocityGuard(); } function clearPinnedPositions(data) { @@ -1347,6 +7495,27 @@ }); } + function releasePinnedPositions(data) { + data.nodes.forEach(node => { + node.fx = undefined; + node.fy = undefined; + node.vx = Number.isFinite(node.vx) ? node.vx : 0; + node.vy = Number.isFinite(node.vy) ? node.vy : 0; + }); + } + + function pinGalaxySceneLayout(data) { + const layoutSeed = raw.meta && raw.meta.layout_seed !== undefined + ? raw.meta.layout_seed : 0; + ensureGalaxyPositions(data.nodes, layoutSeed); + data.nodes.forEach(node => { + node.vx = 0; + node.vy = 0; + node.fx = node.x; + node.fy = node.y; + }); + } + function pinFullGraphLayout(data) { /* The rare fallback above the live-force ceiling is deterministic and bounded, but it must still answer the tuning controls. A centred grid avoids the old empty-core ring; @@ -1359,11 +7528,10 @@ }); const ordered = [...groups.entries()].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])); const s = state.settings; - const gravity = Math.max(0, Math.min(1, Number(s.gravity) / 100 || 0)); const repel = Math.max(0, Number(s.repel) || 0); const link = Math.max(4, Number(s.link) || 4); const nodeSize = Math.max(1, Number(s.size) || 3); - const compactness = 1.75 - gravity * 1.4; + const compactness = galaxyLayoutCompactness(s.gravity); const localGap = (4 + nodeSize * 1.6 + Math.sqrt(repel) * 0.8 + link * 0.16) * compactness; const columns = Math.max(1, Math.ceil(Math.sqrt(ordered.length))); const largestGroup = ordered.reduce((largest, [, nodes]) => Math.max(largest, nodes.length), 1); @@ -1435,7 +7603,10 @@ const focus = hoverSet && hoverSet.size > 1, neighbor = focus && hoverSet.has(node.id), dim = focus && !neighbor; let r = node.radius; const col = node.color; - ctx.globalAlpha = node.ghost ? 0.22 : (dim ? 0.12 : 1); + const spacetimeFade = state.settings.mode === 'galaxy' && node.anchor_role !== 'global' + ? 1 - 0.55 * Math.max(0, Math.min(1, Number(node.__galaxySpacetimeWarp) || 0)) + : 1; + ctx.globalAlpha = (node.ghost ? 0.22 : (dim ? 0.12 : 1)) * spacetimeFade; if (node.ghost) { ctx.lineWidth = 1.1 / scale; ctx.strokeStyle = col; @@ -1455,11 +7626,7 @@ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(node.members), node.x, node.y); - ctx.font = '500 ' + Math.max(2.6, r * 0.4) + 'px system-ui, sans-serif'; - // Cluster names sit outside the coloured bubble. They therefore need the active - // theme's text colour, not the dark-theme near-white that disappears on light canvas. - ctx.fillStyle = state.themeColors.label || '#e7e9ee'; - ctx.fillText(nodeName(node), node.x, node.y + r * 1.5 + r * 0.5); + pendingLabels.push({ x: node.x, y: node.y + r * 1.5 + r * 0.5, text: nodeName(node), cluster: true, scale, r }); ctx.textAlign = 'left'; ctx.globalAlpha = 1; return; @@ -1476,6 +7643,11 @@ fallback preserves them when detached canvases are unavailable, while a large graph forces the gradient-free signature tier. */ let nodeMaterial; + const galaxyAnchor = state.settings.mode === 'galaxy' + && (node.anchor_role === 'global' || node.anchor_role === 'community'); + if (galaxyAnchor) paintGalaxyAnchorAdornment( + ctx, node, scale, state.themeColors.accent || col, false + ); if (state.styleName === 'galaxy') { nodeMaterial = materialRecipe('galaxy', state.themeColors, state.palette, col); paintMaterialSurface(ctx, node.x, node.y, r, scale, nodeMaterial, materialLow); @@ -1496,6 +7668,9 @@ paintMaterialSurface(ctx, node.x, node.y, r, scale, nodeMaterial, materialLow); if (node.hub) { ctx.lineWidth = 0.8 / scale; ctx.strokeStyle = node.stroke; ctx.stroke(); } } + if (galaxyAnchor) paintGalaxyAnchorAdornment( + ctx, node, scale, state.themeColors.accent || nodeMaterial.identity, true + ); if (node.id === hilite) { /* Hover lifts exposure without changing the material or rotating its light. The two unblurred rings remain crisp at every DPR and also serve explicit selection. */ @@ -1507,20 +7682,21 @@ ctx.strokeStyle = alpha(nodeMaterial.identity, 0.92); ctx.beginPath(); ctx.arc(node.x, node.y, r + 2.45 / scale, 0, 6.2832); ctx.stroke(); } + // Labels are deferred to onRenderFramePost so they always render above + // every node body regardless of iteration order. + ctx.globalAlpha = 1; + } + + function paintNodeLabel(node, ctx, scale) { + if (!Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + const focus = hoverSet && hoverSet.size > 1, neighbor = focus && hoverSet.has(node.id); + const r = node.radius; const showLabel = (state.settings.labels && labelIds.has(node.id)) || node.id === hilite || neighbor; if (showLabel && scale > 0.35) { - // The dashboard setting is a screen-space font size. As on the classic renderer, - // compensate only for graph zoom; an extra artistic divisor makes a configured 12px - // label unreadable at normal zoom and makes the Font size control misleading. - const size = Math.max(2, state.settings.font / scale); - ctx.font = '500 ' + size + 'px system-ui, sans-serif'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = 'rgba(0,0,0,.5)'; - ctx.fillText(nodeName(node), node.x + r + 1.6 + 0.3, node.y + 0.3); - // Node names sit directly on the canvas, so Classic's light and sepia themes need - // the dashboard-resolved text colour just like relation and collapsed-cluster labels. - ctx.fillStyle = state.themeColors.label || (node.id === hilite ? '#ffffff' : 'rgba(232,236,245,.86)'); - ctx.fillText(nodeName(node), node.x + r + 1.6, node.y); + pendingLabels.push({ + x: node.x + r + 1.6, y: node.y, r, text: nodeName(node), + isHilite: node.id === hilite, scale, + }); } ctx.globalAlpha = 1; } @@ -1602,31 +7778,605 @@ /* Large graphs settle harder, exactly as the classic path does (`GPERF.large?.055:.035`). Shared so reheat() and freeze() cannot drift back to the small-graph constant. */ function alphaDecay() { return large ? 0.055 : 0.035; } + function pageHidden() { + return !!(visibilityDocument && visibilityDocument.hidden === true); + } + + function autoCollapseEligible() { + if (raw.nodes.length <= 500) return false; + /* Galaxy's O(n) kinematic fallback keeps even Complete views moving without the live + pair solver. Keep it expanded by default; an explicit Collapse control still selects + the lightweight cluster overview. */ + return state.settings.mode !== 'galaxy'; + } + + function galaxyDynamicsEligible() { + if (!hasBrowserFrameClock || destroyed || !running || pageHidden()) return false; + if (state.settings.mode !== 'galaxy' || state.settings.frozen + || state.settings.orbitPaused === true) return false; + const data = fg.graphData() || {}; + return Array.isArray(data.nodes) && data.nodes.some(node => node && !node.ghost); + } + + function resetGalaxyClock() { + galaxyLastFrameTime = null; + galaxyAccumulator = 0; + galaxyLastSubsteps = 0; + } + + function resetGalaxyDiagnostics() { + galaxyFrames = 0; + galaxySteps = 0; + galaxyLastKinetic = 0; + galaxyLastCollisions = 0; + galaxyLastRelationCorrections = 0; + galaxyLastRelationDistance = 0; + galaxyLastOrbitalRelationSkips = 0; + galaxyLastOrbitalSeparations = 0; + galaxyLastCrossSystemSeparations = 0; + galaxyLastSystemPacking = { + systems: 0, overlaps: 0, adjustedSystems: 0, remainingOverlaps: 0, + infeasiblePairs: 0, correctionDistance: 0, maximumShift: 0, + gap: GALAXY_SYSTEM_PACKING_GAP, + }; + galaxyLastOrbitalCorrection = 0; + galaxyLastLocalVelocityLimits = 0; + galaxySpeedCaps = 0; + galaxyLastBlackHoleExclusion = { + anchorId: null, contacts: 0, systems: 0, coreNodes: 0, fixedSystemNodes: 0, + repelledNodes: 0, + correctedDistance: 0, maximumShift: 0, inwardVelocityRemoved: 0, + tangentialVelocityRemoved: 0, + minimumClearance: null, + }; + galaxyLastSystemAnchorExclusion = { + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + systems: 0, contacts: 0, correctedDistance: 0, maximumShift: 0, + inwardVelocityRemoved: 0, tangentialVelocityRemoved: 0, + minimumClearance: null, iterations: 0, + }; + galaxyLastFarFieldConfinement = { + anchorId: null, envelopeRadius: 0, softRadius: 0, + acceleratedSystems: 0, boundedSystems: 0, boundedCoreNodes: 0, + boundedFixedSource: 0, boundedFixedFollowers: 0, boundedDeformedSystems: 0, + boundedOversizedNodes: 0, + correctedDistance: 0, maximumShift: 0, outwardVelocityRemoved: 0, + tangentialVelocityRemoved: 0, + annulus: { anchorId: null, innerCorrectedNodes: 0, outerCorrectedNodes: 0, + infeasibleNodes: 0 }, + }; + galaxyLastFarFieldGravity = { + anchorId: null, envelopeRadius: 0, softRadius: 0, samples: 0, + acceleratedSystems: 0, acceleratedCoreNodes: 0, acceleratedFixedFollowers: 0, + maximumAcceleration: 0, + }; + galaxyReheatStepsRemaining = 0; + galaxyReheatActivations = 0; + galaxyReheatStepsApplied = 0; + galaxyLastReheatSubsteps = 0; + galaxyKinematicSteps = 0; + galaxyLastMutualGravity = { + systems: 0, interactions: 0, traversals: 0, approximations: 0, + maximumAcceleration: 0, capScale: 1, + }; + galaxyLastSystemGravity = { + systems: 0, anchors: 0, satellites: 0, repulsions: 0, surfaceRepulsions: 0, + maximumRepulsion: 0, maximumSampledAttraction: 0, maximumNetRepulsion: 0, + minimumSurfaceNetRepulsion: null, + repulsionPadding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + repulsionRange: GALAXY_SYSTEM_ANCHOR_REPULSION_RANGE, + repulsionAcceleration: GALAXY_SYSTEM_ANCHOR_REPULSION_ACCELERATION, + maximumAcceleration: 0, capScale: 1, + }; + galaxyLastGravityResponse = { + systems: 0, moved: 0, ratio: 1, maximumShift: 0, anchorId: null, + }; + galaxyLastSpacetime = { + anchorId: null, systems: 0, coreNodes: 0, warpedNodes: 0, + maximumWarp: 0, maximumFrameDragAcceleration: 0, + maximumHorizonAcceleration: 0, tidalSystems: 0, tidalPlanets: 0, + maximumTidalAcceleration: 0, + }; + galaxyLastEventHorizonDecay = { + anchorId: null, systems: 0, nodes: 0, maximumWarp: 0, + maximumVelocityRemoved: 0, + }; + galaxyLastCarrierOrbitSupport = { + anchorId: null, eligible: 0, supported: 0, coreEligible: 0, coreSupported: 0, + minTangentialSpeed: null, coreMinTangentialSpeed: null, + maximumRadialSpeed: 0, maximumVelocityCorrection: 0, corrected: 0, + meanAngularVelocity: 0, + }; + resetGalaxyClock(); + } + + function cancelGalaxyDynamics(resetClock = true) { + cancelFrame(galaxyFrame); + galaxyFrame = 0; + if (resetClock) resetGalaxyClock(); + } + + function galaxyIntegratorOptions() { + const orbitScale = galaxyRelationOrbitScale(state.settings.link); + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(state.settings.repel); + /* The repurposed control owns angular velocity; keep the physical contact cushion neutral. */ + const orbitalSeparationPadding = galaxyOrbitalSeparationPadding( + GALAXY_ORBITAL_SEPARATION_BASE_SETTING); + const orbitalSeparationStrength = galaxyOrbitalSeparationStrength( + GALAXY_ORBITAL_SEPARATION_BASE_SETTING); + return { + fixedNodeId: activeDragNode ? activeDragNode.id : null, + orbitalSpeed: state.settings.repel, + layoutSeed: raw.meta && raw.meta.layout_seed !== undefined ? raw.meta.layout_seed : 0, + dragSource: activeDragNode, + dragFollowers, + dragSoftening: activeDragNode ? Math.max(GALAXY_DRAG_GRAVITY_SOFTENING, + finitePositive(activeDragNode.radius, 2, 160) * 1.5) : GALAXY_DRAG_GRAVITY_SOFTENING, + gravity: state.settings.gravity, + gravitationalConstant: galaxyPhysicsMultiplier( + state.settings.gravitationalConstant, GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), + localGravitationalConstant: galaxyPhysicsMultiplier( + state.settings.localGravitationalConstant, + GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), + blackHoleMass: galaxyPhysicsMultiplier( + state.settings.blackHoleMass, GALAXY_BLACK_HOLE_MASS_MULTIPLIER, 16), + softening: galaxyLiveSoftening(), + centralSoftening: Math.max(36, galaxySoftening() * 5), + bridgeSoftening: Math.max(24, galaxySoftening() * 4), + exactLimit: GALAXY_EXACT_LIMIT, + theta: GALAXY_BARNES_HUT_THETA, + localPairFraction: GALAXY_LOCAL_PAIR_FRACTION, + corePairMultiplier: GALAXY_CORE_PAIR_MULTIPLIER, + /* Evidence bridges remain exported and independently testable, but are not another + live gravity source. On real 24-system scenes even a 0.35-scaled bridge field added + enough non-central energy to eject outer systems from the black-hole potential. */ + includeBridges: false, + /* Every external solar system feels a weak mass-aware field from the others. This is + independent of evidence links; inverse-square distance naturally favors neighbors, + while the black-hole potential remains the dominant galaxy-wide force. */ + includeMutualSystems: true, + mutualSystemGravityFraction: GALAXY_MUTUAL_SYSTEM_GRAVITY_FRACTION, + mutualSystemSoftening: GALAXY_MUTUAL_SYSTEM_SOFTENING, + /* Only same-community live relations become springs. Their bounded response makes Link + distance a real tight/loose control without letting a cross-system evidence edge pull + two solar systems out of the black-hole hierarchy. */ + includeRelations: true, + /* Star/planet edges describe topology, not a second radial potential. The selected + dominant node owns that orbit; non-anchor relations retain the Link control. */ + skipSystemAnchorRelations: true, + /* Server-authored systems give every member the same explicit anchor id. Keep all of + those evidence links painted, but let the hierarchy's central potential—not Link + PBD—own every orbital radius inside that system. */ + skipOrbitalSystemRelations: true, + /* Hooke acceleration is the cohesive topology force; its existing force and + acceleration caps keep dense hubs bounded. Authored star/planet links remain skipped + so stellar gravity owns orbital radii. The later contractive PBD pass is only the + finite-distance safety net for a pathological large error. */ + includeRelationSprings: true, + orbitScale, + linkSetting: state.settings.link, + relationStrengthMultiplier: GALAXY_RELATION_STRENGTH_MULTIPLIER, + relationForceCap: GALAXY_RELATION_FORCE_CAP, + relationAccelerationCap: GALAXY_RELATION_ACCELERATION_CAP, + /* PBD uses one contractive exponential response. Scaling the completed displacement + above one would cross the target and ping-pong on the next frame. */ + relationConstraintStrengthMultiplier: + GALAXY_RELATION_CONSTRAINT_STRENGTH_MULTIPLIER * 0.18 + * galaxyPhysicsMultiplier(state.settings.springStiffness, + GALAXY_SPRING_STIFFNESS_MULTIPLIER, 8), + relationConstraintResponseMultiplier: + GALAXY_RELATION_CONSTRAINT_RESPONSE_MULTIPLIER, + relationConstraintRate: GALAXY_RELATION_CONSTRAINT_RATE, + relationConstraintMaxCorrection: GALAXY_RELATION_CONSTRAINT_MAX_CORRECTION, + /* Link and separation must share one lower bound. Independent targets made Link pull + inward and Orbital separation push outward on every tick, which looked exactly like + repeated reheating even though D3 was off. */ + relationPadding: Math.max(1.5, orbitalSeparationPadding), + /* The explicit local pressure is what makes Orbital separation visible. Its response + and target cushion are both 2x the retired normalized control. */ + includeOrbitalSeparation: true, + orbitalSeparationPadding, + orbitalSeparationStrength, + crossCommunitySeparationPadding: GALAXY_CROSS_SYSTEM_REPULSION_PADDING, + /* Complete system envelopes own cross-community clearance below. Leaving node-pair + pressure active at the same time double-corrects dense contacts and produces the + visible jitter/reheating that rigid carrier translation is meant to eliminate. */ + crossCommunitySeparationStrength: 0, + /* A pointer-owned source must be the only moving layout authority. Re-packing every + other complete envelope during a drag can move an unrelated system sideways or away + from the dragged mass, masking the bounded gravitational follower field. */ + /* Authored Galaxy scenes are admitted to non-intersecting co-rotating rings once. + Repacking those managed carriers during their orbit causes visible teleportation. */ + includeSystemPacking: false, + systemPackingGap: GALAXY_SYSTEM_PACKING_GAP, + systemPackingStrength: GALAXY_SYSTEM_PACKING_STRENGTH, + systemPackingMaxCorrection: GALAXY_SYSTEM_PACKING_MAX_CORRECTION, + /* Dense hubs sample one immutable phase and receive at most one bounded correction + per frame, irrespective of how many members touch them. */ + orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, + /* Contacts must not erase a planet's tangential phase. The dominant-star surface + handles that hard minimum; generic pressure remains active for non-anchor pairs. */ + preserveLocalTangentialVelocity: true, + /* Dense planet/planet contacts resolve along each declared stellar orbit instead of + pumping the system radially outward. The manifold projection is mass-balanced and + keeps a pointer-owned dominant star as its external fixed frame. */ + preserveSystemRadii: true, + skipSystemAnchorPairs: true, + systemAnchorExclusionPadding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + systemAnchorRepulsionRange: GALAXY_SYSTEM_ANCHOR_REPULSION_RANGE, + systemAnchorRepulsionAcceleration: GALAXY_SYSTEM_ANCHOR_REPULSION_ACCELERATION, + /* The black-hole contact is independent of the adjustable local separation pressure. + It is always strong enough to keep painted geometry outside the event horizon. */ + includeBlackHoleExclusion: true, + blackHoleExclusionPadding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING, + /* The outer well is intentionally scene-seeded, not coupled to a slider. A cached + envelope makes its threshold deterministic across normal frames and drag release. */ + includeFarFieldConfinement: true, + farFieldEnvelopeScale: GALAXY_FAR_FIELD_ENVELOPE_SCALE, + farFieldMinimumRadius: GALAXY_FAR_FIELD_MIN_RADIUS, + farFieldSoftFraction: GALAXY_FAR_FIELD_SOFT_FRACTION, + farFieldAcceleration: GALAXY_FAR_FIELD_ACCELERATION, + farFieldMaxAcceleration: GALAXY_FAR_FIELD_MAX_ACCELERATION, + localRelativeSpeedLimit: GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, + timestep: GALAXY_FIXED_TIMESTEP, + /* The render loop consumes one fixed 30 Hz physical slice per substep. Passing that + wall-clock slice explicitly keeps convergence identical after a throttled render + frame is split into several steps. */ + /* Black-hole gravity and the supported carrier tangent advance a bounded orbit. + Monotone inward projection destroys angular momentum and re-stacks clear lanes. */ + inwardConvergence: false, + wallClockSeconds: GALAXY_FRAME_INTERVAL_MS / 1000, + velocityDecay: GALAXY_VELOCITY_DECAY + * galaxyPhysicsMultiplier(state.settings.damping, 1, 100), + includeSpacetime: true, + frameDraggingFraction: GALAXY_FRAME_DRAGGING_FRACTION, + frameDraggingMaxAcceleration: GALAXY_FRAME_DRAGGING_MAX_ACCELERATION, + eventHorizonInfluenceScale: GALAXY_EVENT_HORIZON_INFLUENCE_SCALE, + eventHorizonDecayRate: GALAXY_EVENT_HORIZON_DECAY_RATE, + eventHorizonInwardAcceleration: GALAXY_EVENT_HORIZON_INWARD_ACCELERATION, + tidalStrengthFraction: GALAXY_TIDAL_STRENGTH_FRACTION, + tidalAccelerationCap: GALAXY_TIDAL_ACCELERATION_CAP, + /* The legacy limit is derived from link distance (14.4 at Galaxy defaults) and can + clamp an otherwise valid inner orbit. Common-scaling every body then strips angular + momentum from the entire disk. The physical solver uses only the true emergency cap. */ + speedLimit: MAX_NODE_SPEED, + /* The smooth local potential prevents singular packing. Even an energy-dissipating + projection can repeatedly remap phase space in a densely overlapping real scene, so + collision remains an optional helper rather than part of the persistent clock. */ + includeCollisions: false, + collisionPadding: 1.5, + collisionStrength: 0.7, + collisionIterations: 1, + }; + } + + function physicsDiagnostics() { + const data = fg.graphData() || {}; + const orbitalSpeed = galaxyOrbitalSpeedMultiplier(state.settings.repel); + const diagnosticAnchor = galaxyGlobalAnchor(data.nodes || []); + return Object.assign(galaxyMotionDiagnostics(data.nodes || []), { + mode: state.settings.mode, + running, + frozen: state.settings.frozen === true, + staticLayout: staticFullLayout, + renderedNodes: (data.nodes || []).length, + renderedLinks: (data.links || []).length, + galaxyLiveNodeLimit: GALAXY_LIVE_NODE_LIMIT, + galaxyLiveLinkLimit: GALAXY_LIVE_LINK_LIMIT, + withinGalaxyLiveLimit: galaxySceneWithinLiveLimit(data), + /* Large paint omits decorative material work while the bounded physical solver can + remain live when motion is enabled. */ + largeRenderTier: materialLow, + collapsed, + kinematicFallback: staticFullLayout || collapsed, + oversizedKinematic: staticFullLayout, + reducedMotion: reduced(), + hidden: pageHidden(), + orbitPaused: state.settings.orbitPaused === true, + dragging: activeDragNode ? activeDragNode.id : null, + /* Every live body is admitted to the pointer-owned gravity field. Relation and local + annotations remain visible here, but topology never gates the physical response. */ + dragFollowers: dragFollowers.map(follower => follower.node.id), + dragFollowerGravity: { ...dragFollowerGravityReport }, + gravitySetting: state.settings.gravity, + globalGravityFloorSetting: GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, + globalGravityFloorActive: state.settings.gravity < GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, + /* The two normalized controls are independent: G_center owns black-hole and + inter-system motion, while G_star scales the calibrated dominant-star wells. */ + gravitationalConstant: galaxyPhysicsMultiplier(state.settings.gravitationalConstant, + GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), + G_center: galaxyPhysicsMultiplier(state.settings.gravitationalConstant, + GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), + localGravitationalConstant: galaxyPhysicsMultiplier( + state.settings.localGravitationalConstant, + GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), + G_star: galaxyPhysicsMultiplier(state.settings.localGravitationalConstant, + GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), + globalAnchorId: diagnosticAnchor ? diagnosticAnchor.id : null, + globalAnchorLabel: diagnosticAnchor ? nodeName(diagnosticAnchor) : null, + blackHoleSpinAngle: diagnosticAnchor ? galaxyBlackHoleSpinAngle(diagnosticAnchor) : 0, + blackHoleMass: galaxyPhysicsMultiplier(state.settings.blackHoleMass, + GALAXY_BLACK_HOLE_MASS_MULTIPLIER, 16), + damping: galaxyPhysicsMultiplier(state.settings.damping, 1, 100), + springStiffness: galaxyPhysicsMultiplier(state.settings.springStiffness, + GALAXY_SPRING_STIFFNESS_MULTIPLIER, 8), + effectiveGravity: galaxyBlackHoleGravityConstant(state.settings.gravity, true) + * galaxyPhysicsMultiplier(state.settings.gravitationalConstant, + GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), + blackHoleGravity: galaxyBlackHoleGravityConstant(state.settings.gravity, true), + localGravity: galaxyLocalGravityConstant(state.settings.gravity), + effectiveLocalGravity: galaxyStellarGravityConstant(state.settings.gravity) + * galaxyPhysicsMultiplier(state.settings.localGravitationalConstant, + GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), + immediateGravityResponse: { ...galaxyLastGravityResponse }, + systemGravity: { ...galaxyLastSystemGravity }, + mutualSystemGravity: { ...galaxyLastMutualGravity }, + spacetime: { ...galaxyLastSpacetime }, + tidal: { + systems: galaxyLastSpacetime.tidalSystems || 0, + planets: galaxyLastSpacetime.tidalPlanets || 0, + maximumAcceleration: galaxyLastSpacetime.maximumTidalAcceleration || 0, + }, + eventHorizonDecay: { ...galaxyLastEventHorizonDecay }, + carrierOrbitSupport: { ...galaxyLastCarrierOrbitSupport }, + coreOrbitSupport: { + eligible: galaxyLastCarrierOrbitSupport.coreEligible || 0, + supported: galaxyLastCarrierOrbitSupport.coreSupported || 0, + minTangentialSpeed: galaxyLastCarrierOrbitSupport.coreMinTangentialSpeed, + }, + linkSetting: state.settings.link, + relationOrbitScale: galaxyRelationOrbitScale(state.settings.link), + relationStrengthMultiplier: GALAXY_RELATION_STRENGTH_MULTIPLIER, + relationForceCap: GALAXY_RELATION_FORCE_CAP, + relationAccelerationCap: GALAXY_RELATION_ACCELERATION_CAP, + relationConstraintStrengthMultiplier: + GALAXY_RELATION_CONSTRAINT_STRENGTH_MULTIPLIER * 0.18 + * galaxyPhysicsMultiplier(state.settings.springStiffness, + GALAXY_SPRING_STIFFNESS_MULTIPLIER, 8), + relationConstraintResponseMultiplier: + GALAXY_RELATION_CONSTRAINT_RESPONSE_MULTIPLIER, + relationConstraintMaxCorrection: + GALAXY_RELATION_CONSTRAINT_MAX_CORRECTION, + orbitalSpeedSetting: state.settings.repel, + orbitalSpeedMultiplier: orbitalSpeed, + orbitalRadiusMultiplier: galaxyOrbitalRadiusMultiplier(state.settings.repel), + /* Compatibility diagnostics retain the old names for saved-view tooling. */ + orbitalSeparationSetting: state.settings.repel, + orbitalSeparationPadding: galaxyOrbitalSeparationPadding( + GALAXY_ORBITAL_SEPARATION_BASE_SETTING), + orbitalSeparationStrength: galaxyOrbitalSeparationStrength( + GALAXY_ORBITAL_SEPARATION_BASE_SETTING), + crossSystemRepulsionPadding: GALAXY_CROSS_SYSTEM_REPULSION_PADDING, + crossSystemRepulsionStrength: 0, + systemPacking: { ...galaxyLastSystemPacking }, + systemAnchorExclusionPadding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + systemAnchorRepulsionRange: GALAXY_SYSTEM_ANCHOR_REPULSION_RANGE, + systemAnchorRepulsionAcceleration: GALAXY_SYSTEM_ANCHOR_REPULSION_ACCELERATION, + systemAnchorExclusion: { ...galaxyLastSystemAnchorExclusion }, + blackHoleExclusionPadding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING, + blackHoleExclusion: { ...galaxyLastBlackHoleExclusion }, + farFieldEnvelopeScale: GALAXY_FAR_FIELD_ENVELOPE_SCALE, + farFieldMinimumRadius: GALAXY_FAR_FIELD_MIN_RADIUS, + farFieldSoftFraction: GALAXY_FAR_FIELD_SOFT_FRACTION, + farFieldAcceleration: GALAXY_FAR_FIELD_ACCELERATION, + farFieldMaxAcceleration: GALAXY_FAR_FIELD_MAX_ACCELERATION, + farFieldConfinement: { ...galaxyLastFarFieldConfinement }, + farFieldGravity: { ...galaxyLastFarFieldGravity }, + active: galaxyDynamicsEligible(), + scheduled: galaxyFrame !== 0, + frameIntervalMs: GALAXY_FRAME_INTERVAL_MS, + timestep: GALAXY_FIXED_TIMESTEP, + maxSubsteps: GALAXY_MAX_SUBSTEPS, + reheatActivations: galaxyReheatActivations, + reheatStepsRemaining: galaxyReheatStepsRemaining, + reheatStepsApplied: galaxyReheatStepsApplied, + lastReheatSubsteps: galaxyLastReheatSubsteps, + velocityDecay: GALAXY_VELOCITY_DECAY + * galaxyPhysicsMultiplier(state.settings.damping, 1, 100), + frames: galaxyFrames, + steps: galaxySteps, + kinematicSteps: galaxyKinematicSteps, + lastSubsteps: galaxyLastSubsteps, + lastIntegratorKinetic: galaxyLastKinetic, + lastCollisions: galaxyLastCollisions, + lastRelationCorrections: galaxyLastRelationCorrections, + lastRelationCorrectionDistance: galaxyLastRelationDistance, + lastOrbitalSystemRelationSkips: galaxyLastOrbitalRelationSkips, + lastOrbitalSeparations: galaxyLastOrbitalSeparations, + lastCrossSystemSeparations: galaxyLastCrossSystemSeparations, + lastOrbitalCorrectionDistance: galaxyLastOrbitalCorrection, + lastLocalVelocityLimits: galaxyLastLocalVelocityLimits, + localRelativeSpeedLimit: GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, + systemOrbitSeedSpeedLimit: GALAXY_SYSTEM_ORBIT_SEED_SPEED_LIMIT, + speedCapActivations: galaxySpeedCaps, + }); + } + + function runGalaxyFrame(timestamp) { + galaxyFrame = 0; + if (!galaxyDynamicsEligible()) { + resetGalaxyClock(); + return; + } + const now = Number.isFinite(timestamp) + ? timestamp + : (window.performance && typeof window.performance.now === 'function' + ? window.performance.now() : Date.now()); + /* The first visible frame receives one ordinary step, never the wall time accumulated + while a tab was hidden, the graph was frozen, or a pointer owned a node. */ + if (galaxyLastFrameTime === null) { + galaxyLastFrameTime = now; + galaxyAccumulator = GALAXY_FRAME_INTERVAL_MS; + } else { + const elapsed = Math.max(0, Math.min( + GALAXY_FRAME_INTERVAL_MS * GALAXY_MAX_SUBSTEPS, + now - galaxyLastFrameTime + )); + galaxyLastFrameTime = now; + galaxyAccumulator = Math.min( + GALAXY_FRAME_INTERVAL_MS * GALAXY_MAX_SUBSTEPS, + galaxyAccumulator + elapsed + ); + } + const ordinarySubsteps = Math.min(GALAXY_MAX_SUBSTEPS, + Math.floor((galaxyAccumulator + 1e-9) / GALAXY_FRAME_INTERVAL_MS)); + /* Galaxy is already live. Reheat must never add fixed slices or fast-forward time, even + if a future caller accidentally leaves a stale non-zero budget in the telemetry slot. */ + const reheatSubsteps = 0; + const substeps = ordinarySubsteps + reheatSubsteps; + galaxyLastSubsteps = substeps; + galaxyLastReheatSubsteps = reheatSubsteps; + if (substeps > 0) { + galaxyPhaseRestorePending = false; + const data = fg.graphData() || { nodes: [], links: [] }; + for (let index = 0; index < substeps; index++) { + const kinematicFallback = staticFullLayout || collapsed; + const report = kinematicFallback + ? advanceGalaxyKinematicOrbits(data.nodes || [], galaxyIntegratorOptions()) + : integrateGalaxyLeapfrog( + data.nodes || [], data.links || [], raw.community_bridges || [], + galaxyIntegratorOptions() + ); + if (!kinematicFallback) { + report.orbitalSpeed = applyGalaxyOrbitalSpeedControl( + data.nodes || [], galaxyIntegratorOptions()); + } + galaxySteps++; + if (kinematicFallback) { + galaxyKinematicSteps++; + galaxyLastKinetic = galaxyMotionDiagnostics(data.nodes || []).kineticEnergy; + galaxyLastCollisions = 0; + galaxyLastRelationCorrections = 0; + galaxyLastRelationDistance = 0; + galaxyLastOrbitalRelationSkips = 0; + galaxyLastOrbitalSeparations = 0; + galaxyLastCrossSystemSeparations = 0; + galaxyLastSystemPacking = report.systemPacking || galaxyLastSystemPacking; + galaxyLastOrbitalCorrection = 0; + galaxyLastLocalVelocityLimits = 0; + } else { + galaxyLastKinetic = report.kinetic; + galaxyLastCollisions = report.collisions; + galaxyLastRelationCorrections = report.relationConstraint.applied; + galaxyLastRelationDistance = report.relationConstraint.correctedDistance; + galaxyLastOrbitalRelationSkips = report.relationConstraint.skippedOrbitalSystem || 0; + galaxyLastOrbitalSeparations = report.orbitalSeparation.overlaps; + galaxyLastCrossSystemSeparations = + report.orbitalSeparation.crossCommunityOverlaps || 0; + galaxyLastSystemPacking = report.systemPacking || galaxyLastSystemPacking; + galaxyLastOrbitalCorrection = report.orbitalSeparation.correctionDistance; + galaxyLastSystemAnchorExclusion = report.systemAnchorExclusion; + galaxyLastBlackHoleExclusion = report.blackHoleExclusion; + galaxyLastFarFieldConfinement = report.farFieldConfinement; + galaxyLastFarFieldGravity = report.farFieldGravity; + galaxyLastLocalVelocityLimits = report.systemVelocity.limitedSystems; + galaxyLastSystemGravity = report.systemGravity; + galaxyLastMutualGravity = report.mutualGravity; + galaxyLastSpacetime = report.spacetime; + galaxyLastEventHorizonDecay = report.eventHorizonDecay; + galaxyLastCarrierOrbitSupport = report.carrierOrbitSupport + || galaxyLastCarrierOrbitSupport; + dragFollowerGravityReport = report.dragGravity; + if (report.speedCapped) galaxySpeedCaps++; + } + } + galaxyAccumulator = Math.max(0, + galaxyAccumulator - ordinarySubsteps * GALAXY_FRAME_INTERVAL_MS); + galaxyReheatStepsRemaining = Math.max(0, + galaxyReheatStepsRemaining - reheatSubsteps); + galaxyReheatStepsApplied += reheatSubsteps; + galaxyFrames++; + invalidate(); + if (typeof opts.onPhysics === 'function') opts.onPhysics(physicsDiagnostics()); + if (typeof opts.onPhysicsFrame === 'function') opts.onPhysicsFrame(api.getPhysicsSnapshot()); + } + if (galaxyDynamicsEligible()) galaxyFrame = requestFrame(runGalaxyFrame); + } + + function scheduleGalaxyDynamics(resetClock = false) { + if (resetClock) resetGalaxyClock(); + if (!galaxyDynamicsEligible()) { + cancelGalaxyDynamics(resetClock); + return; + } + if (!galaxyFrame) galaxyFrame = requestFrame(runGalaxyFrame); + } + + function setGalaxySeedFlag(node, name, value) { + if (!value) { + delete node[name]; + return; + } + Object.defineProperty(node, name, { + value: true, writable: true, configurable: true, enumerable: false + }); + } + + function saveGalaxyPhase() { + raw.nodes.forEach(node => { + if (!Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + galaxySavedPhase.set(node.id, { + x: node.x, y: node.y, + vx: Number.isFinite(node.vx) ? node.vx : 0, + vy: Number.isFinite(node.vy) ? node.vy : 0, + orbitSeeded: node.__galaxyOrbitSeeded === true, + systemOrbitSeeded: node.__galaxySystemOrbitSeeded === true, + }); + }); + } + + function restoreGalaxyPhase() { + raw.nodes.forEach(node => { + const saved = galaxySavedPhase.get(node.id); + const server = galaxyServerPhase.get(node.id); + const phase = saved || server; + node.x = phase && Number.isFinite(phase.x) ? phase.x : undefined; + node.y = phase && Number.isFinite(phase.y) ? phase.y : undefined; + node.vx = saved && Number.isFinite(saved.vx) ? saved.vx : 0; + node.vy = saved && Number.isFinite(saved.vy) ? saved.vy : 0; + node.fx = undefined; + node.fy = undefined; + setGalaxySeedFlag(node, '__galaxyOrbitSeeded', !!(saved && saved.orbitSeeded)); + setGalaxySeedFlag( + node, '__galaxySystemOrbitSeeded', !!(saved && saved.systemOrbitSeeded) + ); + }); + ensureGalaxyPositions(raw.nodes, raw.meta && raw.meta.layout_seed); + } + + function transitionGalaxyMode(previousMode, nextMode) { + if (previousMode === nextMode) return; + cancelGalaxyDynamics(true); + if (previousMode === 'galaxy') saveGalaxyPhase(); + if (nextMode === 'galaxy') { + /* A legacy settings timer must not fire after Galaxy takes ownership and reset D3's + countdown underneath the fixed clock. Lowering an existing target is not a wake. */ + const hadSoftAlphaTimer = softAlphaTimer !== 0; + clearTimeout(softAlphaTimer); + softAlphaTimer = 0; + if (hadSoftAlphaTimer && typeof fg.d3AlphaTarget === 'function') fg.d3AlphaTarget(0); + restoreGalaxyPhase(); + galaxyPhaseRestorePending = true; + } + /* Never hand force-graph the array that the other integrator mutated. A fresh visible() + projection preserves object identity for nodes but prevents its cached legacy cluster + or link endpoint objects from contaminating the restored phase space. */ + seeded = null; + fullLayoutDirty = true; + } // Rendering while frozen deliberately gives force-graph a one-tick budget. Keep the // matching live values in one place so unfreezing after a style, scope, or data render // cannot reheat against that stale one-tick budget. - function setSimulationBudget(live) { + function setSimulationBudget(live, fullyStopped = false) { const simulate = live && !staticFullLayout; if (fg.cooldownTime) fg.cooldownTime(simulate ? (large ? 1100 : 2200) : 0); - if (fg.cooldownTicks) fg.cooldownTicks(simulate ? (large ? 80 : 160) : 1); + if (fg.cooldownTicks) fg.cooldownTicks( + simulate ? (large ? 80 : 160) : (fullyStopped ? 0 : 1) + ); if (fg.warmupTicks) fg.warmupTicks(simulate ? (large ? 18 : 40) : 0); } - function setDragSimulationBudget(active) { - if (active && !staticFullLayout && !state.settings.frozen) { - /* Never use an unbounded drag budget. The scoped forces above keep the gesture - responsive without allowing a long pointer hold to become an infinite reheat. */ - if (fg.cooldownTime) fg.cooldownTime(5000); - if (fg.cooldownTicks) fg.cooldownTicks(260); - if (fg.warmupTicks) fg.warmupTicks(0); - if (fg.d3AlphaDecay) fg.d3AlphaDecay(0.08); - return; - } - setSimulationBudget(!staticFullLayout && !state.settings.frozen); - if (fg.d3AlphaDecay) fg.d3AlphaDecay(staticFullLayout ? 1 : alphaDecay()); - } - - function prepareReheat() { const nodes = fg.graphData().nodes || []; nodes.forEach(node => { @@ -1654,7 +8404,7 @@ fg.resetCountdown(); } - function softReheat(dragging = false) { + function softReheat() { if (!supportsSoftAlpha()) { /* Keep the dependency-light Node harness and older vendor bundles working. The real browser bundle takes the bounded alpha-target path above. */ @@ -1663,12 +8413,22 @@ } clearTimeout(softAlphaTimer); softAlphaTimer = 0; - fg.d3AlphaTarget(dragging || activeDragNode ? DRAG_ALPHA_TARGET : SETTINGS_ALPHA_TARGET); + fg.d3AlphaTarget(SETTINGS_ALPHA_TARGET); fg.resetCountdown(); softAlphaTimer = setTimeout(() => { softAlphaTimer = 0; if (!destroyed && !activeDragNode) releaseSoftAlpha(); - }, dragging || activeDragNode ? DRAG_SETTLE_DELAY_MS : ALPHA_TARGET_HOLD_MS); + }, ALPHA_TARGET_HOLD_MS); + } + + function cancelSoftAlphaForDrag() { + if (!softAlphaTimer) return; + clearTimeout(softAlphaTimer); + softAlphaTimer = 0; + /* Lowering an already-active target cannot wake the simulation and needs no countdown + reset. Without this cancellation, a 180 ms settings timer can fire just after pointer + release and make an otherwise localized drag appear to reheat the whole galaxy. */ + if (typeof fg.d3AlphaTarget === 'function') fg.d3AlphaTarget(0); } function schedulePhysicsUpdate() { @@ -1707,15 +8467,23 @@ const reused = sameData(seeded, next); const data = reused ? seeded : next; const fullGraph = state.renderMode === 'full'; - staticFullLayout = fullGraph - && (data.nodes.length > FULL_FORCE_NODE_LIMIT || data.links.length > FULL_FORCE_LINK_LIMIT); + const galaxyMode = state.settings.mode === 'galaxy'; + const wasStatic = staticFullLayout; + const overGalaxyLiveLimit = !galaxySceneWithinLiveLimit(data); + const overFullForceLimit = data.nodes.length > FULL_FORCE_NODE_LIMIT + || data.links.length > FULL_FORCE_LINK_LIMIT; + staticFullLayout = galaxyMode + ? overGalaxyLiveLimit + : fullGraph && overFullForceLimit; materialLow = data.nodes.length > LARGE_NODE_LIMIT || data.links.length > LARGE_LINK_LIMIT; large = fullGraph || data.nodes.length > LARGE_NODE_LIMIT || data.links.length > LARGE_LINK_LIMIT; dense = data.links.length > DENSE_LINK_LIMIT; const sizeMetric = n => state.sizeBy === 'betweenness' ? (n.betweenness || 0) : ((n.degree || 0) / Math.max(1, maxDeg)); data.nodes.forEach(n => { const base = (state.settings.size || 3); - n.radius = graphNodeRadius(n, base, sizeMetric(n)); + n.radius = galaxyMode + ? evidenceNodeRadius(n, base) + : graphNodeRadius(n, base, sizeMetric(n)); n.color = nodeColor(n); n.stroke = contrastOn(n.color); }); @@ -1728,25 +8496,203 @@ .slice(0, labelCap) .map(n => n.id)); applyChrome(); + /* graphData() synchronously runs configured warmup ticks. Detach the legacy simulation + before handing it restored Galaxy coordinates, or Compact's old link/charge field gets + one last chance to corrupt the physical phase before the custom clock even starts. */ + if (galaxyMode) disableD3GalaxyIntegration(); if (!reused) { if (staticFullLayout) { - pinFullGraphLayout(data); + if (galaxyMode) { + pinGalaxySceneLayout(data); + /* Oversized Galaxy scenes skip the live admission branch, but their direct + black-hole children still need compact core lanes before the O(n) kinematic + clock starts. Keep the nodes pinned to the newly admitted coordinates. */ + markGalaxyBlackHoleChildren(data.nodes, data.links); + seedGalaxyOrbits( + data.nodes, raw.meta && raw.meta.layout_seed, + state.settings.gravity, galaxyLiveSoftening(), reducedMotion, + { fixedNodeId: activeDragNode ? activeDragNode.id : null, + restorePhase: galaxyPhaseRestorePending, + coreOnly: true, + orbitalSpeed: state.settings.repel, + gravitationalConstant: state.settings.gravitationalConstant, + localGravitationalConstant: state.settings.localGravitationalConstant } + ); + } else pinFullGraphLayout(data); fullLayoutDirty = false; + } else if (galaxyMode) { + /* Canonical v5 scenes already carry compact deterministic coordinates. Compatibility + payloads and direct embeds may not: D3 is intentionally disabled in Galaxy mode, + so fill only those missing positions before the one-shot orbital seed. Finite + server coordinates are preserved byte-for-byte by ensureGalaxyPositions(). */ + ensureGalaxyPositions(data.nodes, raw.meta && raw.meta.layout_seed); + releasePinnedPositions(data); + markGalaxyBlackHoleChildren(data.nodes, data.links); + /* Fresh server coordinates may contain dozens of mutually intersecting complete + systems. Pack them once in open space before any carrier velocity or finite outer + envelope is cached; the later field is then sized from the already-clear scene. */ + const authoredGalaxy = data.nodes.some(node => node.anchor_role === 'global') + && data.nodes.filter(node => node.anchor_role === 'community').length > 1; + if (authoredGalaxy) { + establishGalaxyCarrierLanes(data.nodes, { + gap: GALAXY_SYSTEM_PACKING_GAP, + layoutSeed: raw.meta && raw.meta.layout_seed, + }); + galaxyLastSystemPacking = applyGalaxySystemPacking(data.nodes, { + gap: GALAXY_SYSTEM_PACKING_GAP, + strength: 1, + maxCorrection: Infinity, + respectFixedCoordinates: false, + }); + } + seedGalaxyOrbits( + data.nodes, raw.meta && raw.meta.layout_seed, + state.settings.gravity, galaxyLiveSoftening(), reducedMotion, + { fixedNodeId: activeDragNode ? activeDragNode.id : null, + restorePhase: galaxyPhaseRestorePending, + orbitalSpeed: state.settings.repel, + gravitationalConstant: state.settings.gravitationalConstant, + localGravitationalConstant: state.settings.localGravitationalConstant } + ); + seedGalaxySystemOrbits( + data.nodes, raw.meta && raw.meta.layout_seed, + state.settings.gravity, Math.max(36, galaxySoftening() * 5), reducedMotion, + { gravitationalConstant: state.settings.gravitationalConstant, + blackHoleMass: state.settings.blackHoleMass, + orbitalSpeed: state.settings.repel } + ); } else clearPinnedPositions(data); + /* graphData() may paint synchronously. Enforce the event horizon after every layout + seed (including the pinned oversized layout) before the vendor sees the payload. */ + if (galaxyMode) { + const prePaintHorizon = applyGalaxyBlackHoleExclusion( + data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING } + ); + const preStarExclusion = applyGalaxySystemAnchorExclusion(data.nodes, { + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + fixAnchors: true, + }); + /* Static and reused payloads do not enter the live integrator, but still paint the + same finite galaxy. Apply the exact outer extent before handing coordinates to + force-graph, then reassert the inner horizon after any inward system shift. */ + galaxyLastFarFieldConfinement = applyGalaxyFarFieldConfinement(data.nodes, { + includeFarFieldConfinement: true, + farFieldEnvelopeScale: GALAXY_FAR_FIELD_ENVELOPE_SCALE, + farFieldMinimumRadius: GALAXY_FAR_FIELD_MIN_RADIUS, + farFieldSoftFraction: GALAXY_FAR_FIELD_SOFT_FRACTION, + }); + galaxyLastFarFieldGravity = { + anchorId: galaxyLastFarFieldConfinement.anchorId, + envelopeRadius: galaxyLastFarFieldConfinement.envelopeRadius, + softRadius: galaxyLastFarFieldConfinement.softRadius, + samples: 0, acceleratedSystems: 0, acceleratedCoreNodes: 0, + acceleratedFixedFollowers: 0, maximumAcceleration: 0, + }; + const postOuterHorizon = applyGalaxyBlackHoleExclusion( + data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING } + ); + galaxyLastFarFieldConfinement.annulus = applyGalaxyAnnularBounds(data.nodes, { + includeFarFieldConfinement: true, + blackHoleExclusionPadding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING, + }); + const postStarExclusion = applyGalaxySystemAnchorExclusion(data.nodes, { + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + fixAnchors: true, + }); + galaxyLastSystemAnchorExclusion = combineGalaxySystemAnchorExclusions( + [preStarExclusion, postStarExclusion] + ); + const postStarHorizon = applyGalaxyBlackHoleExclusion( + data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING } + ); + galaxyLastBlackHoleExclusion = combineGalaxyBlackHoleExclusions( + [prePaintHorizon, postOuterHorizon, postStarHorizon] + ); + } fg.graphData(data); seeded = data; } else if (staticFullLayout && fullLayoutDirty) { - pinFullGraphLayout(data); + if (galaxyMode) pinGalaxySceneLayout(data); + else pinFullGraphLayout(data); fullLayoutDirty = false; + } else if (wasStatic && !staticFullLayout) { + releasePinnedPositions(data); + } + if (reused && galaxyMode && !staticFullLayout) { + markGalaxyBlackHoleChildren(data.nodes, data.links); + seedGalaxyOrbits( + data.nodes, raw.meta && raw.meta.layout_seed, + state.settings.gravity, galaxyLiveSoftening(), reducedMotion, + { fixedNodeId: activeDragNode ? activeDragNode.id : null, + restorePhase: galaxyPhaseRestorePending, + orbitalSpeed: state.settings.repel, + gravitationalConstant: state.settings.gravitationalConstant, + localGravitationalConstant: state.settings.localGravitationalConstant } + ); + seedGalaxySystemOrbits( + data.nodes, raw.meta && raw.meta.layout_seed, + state.settings.gravity, Math.max(36, galaxySoftening() * 5), reducedMotion, + { gravitationalConstant: state.settings.gravitationalConstant, + blackHoleMass: state.settings.blackHoleMass, + orbitalSpeed: state.settings.repel } + ); + } + /* Reused arrays bypass graphData(); size changes, static repins, and restored phases still + receive the same strict painted-edge invariant before the next redraw. */ + if (reused && galaxyMode) { + const prePaintHorizon = applyGalaxyBlackHoleExclusion( + data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING } + ); + const preStarExclusion = applyGalaxySystemAnchorExclusion(data.nodes, { + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + fixAnchors: true, + }); + galaxyLastFarFieldConfinement = applyGalaxyFarFieldConfinement(data.nodes, { + includeFarFieldConfinement: true, + farFieldEnvelopeScale: GALAXY_FAR_FIELD_ENVELOPE_SCALE, + farFieldMinimumRadius: GALAXY_FAR_FIELD_MIN_RADIUS, + farFieldSoftFraction: GALAXY_FAR_FIELD_SOFT_FRACTION, + }); + galaxyLastFarFieldGravity = { + anchorId: galaxyLastFarFieldConfinement.anchorId, + envelopeRadius: galaxyLastFarFieldConfinement.envelopeRadius, + softRadius: galaxyLastFarFieldConfinement.softRadius, + samples: 0, acceleratedSystems: 0, acceleratedCoreNodes: 0, + acceleratedFixedFollowers: 0, maximumAcceleration: 0, + }; + const postOuterHorizon = applyGalaxyBlackHoleExclusion( + data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING } + ); + galaxyLastFarFieldConfinement.annulus = applyGalaxyAnnularBounds(data.nodes, { + includeFarFieldConfinement: true, + blackHoleExclusionPadding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING, + }); + const postStarExclusion = applyGalaxySystemAnchorExclusion(data.nodes, { + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + fixAnchors: true, + }); + galaxyLastSystemAnchorExclusion = combineGalaxySystemAnchorExclusions( + [preStarExclusion, postStarExclusion] + ); + const postStarHorizon = applyGalaxyBlackHoleExclusion( + data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING } + ); + galaxyLastBlackHoleExclusion = combineGalaxyBlackHoleExclusions( + [prePaintHorizon, postOuterHorizon, postStarHorizon] + ); } applyForces(); fg.autoPauseRedraw(!needsContinuousFrames()); /* Bound the simulation the way the classic path does. Without these force-graph keeps its 15-second default window, so every load and every reheat of a large store runs the layout — and repaints every node and link — for more than ten seconds longer. */ - setSimulationBudget(motion); - if (fg.d3AlphaDecay) fg.d3AlphaDecay(staticFullLayout ? 1 : alphaDecay()); - if (fg.d3VelocityDecay) fg.d3VelocityDecay(large ? 0.45 : 0.38); + setSimulationBudget(galaxyMode ? false : motion, galaxyMode); + /* D3 is only the renderer in Galaxy mode. Its alpha, velocity decay and countdown are + intentionally untouched; the fixed-step clock owns all three physical concerns. */ + if (!galaxyMode && fg.d3AlphaDecay) fg.d3AlphaDecay(staticFullLayout ? 1 : alphaDecay()); + if (!galaxyMode && fg.d3VelocityDecay) { + fg.d3VelocityDecay(large ? 0.45 : 0.38); + } if (fg.linkCurvature) { fg.linkCurvature(dense ? 0 : ((PRESETS[state.settings.mode] || PRESETS.compact).curve || 0)); } @@ -1767,11 +8713,14 @@ .linkDirectionalParticleColor(l => alpha(layerColor(l.layer), 0.95)) .linkDirectionalParticleSpeed(l => 0.002 + ((state.settings.flowSpeed || 45) / 100) * 0.008); } - if (reheat && motion && !staticFullLayout && !state.settings.frozen) { + if (!galaxyMode && reheat && motion && !staticFullLayout && !state.settings.frozen) { prepareReheat(); - softReheat(dragging); + softReheat(); } - if ((staticFullLayout || state.settings.frozen || !motion) && fg.d3AlphaDecay) { /* keep painting, stop layout */ fg.d3AlphaDecay(1); } + if (!galaxyMode && (staticFullLayout || state.settings.frozen || !motion) + && fg.d3AlphaDecay) { /* keep painting, stop layout */ fg.d3AlphaDecay(1); } + if (galaxyMode) scheduleGalaxyDynamics(!reused || wasStatic !== staticFullLayout); + else cancelGalaxyDynamics(true); /* Nothing was reseeded, so force-graph's own change detection saw no reason to repaint — but Style, Color by and Labels all just changed how the *same* data must be drawn. */ if (reused) invalidate(); @@ -1792,51 +8741,137 @@ collapsed = false; state.collapse = false; render(false, true); - setTimeout(() => { fg.centerAt(node.x, node.y, 500); fg.zoom(1.6, 500); }, 60); + clearTimeout(clusterExpandTimer); + clusterExpandTimer = setTimeout(() => { clusterExpandTimer = 0; fg.centerAt(node.x, node.y, 500); fg.zoom(1.6, 500); }, 60); if (opts.onCollapseChange) opts.onCollapseChange(false); return; } if (opts.onNodeClick) opts.onNodeClick(node); } - function reheatLiveLayout(dragging = false) { - if (destroyed || state.settings.frozen || staticFullLayout) return; - cancelAutoFit(); - setDragSimulationBudget(dragging); - prepareReheat(); - if (fg.d3AlphaDecay) fg.d3AlphaDecay(dragging ? 0.08 : alphaDecay()); - /* Older bundles have no alpha-target countdown. They are already live during pointer - movement, so avoid a full fallback reheat on drag-start; release gets the one fallback - kick needed to settle after the temporary anchor is removed. */ - if (!dragging || supportsSoftAlpha()) softReheat(dragging); + function dragNodeEligible(node) { + return !!node && !node.ghost && !node._historyGhost + && node.static !== true && node.frozen !== true; + } + + function dragFollowerEligible(node) { + /* The evidence black hole may be the dragged primary, but it can never be displaced as + another body's follower. The fixed Galaxy step owns its origin invariant. */ + return dragNodeEligible(node) && node.anchor_role !== 'global'; + } + + /* Every live body participates in the dragged mass field. Evidence relations and local + membership annotate stronger structure, while distance alone governs unlinked bodies. + This is intentionally not a graph-neighbour filter: a nearby unlinked star must feel the + same softened gravity as a linked one, and distant systems simply receive a weaker tail. */ + function captureDragFollowers(node) { + const data = fg.graphData() || {}; + const nodes = Array.isArray(data.nodes) ? data.nodes : []; + const related = new Map(); + (Array.isArray(data.links) ? data.links : []).forEach(link => { + if (!link || link.ghost || link._historyGhost || link.static === true) return; + const source = linkEndpoint(link, 'source'); + const target = linkEndpoint(link, 'target'); + const otherId = source === node.id ? target : (target === node.id ? source : null); + if (otherId != null && !related.has(otherId)) related.set(otherId, link); + }); + const followers = []; + if (state.settings.mode === 'galaxy') nodes.forEach(other => { + if (!other || other.id === node.id + || !dragFollowerEligible(other) + || !Number.isFinite(other.x) || !Number.isFinite(other.y)) return; + const distance = Math.hypot(other.x - node.x, other.y - node.y); + const link = related.get(other.id) || null; + const proximity = link ? 'related' + : communityKey(other) === communityKey(node) ? 'system' + : distance <= GALAXY_DRAG_GRAVITY_CAPTURE_RADIUS ? 'nearby' : 'field'; + followers.push({ node: other, link, proximity, distance }); + }); + else nodes.forEach(other => { + const link = other ? related.get(other.id) : null; + if (!link || !dragFollowerEligible(other) + || !Number.isFinite(other.x) || !Number.isFinite(other.y)) return; + followers.push({ node: other, link, proximity: 'related', + distance: Math.hypot(other.x - node.x, other.y - node.y) }); + }); + return followers; + } + + function followDraggedNode(node) { + /* Re-sample proximity at the current pointer position so bodies encountered along the + path begin responding; direct relations and same-system members remain included. */ + dragFollowers = captureDragFollowers(node); + /* The fixed-step solver samples this source/follower set. Pointermove only updates the + source position and membership; it never stacks a displacement or velocity impulse. */ + dragFollowerGravityReport = { + applied: dragFollowers.length, maximumAcceleration: 0, maximumPull: 0, + }; } function beginNodeDrag(node) { - if (destroyed || state.settings.frozen || staticFullLayout) return; + if (destroyed || state.settings.frozen || staticFullLayout || !dragNodeEligible(node)) return false; + if (activeDragNode) return activeDragNode.id === node.id; setActiveDragNode(node); - setDragSimulationBudget(true); - prepareReheat(); - if (fg.d3AlphaDecay) fg.d3AlphaDecay(0.08); - if (supportsSoftAlpha()) softReheat(true); + dragFollowers = captureDragFollowers(node); + dragFollowerGravityReport = { applied: 0, maximumAcceleration: 0, maximumPull: 0 }; + /* The graph keeps evolving while the pointer owns this node. The custom integrator treats + it as a fixed moving mass source; no global force is detached and no alpha is changed. */ + cancelSoftAlphaForDrag(); + dragPreVelocity = { vx: Number.isFinite(node.vx) ? node.vx : 0, vy: Number.isFinite(node.vy) ? node.vy : 0 }; + dragReleaseVelocity = null; + node.vx = 0; + node.vy = 0; + if (state.settings.mode === 'galaxy') scheduleGalaxyDynamics(false); + return true; } function finishNodeDrag(node) { - if (!node) return; + if (!node || !activeDragNode || activeDragNode.id !== node.id) return; const retainAnchor = state.settings.frozen || staticFullLayout; if (!retainAnchor) { node.fx = undefined; node.fy = undefined; } setActiveDragNode(null); - restoreDragPhysics(); - setDragSimulationBudget(false); - node.vx = 0; - node.vy = 0; - if (!retainAnchor) reheatLiveLayout(false); + dragFollowers = []; + if (state.settings.mode === 'galaxy' && dragReleaseVelocity) { + const data = fg.graphData() || {}; + const insertion = galaxySlingshotCapture(node, data.nodes || [], + dragReleaseVelocity, { + gravity: state.settings.gravity, + localGravitationalConstant: state.settings.localGravitationalConstant, + softening: galaxyLiveSoftening(), + layoutSeed: raw.meta && raw.meta.layout_seed, + }); + node.vx = insertion.vx; + node.vy = insertion.vy; + lastSlingshotRelease = { + id: node.id, vx: node.vx, vy: node.vy, speed: Math.hypot(node.vx, node.vy), + eligible: insertion.eligible, captured: insertion.captured, + escaped: insertion.escaped, reason: insertion.reason, + starId: insertion.starId, orbitRadius: insertion.radius, + circularSpeed: insertion.circularSpeed, escapeSpeed: insertion.escapeSpeed, + }; + if (typeof opts.onSlingshotRelease === 'function') { + opts.onSlingshotRelease({ ...lastSlingshotRelease }); + } + } else if (state.settings.mode === 'galaxy' && dragPreVelocity) { + node.vx = dragPreVelocity.vx; + node.vy = dragPreVelocity.vy; + } else { + node.vx = 0; + node.vy = 0; + } + dragPreVelocity = null; + dragReleaseVelocity = null; + if (state.settings.mode === 'galaxy') { + disableD3GalaxyIntegration(); + scheduleGalaxyDynamics(false); + } } - /* A drag uses fx/fy only while the pointer is down. Pointer-up releases the anchor and gives - the live layout one bounded settle; dragging itself never reheats the simulation. */ + /* A drag uses fx/fy only while the pointer is down. The fixed-step Galaxy clock remains + live throughout the gesture; pointer-up merely releases that one moving mass source. */ fg.backgroundColor('rgba(0,0,0,0)').nodeRelSize(1) .enableNodeDrag(false).autoPauseRedraw(true) /* force-graph's default `nodeLabel`/`linkLabel` is the literal accessor "name", and its @@ -1846,6 +8881,37 @@ .nodeLabel(node => esc(nodeName(node))) .linkLabel(link => esc(link && link.label ? link.label : '')) .onRenderFramePre((ctx, scale) => { try { styleBackground(ctx, scale); } catch (e) { } }) + .onRenderFramePost((ctx, scale) => { + try { + const currentData = fg.graphData() || {}; + if (Array.isArray(currentData.nodes)) { + for (const node of currentData.nodes) paintNodeLabel(node, ctx, scale); + } + } catch (e) { /* label pass must never break the render loop */ } + const batch = pendingLabels; + pendingLabels = []; + if (!batch.length) return; + ctx.save(); + ctx.textBaseline = 'middle'; + for (const label of batch) { + if (label.cluster) { + ctx.font = '500 ' + Math.max(2.6, label.r * 0.4) + 'px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = state.themeColors.label || '#e7e9ee'; + ctx.fillText(label.text, label.x, label.y); + ctx.textAlign = 'left'; + } else { + const size = Math.max(2, state.settings.font / scale); + ctx.font = '500 ' + size + 'px system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillStyle = 'rgba(0,0,0,.5)'; + ctx.fillText(label.text, label.x + 0.3, label.y + 0.3); + ctx.fillStyle = state.themeColors.label || (label.isHilite ? '#ffffff' : 'rgba(232,236,245,.86)'); + ctx.fillText(label.text, label.x, label.y); + } + } + ctx.restore(); + }) .nodeCanvasObject((node, ctx, scale) => styleNode(node, ctx, scale)) .nodePointerAreaPaint((node, color, ctx) => { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(node.x, node.y, node.radius + 2, 0, 6.2832); ctx.fill(); }) .linkColor(l => { @@ -1890,7 +8956,7 @@ Keep auto-collapse for true zoom-out, but do not hide a freshly selected arrangement merely because its fit scale is below the old, overly eager threshold. */ const collapseThreshold = state.settings.mode === 'communities' ? 0.22 : 0.42; - const canAutoCollapse = raw.nodes.length > 500; + const canAutoCollapse = autoCollapseEligible(); const next = canAutoCollapse && zoom < collapseThreshold; if (next !== collapsed) { collapsed = next; @@ -1910,10 +8976,10 @@ fg.onNodeDragEnd(node => finishNodeDrag(node)); } - /* force-graph's built-in drag always reheats the entire simulation. Ledger treats manual - placement as a pin, so install a small scoped drag controller and leave global physics - changes to the explicit Reheat control. Capturing pointer-down prevents the vendor's drag - handler from seeing node gestures while preserving its background pan/zoom path. */ + /* force-graph's built-in drag always reheats the entire simulation. The scoped controller + instead turns one node into a moving gravity source while the existing solver stays live. + Capturing pointer-down prevents the vendor's alpha kick from seeing node gestures while + preserving its background pan/zoom path. */ let detachManualDrag = null; if (typeof window !== 'undefined' && typeof window.addEventListener === 'function' && typeof el.addEventListener === 'function' && typeof el.querySelector === 'function') { @@ -1932,7 +8998,15 @@ window.removeEventListener('pointerup', endManualDrag, true); window.removeEventListener('pointercancel', endManualDrag, true); if (current.dragged) { + /* A cancelled gesture is not a physical release. Discard the sampled pointer velocity + so finishNodeDrag restores the body's pre-drag orbital phase. */ + if (event.type === 'pointercancel') dragReleaseVelocity = null; finishNodeDrag(current.node); + // The manual controller owns this gesture. Prevent force-graph's pointer-up handler + // from applying a second release/reheat after the node has been placed exactly at the + // pointer, which is especially visible when reduced motion disables camera settling. + event.preventDefault(); + event.stopPropagation(); suppressNodeClick(); } else if (event.type !== 'pointercancel') { // Our capture listener owns the direct click. Suppress force-graph's @@ -1957,12 +9031,32 @@ manualDrag.dragged = true; started = true; } - if (started) beginNodeDrag(manualDrag.node); + if (started && !beginNodeDrag(manualDrag.node)) { + manualDrag.dragged = false; + return; + } const node = manualDrag.node; node.x = node.fx = point.x + manualDrag.offsetX; node.y = node.fy = point.y + manualDrag.offsetY; - node.vx = 0; - node.vy = 0; + const sampleTime = Number.isFinite(event.timeStamp) ? event.timeStamp : Date.now(); + const previousSample = manualDrag.lastSample; + if (previousSample && sampleTime > previousSample.time) { + const elapsed = Math.max(1, sampleTime - previousSample.time); + const rawVx = (node.x - previousSample.x) / elapsed / GALAXY_SLINGSHOT_VELOCITY_SCALE; + const rawVy = (node.y - previousSample.y) / elapsed / GALAXY_SLINGSHOT_VELOCITY_SCALE; + const speed = Math.hypot(rawVx, rawVy); + const scale = speed > GALAXY_SLINGSHOT_SPEED_LIMIT + ? GALAXY_SLINGSHOT_SPEED_LIMIT / speed : 1; + /* Low-pass two samples so a noisy final pointer event cannot create a release-only + spike. The cap remains below the solver's emergency speed limit. */ + const sampled = { vx: rawVx * scale, vy: rawVy * scale }; + dragReleaseVelocity = dragReleaseVelocity ? { + vx: dragReleaseVelocity.vx * 0.35 + sampled.vx * 0.65, + vy: dragReleaseVelocity.vy * 0.35 + sampled.vy * 0.65, + } : sampled; + } + manualDrag.lastSample = { x: node.x, y: node.y, time: sampleTime }; + followDraggedNode(node); invalidate(); event.preventDefault(); event.stopPropagation(); @@ -1979,12 +9073,14 @@ const hitRadius = (node.radius || 1) + 5 / Math.max(zoom, 0.1); if (d <= hitRadius && d < distance) { candidate = node; distance = d; } }); - if (!candidate) return; + if (!dragNodeEligible(candidate)) return; cancelAutoFit(); manualDrag = { node: candidate, pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, offsetX: candidate.x - point.x, offsetY: candidate.y - point.y, dragged: false, + lastSample: { x: candidate.x, y: candidate.y, + time: Number.isFinite(event.timeStamp) ? event.timeStamp : Date.now() }, }; window.addEventListener('pointermove', moveManualDrag, true); window.addEventListener('pointerup', endManualDrag, true); @@ -2003,13 +9099,26 @@ } api.setData = data => { if (destroyed) return; + cancelGalaxyDynamics(true); + resetGalaxyDiagnostics(); + galaxyServerPhase.clear(); + galaxySavedPhase.clear(); + galaxyPhaseRestorePending = false; const inputNodes = Array.isArray(data && data.nodes) ? data.nodes : []; const nodes = [], nodeIds = new Set(); inputNodes.forEach(node => { if (!node || (typeof node !== 'object' && typeof node !== 'function') || !validNodeId(node.id) || nodeIds.has(node.id)) return; nodeIds.add(node.id); - nodes.push(Object.assign({}, node, { name: nodeName(node) })); + const copy = Object.assign({}, node, { name: nodeName(node) }); + galaxyServerPhase.set(copy.id, Object.freeze({ + x: Number.isFinite(copy.x) ? copy.x : undefined, + y: Number.isFinite(copy.y) ? copy.y : undefined, + })); + Object.defineProperty(copy, '_historyGhost', { + value: node.ghost === true, writable: true, configurable: true, enumerable: false + }); + nodes.push(copy); }); const linkInput = Array.isArray(data && data.links) ? data.links @@ -2018,7 +9127,11 @@ .filter(link => link && (typeof link === 'object' || typeof link === 'function')) .map(link => { const source = linkEndpoint(link, 'source'), target = linkEndpoint(link, 'target'); - return Object.assign({}, link, { source, target }); + const copy = Object.assign({}, link, { source, target }); + Object.defineProperty(copy, '_historyGhost', { + value: link.ghost === true, writable: true, configurable: true, enumerable: false + }); + return copy; }) .filter(link => link.source != null && link.target != null && nodeIds.has(link.source) && nodeIds.has(link.target)); @@ -2028,21 +9141,77 @@ source: linkEndpoint(link, 'source'), target: linkEndpoint(link, 'target') })) .filter(link => link.source != null && link.target != null); + const sceneCommunities = (Array.isArray(data && data.communities) ? data.communities : []) + .filter(community => community && typeof community === 'object') + .map(community => ({ ...community })); + const declaredCommunityIds = []; + const extraCommunityIds = []; + const seenCommunityIds = new Set(); + sceneCommunities.forEach(community => { + if (community.id === undefined || community.id === null) return; + const key = String(community.id); + if (!seenCommunityIds.has(key)) { + seenCommunityIds.add(key); + declaredCommunityIds.push(key); + } + }); + nodes.forEach(node => { + const supplied = node.community_id !== undefined && node.community_id !== null + ? node.community_id + : (typeof node.community === 'string' ? node.community : null); + if (supplied === null) return; + const key = String(supplied); + node.community_id = key; + if (!seenCommunityIds.has(key)) { + seenCommunityIds.add(key); + extraCommunityIds.push(key); + } + }); + /* Scene order is stable and meaningful (mass-ranked). Unknown compatibility IDs are + appended deterministically so node colour and grouping never depend on payload order. */ + const communityOrder = declaredCommunityIds.concat(extraCommunityIds.sort()); + const communityIndex = new Map(communityOrder.map((id, index) => [id, index])); + nodes.forEach(node => { + if (node.community_id !== undefined && communityIndex.has(String(node.community_id))) { + node.community = communityIndex.get(String(node.community_id)); + } + }); + const sceneMetaSource = data && (data.meta || data.metadata); + const sceneMeta = sceneMetaSource && typeof sceneMetaSource === 'object' + ? { ...sceneMetaSource } : {}; + if (sceneMeta.layout_seed === undefined && data && data.layout_seed !== undefined) { + sceneMeta.layout_seed = data.layout_seed; + } + const suppliedBridges = Array.isArray(data && data.community_bridges) + ? data.community_bridges + : (Array.isArray(data && data.communityBridges) ? data.communityBridges : []); + let communityBridges = suppliedBridges + .filter(bridge => bridge && typeof bridge === 'object') + .map(bridge => ({ ...bridge })); /* A fresh payload means fresh node objects, so the cached seed is stale even when the ids are identical — force-graph must be re-pointed at the new objects or the render below would style ones nobody is painting from. */ seeded = null; fullLayoutDirty = true; - raw = { nodes, links, suggestions }; + raw = { + nodes, links, suggestions, communities: sceneCommunities, + community_bridges: communityBridges, meta: sceneMeta + }; adj = communities(raw.nodes, raw.links); const deg = Object.create(null); raw.links.forEach(l => { + if (l.ghost) return; const s = linkEndpoint(l, 'source'), t = linkEndpoint(l, 'target'); deg[s] = (deg[s] || 0) + 1; deg[t] = (deg[t] || 0) + 1; }); raw.nodes.forEach(n => { n.degree = deg[n.id] || 0; n.betweenness = 0; }); maxDeg = maxOf(raw.nodes.map(n => n.degree), 1); + sanitizeEvidenceMetrics(raw.nodes, maxDeg); + if (!communityBridges.length) { + communityBridges = fallbackCommunityBridges(raw.nodes, raw.links); + raw.community_bridges = communityBridges; + } const ranked = [...raw.nodes].sort((a, b) => b.degree - a.degree); ranked.forEach((n, i) => { n.rank = i; n.hub = i < 6; }); // A refresh can replace the workspace while a prior focus/highlight still names an old id. @@ -2052,8 +9221,19 @@ if (hilite != null && !nodeIds.has(hilite)) hilite = null; hoverSet = hilite == null ? null : new Set([hilite].concat(adj[hilite] || [])); // Bridge *edges* are cheap (linear) and feed the stats readout, so they stay eager. - // Betweenness is not: see ensureBetweenness. - findBridges(raw.nodes, raw.links, adj); + const liveLinks = raw.links.filter(link => !link.ghost); + // Build adjacency from live links only — ghost links would create false alternative + // paths in the DFS, causing real bridges to be missed. + liveAdj = Object.create(null); + raw.nodes.forEach(n => { liveAdj[n.id] = []; }); + liveLinks.forEach(l => { + const s = linkEndpoint(l, 'source'), t = linkEndpoint(l, 'target'); + if (liveAdj[s]) liveAdj[s].push(t); + if (liveAdj[t]) liveAdj[t].push(s); + }); + findBridges(raw.nodes, liveLinks, liveAdj); + raw.links.filter(link => link.ghost) + .forEach(link => { link.bridge = false; }); betweennessReady = false; if (state.bridges || state.sizeBy === 'betweenness') ensureBetweenness(); if ((state.bridges || state.sizeBy === 'betweenness') && opts.onMetrics) { @@ -2069,17 +9249,72 @@ settled graph sits at alpha~0, so without the reheat those sliders install a force that moves nothing. The paint-only settings must keep the arrangement the user is reading. render() applies the reduced-motion exemption (`if(layout&&!prefersReducedMotion())`). */ - const LAYOUT_KEYS = ['mode', 'repel', 'link', 'gravity', 'size']; + const LAYOUT_KEYS = [ + 'mode', 'repel', 'link', 'gravity', 'size', + 'gravitationalConstant', 'G_center', 'localGravitationalConstant', 'G_star', + 'blackHoleMass', 'damping', 'springStiffness', + ]; api.setSettings = patch => { - const next = patch && typeof patch === 'object' ? patch : {}; + const next = patch && typeof patch === 'object' ? { ...patch } : {}; + if (next.gravitationalConstant === undefined && next.G_center !== undefined) { + next.gravitationalConstant = next.G_center; + } + delete next.G_center; + if (next.gravitationalConstant !== undefined) next.gravitationalConstant = + galaxyPhysicsMultiplier(next.gravitationalConstant, + state.settings.gravitationalConstant, 8); + if (next.localGravitationalConstant === undefined && next.G_star !== undefined) { + next.localGravitationalConstant = next.G_star; + } + delete next.G_star; + if (next.localGravitationalConstant !== undefined) next.localGravitationalConstant = + galaxyPhysicsMultiplier(next.localGravitationalConstant, + state.settings.localGravitationalConstant, 8); + if (next.blackHoleMass !== undefined) next.blackHoleMass = galaxyPhysicsMultiplier( + next.blackHoleMass, state.settings.blackHoleMass, 16); + if (next.damping !== undefined) next.damping = galaxyPhysicsMultiplier( + next.damping, state.settings.damping, 100); + if (next.springStiffness !== undefined) next.springStiffness = galaxyPhysicsMultiplier( + next.springStiffness, state.settings.springStiffness, 8); + if (next.orbitPaused !== undefined) next.orbitPaused = next.orbitPaused === true; const wasFrozen = state.settings.frozen === true; + const wasOrbitPaused = state.settings.orbitPaused === true; const isUnfreezing = wasFrozen && next.frozen === false; const layoutChanged = LAYOUT_KEYS.some(k => next[k] !== undefined); + const previousMode = state.settings.mode; + const previousGravity = Number(state.settings.gravity); if (layoutChanged) { fullLayoutDirty = true; cancelAutoFit(); } Object.assign(state.settings, next); + if (next.orbitPaused !== undefined && previousMode === 'galaxy') { + if (state.settings.orbitPaused) cancelGalaxyDynamics(true); + else if (wasOrbitPaused) scheduleGalaxyDynamics(true); + } + transitionGalaxyMode(previousMode, state.settings.mode); + const nextGravity = Number(state.settings.gravity); + const gravityChanged = next.gravity !== undefined + && Number.isFinite(previousGravity) && Number.isFinite(nextGravity) + && Math.abs(nextGravity - previousGravity) > 1e-12; + if (gravityChanged && previousMode === 'galaxy' && state.settings.mode === 'galaxy' + && !state.settings.frozen && !staticFullLayout && !collapsed) { + const data = fg.graphData() || {}; + /* Gravity changes the acceleration/circular support sampled on the next physical + slice. It never teleports established carrier radii; doing so intersects clear + lanes and discards the current orbital phase. */ + const anchor = galaxyGlobalAnchor(data.nodes || []); + galaxyLastGravityResponse = { + systems: 0, moved: 0, ratio: 1, maximumShift: 0, + anchorId: anchor ? anchor.id : null, + }; + } + if (state.settings.mode === 'galaxy') { + if (previousMode !== 'galaxy' && state.sizeBy !== 'mass') legacySizeBy = state.sizeBy; + state.sizeBy = 'mass'; + } else if (previousMode === 'galaxy' && state.sizeBy === 'mass') { + state.sizeBy = legacySizeBy; + } /* Classic synchronises the complete GSET object during a redraw. If the visible switch was turned off by that sync after an earlier freeze, a plain render restores the paint settings but leaves d3 at its old alpha/charge state. Route the transition @@ -2093,7 +9328,15 @@ }; api.setPreset = name => { const p = PRESETS[name] || PRESETS.compact; + const previousMode = state.settings.mode; state.settings.mode = PRESETS[name] ? name : 'compact'; + transitionGalaxyMode(previousMode, state.settings.mode); + if (state.settings.mode === 'galaxy') { + if (previousMode !== 'galaxy' && state.sizeBy !== 'mass') legacySizeBy = state.sizeBy; + state.sizeBy = 'mass'; + } else if (previousMode === 'galaxy' && state.sizeBy === 'mass') { + state.sizeBy = legacySizeBy; + } ['repel', 'link', 'gravity', 'font', 'size', 'linkw', 'labelDensity'].forEach(k => { if (p[k] !== undefined) state.settings[k] = p[k]; }); fullLayoutDirty = true; render(true, true); @@ -2201,6 +9444,9 @@ api.exportData = () => { const data = visible(); return { + meta: { ...raw.meta }, + communities: raw.communities.map(community => ({ ...community })), + community_bridges: raw.community_bridges.map(bridge => ({ ...bridge })), nodes: data.nodes.map(node => { const { x, y, vx, vy, fx, fy, color, stroke, radius, ...stable } = node; return stable; @@ -2213,17 +9459,106 @@ }; }; api.fit = () => { if (!destroyed) fg.zoomToFit(reduced() ? 0 : 500, 40); }; + api.physicsDiagnostics = () => physicsDiagnostics(); + api.graphToScreen = (x, y) => { + if (!fg.graph2ScreenCoords) return { x: Number(x) || 0, y: Number(y) || 0 }; + const point = fg.graph2ScreenCoords(Number(x) || 0, Number(y) || 0); + return { x: point.x, y: point.y }; + }; + api.getPhysicsSnapshot = () => { + const data = fg.graphData() || {}; + const nodes = Array.isArray(data.nodes) ? data.nodes : []; + const center = galaxyGlobalAnchor(nodes); + const centerPoint = center ? api.graphToScreen(center.x, center.y) : null; + const systemAnchors = []; + communityCenters(nodes).forEach(system => { + const star = galaxySystemAnchor(system.nodes); + if (!star || star.anchor_role !== 'community') return; + systemAnchors.push({ + id: star.id, x: star.x, y: star.y, + radius: finitePositive(star.radius, evidenceNodeRadius(star, 3), 160), + mass: finitePositive(star.gravity_mass, 1, 1000), + memberCount: system.nodes.length, + systemOrbitRadius: system.nodes.reduce((maximum, node) => node === star + ? maximum : Math.max(maximum, Math.hypot(node.x - star.x, node.y - star.y)), 0), + galacticOrbitRadius: center + ? Math.hypot(star.x - center.x, star.y - center.y) : null, + communityId: communityKey(star), + }); + }); + const systemAnchorIds = new Set(systemAnchors.map(star => String(star.id))); + return { + center: center ? { + id: center.id, x: center.x, y: center.y, + label: nodeName(center), + screenX: centerPoint.x, screenY: centerPoint.y, + radius: finitePositive(center.radius, evidenceNodeRadius(center, 3), 160), + } : null, + nodes: nodes.filter(node => node && Number.isFinite(node.x) + && Number.isFinite(node.y)).map(node => ({ + id: node.id, x: node.x, y: node.y, + vx: Number.isFinite(node.vx) ? node.vx : 0, + vy: Number.isFinite(node.vy) ? node.vy : 0, + radius: finitePositive(node.radius, evidenceNodeRadius(node, 3), 160), + isCentral: node === center, + isSystemAnchor: systemAnchorIds.has(String(node.id)), + anchorRole: node.anchor_role || null, + systemAnchorId: node.system_anchor_id === undefined + || node.system_anchor_id === null ? null : node.system_anchor_id, + communityId: communityKey(node), + orbitRadius: Number.isFinite(Number(node.galactic_radius)) + ? Number(node.galactic_radius) : null, + orbitTier: Number.isFinite(Number(node.orbit_tier)) + ? Number(node.orbit_tier) : null, + warp: Number(node.__galaxySpacetimeWarp) || 0, + })), + systemAnchors, + paused: state.settings.orbitPaused === true || state.settings.frozen === true + || !running || pageHidden(), + diagnostics: physicsDiagnostics(), + slingshot: lastSlingshotRelease ? { ...lastSlingshotRelease } : null, + }; + }; api.reheat = () => { - if (destroyed || state.settings.frozen || staticFullLayout) return; + if (destroyed || state.settings.frozen + || (staticFullLayout && state.settings.mode !== 'galaxy')) return; cancelAutoFit(); - raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; }); + if (!staticFullLayout) raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; }); + if (state.settings.mode === 'galaxy') { + /* Persistent physics has no cold alpha to restart. Wake its ordinary fixed clock while + preserving phase and velocity; never inject bonus slices that fast-forward all orbits. */ + galaxyReheatStepsRemaining = Math.max(galaxyReheatStepsRemaining, + large ? GALAXY_REHEAT_LARGE_STEPS : GALAXY_REHEAT_STEPS); + galaxyReheatActivations++; + scheduleGalaxyDynamics(true); + return; + } prepareReheat(); if (fg.d3AlphaDecay) fg.d3AlphaDecay(alphaDecay()); softReheat(); }; api.freeze = on => { - state.settings.frozen = on; - if (on) { + state.settings.frozen = on === true; + if (state.settings.mode === 'galaxy') { + if (state.settings.frozen) { + const restorePhase = galaxyPhaseRestorePending; + galaxyReheatStepsRemaining = 0; + cancelGalaxyDynamics(true); + setSimulationBudget(false, true); + render(false, false); + if (restorePhase && galaxyPhaseRestorePending) { + restoreGalaxyPhase(); + galaxyPhaseRestorePending = false; + invalidate(); + } + return; + } + if (!staticFullLayout) raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; }); + render(false, false); + scheduleGalaxyDynamics(true); + return; + } + if (state.settings.frozen) { const charge = fg.d3Force('charge'); if (charge && charge.strength) charge.strength(0); setSimulationBudget(true); @@ -2321,7 +9656,11 @@ }; api.setAsOf = date => { state.asOf = asOfValue(date); render(false, true); }; api.setSizeBy = metric => { - state.sizeBy = metric === 'betweenness' ? metric : 'degree'; + if (state.settings.mode === 'galaxy') state.sizeBy = 'mass'; + else { + state.sizeBy = metric === 'betweenness' ? metric : 'degree'; + legacySizeBy = state.sizeBy; + } if (state.sizeBy === 'betweenness') { ensureBetweenness(); if (opts.onMetrics) opts.onMetrics(api.metrics()); @@ -2349,7 +9688,7 @@ api.setCollapse = mode => { state.collapse = state.renderMode === 'full' ? false : mode; const collapseThreshold = state.settings.mode === 'communities' ? 0.22 : 0.42; - const canAutoCollapse = raw.nodes.length > 500; + const canAutoCollapse = autoCollapseEligible(); const next = state.renderMode !== 'full' && (mode === true || (mode === 'auto' && canAutoCollapse && zoom < collapseThreshold)); collapsed = next; render(true, true); @@ -2361,6 +9700,7 @@ api.pause = () => { if (destroyed || !running) return; running = false; + cancelGalaxyDynamics(true); if (fg.pauseAnimation) fg.pauseAnimation(); }; api.resume = () => { @@ -2368,16 +9708,20 @@ running = true; if (fg.resumeAnimation) fg.resumeAnimation(); measure(); + scheduleGalaxyDynamics(true); }; api.destroyed = () => destroyed; api.destroy = () => { if (destroyed) return; destroyed = true; running = false; + cancelGalaxyDynamics(true); clearTimeout(fitTimer); fitTimer = 0; clearTimeout(softAlphaTimer); softAlphaTimer = 0; + clearTimeout(clusterExpandTimer); + clusterExpandTimer = 0; cancelFrame(initialFitFrame); initialFitFrame = 0; cancelFrame(dragClickFrame); @@ -2387,8 +9731,8 @@ physicsReheatPending = false; pendingRender = null; setActiveDragNode(null); - dragFollowForce = null; try { + if (detachVisibility) { detachVisibility(); detachVisibility = null; } if (detachManualDrag) { detachManualDrag(); detachManualDrag = null; } if (api._ro) { api._ro.disconnect(); api._ro = null; } // `_destructor` pauses the rAF and drops the graph data; it does not detach the @@ -2398,8 +9742,12 @@ el.classList.remove('engraphis-graph-node-hover'); el.innerHTML = ''; } catch (e) { /* teardown is best-effort: never let it block a view change */ } - raw = { nodes: [], links: [], suggestions: [] }; + raw = { nodes: [], links: [], suggestions: [], communities: [], community_bridges: [], meta: {} }; + galaxyServerPhase.clear(); + galaxySavedPhase.clear(); + galaxyPhaseRestorePending = false; adj = Object.create(null); + liveAdj = Object.create(null); seeded = null; hilite = null; hoverSet = null; @@ -2425,6 +9773,16 @@ api._ro = new ResizeObserver(() => measure()); api._ro.observe(el); } + if (visibilityDocument && typeof visibilityDocument.addEventListener === 'function') { + const handleVisibility = () => { + if (pageHidden()) cancelGalaxyDynamics(true); + else scheduleGalaxyDynamics(true); + }; + visibilityDocument.addEventListener('visibilitychange', handleVisibility); + detachVisibility = () => visibilityDocument.removeEventListener( + 'visibilitychange', handleVisibility + ); + } applyChrome(); return api; } @@ -2436,9 +9794,55 @@ Nothing in the dashboard uses these; treat them as the engine's unit-test seam. */ _internals: { esc, hexRgb, alpha, contrastOn, communities, betweenness, findBridges, maxOf, - graphNodeRadius, paintFlowArrow, + graphNodeRadius, evidenceNodeRadius, sanitizeEvidenceMetrics, fallbackGravityMass, + radiusFromGravityMass, galaxyGravityConstant, galaxyGravityMaximum: GALAXY_GRAVITY_MAXIMUM, + galaxyBlackHoleGravityConstant, galaxyBlackHoleGravitySetting, + galaxyBlackHoleSpinAngle, advanceGalaxyBlackHoleSpin, + galaxyGlobalGravityFloorSetting: GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, + galaxyLocalGravityConstant, + galaxyLocalGravityMultiplier, + galaxyStellarGravityConstant, galaxyFallbackStellarGravityConstant, + galaxySystemGravityConstant, galaxyStellarGravitySetting, + galaxyStellarGravityFloorSetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING, + defaultGalaxyStellarAccelerationCap, defaultGalaxySystemAccelerationCap, + galaxySceneWithinLiveLimit, + galaxyRelationOrbitScale, galaxyOrbitalSpeedMultiplier, galaxyOrbitalRadiusMultiplier, + applyGalaxyOrbitalSpeedControl, + galaxyOrbitalSeparationPadding, galaxyOrbitalSeparationStrength, + communityKey, communityCenters, galaxyOrbitGroups, ensureGalaxyPositions, + markGalaxyBlackHoleChildren, + seedGalaxyOrbits, seedGalaxySystemOrbits, + applyGalaxyGravity, applyGalaxySystemHaloGravity, applyGalaxyEnclosedSystemGravity, + applyGalaxySystemAnchorGravity, applyGalaxySystemAnchorExclusion, + galaxySystemAnchorClearance, + combineGalaxySystemAnchorExclusions, + applyGalaxyCentralGravity, applyGalaxyMutualSystemGravity, galaxyGlobalAnchor, + galaxyBlackHoleField, applyGalaxyBlackHoleGravity, integrateGalaxyGhostOrbits, + applyGalaxySpacetimeAcceleration, applyGalaxyEventHorizonDecay, + galaxySlingshotCapture, + advanceGalaxyKinematicOrbits, + recenterGalaxyOnAnchor, + applyCommunityBridgeGravity, + applyGalaxyRelationSprings, applyGalaxyRelationDistanceConstraints, + applyDraggedNodeGravity, applyDraggedNodeAcceleration, + applyGalaxyCollisions, applyGalaxyOrbitalSeparation, + galaxySystemEnvelopes, applyGalaxySystemPacking, + establishGalaxyCarrierLanes, + applyGalaxyBlackHoleExclusion, + galaxyFarFieldEnvelope, applyGalaxyFarFieldGravity, applyGalaxyFarFieldConfinement, + applyGalaxyAnnularBounds, + stabilizeGalaxySystemVelocities, + galaxyAccelerations, integrateGalaxyLeapfrog, galaxyMotionDiagnostics, + galaxyInwardConvergencePerMinute, galaxyInwardConvergenceFactor, + applyGalaxyInwardConvergence, supportGalaxyCarrierOrbits, + galaxyImmediateGravityRadiusScale, + galaxyLayoutCompactness, + applyGalaxyGravitySettingResponse, + galaxySpringStrength, galaxySpringDistance, galaxySafeSpringDistance, + fallbackCommunityBridges, paintFlowArrow, nodeName, linkEndpoint, asOfValue, materialRecipe, materialTier, - paintMaterialDirect, renderMaterialSample, sampleMaterialColour, + paintMaterialDirect, paintGalaxyAnchorAdornment, + renderMaterialSample, sampleMaterialColour, materialCacheStats, clearMaterialCache, setMaterialCanvasFactory } }; diff --git a/engraphis/dashboard_assets/engraphis-spacetime.js b/engraphis/dashboard_assets/engraphis-spacetime.js new file mode 100644 index 00000000..75817537 --- /dev/null +++ b/engraphis/dashboard_assets/engraphis-spacetime.js @@ -0,0 +1,276 @@ +(() => { + 'use strict'; + + /* A canvas-only cosmetic layer for the Galaxy renderer. It deliberately owns no graph + state or forces: the physics engine is the source of truth and may provide snapshots by + `getPhysicsSnapshot()` or `engraphisgraphphysicschange` on the graph container. Keeping + this external means a 500-node scene adds one canvas and bounded drawing work, not 500 DOM + nodes or a second simulation loop. */ + const MAX_TRAIL_NODES = 160; + const TRAIL_POINTS = 8; + const TRAIL_NODE_LIMIT = 600; + const SAMPLE_INTERVAL = 33; + const GRID_RINGS = 9; + const GRID_SPOKES = 20; + const MAX_LOCAL_WELLS = 24; + const finite = value => Number.isFinite(Number(value)) ? Number(value) : 0; + const reducedMotion = () => typeof matchMedia === 'function' + && matchMedia('(prefers-reduced-motion: reduce)').matches; + + function snapshotCenter(snapshot) { + const center = snapshot && snapshot.center; + return center && Number.isFinite(center.x) && Number.isFinite(center.y) ? center : null; + } + + function bestNodes(snapshot) { + const nodes = Array.isArray(snapshot && snapshot.nodes) ? snapshot.nodes : []; + return nodes.filter(node => node && Number.isFinite(node.x) && Number.isFinite(node.y) + && node.isCentral !== true && node.central !== true) + .sort((a, b) => Math.hypot(finite(b.vx), finite(b.vy)) - Math.hypot(finite(a.vx), finite(a.vy))) + .slice(0, MAX_TRAIL_NODES); + } + + function localAnchors(snapshot) { + const supplied = Array.isArray(snapshot && snapshot.systemAnchors) ? snapshot.systemAnchors : []; + const fallback = Array.isArray(snapshot && snapshot.nodes) ? snapshot.nodes.filter(node => node + && (node.isSystemAnchor === true || node.anchorRole === 'community')) : []; + return (supplied.length ? supplied : fallback).filter(anchor => anchor + && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) + .sort((left, right) => finite(right.mass || right.gravityMass || right.radius) + - finite(left.mass || left.gravityMass || left.radius)) + .slice(0, MAX_LOCAL_WELLS); + } + + function create(container, engine) { + if (!container || !container.appendChild) return null; + const canvas = document.createElement('canvas'); + canvas.className = 'graph-spacetime-overlay'; + canvas.setAttribute('aria-hidden', 'true'); + container.appendChild(canvas); + const ctx = canvas.getContext('2d', { alpha: true }); + if (!ctx) { canvas.remove(); return null; } + const trails = new Map(); + let active = false; + let frame = 0; + let lastSample = 0; + let latest = null; + let destroyed = false; + + const physicalToScreen = point => { + if (!point) return null; + if (engine && typeof engine.graphToScreen === 'function') { + const screen = engine.graphToScreen(point.x, point.y); + if (screen && Number.isFinite(screen.x) && Number.isFinite(screen.y)) return screen; + } + const viewport = latest && latest.viewport; + if (viewport && Number.isFinite(viewport.zoom)) { + return { x: point.x * viewport.zoom + finite(viewport.x), y: point.y * viewport.zoom + finite(viewport.y) }; + } + /* A rendering engine that cannot expose its viewport still gets a centred, harmless + lens/grid rather than an incorrect coordinate transform. */ + return { x: canvas.width / (2 * devicePixelRatio), y: canvas.height / (2 * devicePixelRatio) }; + }; + + const screenRadius = physicalCenter => { + if (!physicalCenter) return 20; + const center = physicalToScreen(physicalCenter); + const edge = physicalToScreen({ x: physicalCenter.x + finite(physicalCenter.radius), y: physicalCenter.y }); + return center && edge ? Math.max(10, Math.abs(edge.x - center.x)) : Math.max(10, finite(physicalCenter.radius)); + }; + + const screenDistance = (origin, graphDistance, fallback) => { + const start = physicalToScreen(origin); + const edge = physicalToScreen({ x: origin.x + graphDistance, y: origin.y }); + return start && edge ? Math.max(fallback, Math.abs(edge.x - start.x)) : fallback; + }; + + const resize = () => { + const ratio = Math.min(2, Math.max(1, finite(window.devicePixelRatio) || 1)); + const width = Math.max(1, container.clientWidth); + const height = Math.max(1, container.clientHeight); + if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) { + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + } + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + return { width, height }; + }; + + const drawGrid = (center, bounds) => { + const maxRadius = Math.hypot(bounds.width, bounds.height) * .72; + const horizon = Math.max(12, finite(center.radius) * 1.55); + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.lineWidth = 1; + for (let ring = 1; ring <= GRID_RINGS; ring += 1) { + const rest = horizon + (maxRadius - horizon) * ring / GRID_RINGS; + ctx.beginPath(); + for (let i = 0; i <= 96; i += 1) { + const angle = i / 96 * Math.PI * 2; + /* A saturating gravity well: rings visibly pinch near the event horizon but do not + explode at r=0 or consume the entire canvas under a high mass setting. */ + const warped = rest - Math.min(rest * .38, (horizon * horizon * 1.9) / Math.max(rest, horizon)); + const x = center.x + Math.cos(angle) * warped; + const y = center.y + Math.sin(angle) * warped * .82; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.strokeStyle = `rgba(96, 198, 255, ${0.025 + ring * .004})`; + ctx.stroke(); + } + for (let spoke = 0; spoke < GRID_SPOKES; spoke += 1) { + const angle = spoke / GRID_SPOKES * Math.PI * 2; + ctx.beginPath(); + for (let step = 0; step <= 16; step += 1) { + const rest = horizon + (maxRadius - horizon) * step / 16; + const warped = rest - Math.min(rest * .38, (horizon * horizon * 1.9) / Math.max(rest, horizon)); + const x = center.x + Math.cos(angle + .12 * (1 - step / 16)) * warped; + const y = center.y + Math.sin(angle + .12 * (1 - step / 16)) * warped * .82; + if (step === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.strokeStyle = 'rgba(128, 107, 255, .07)'; + ctx.stroke(); + } + const lens = ctx.createRadialGradient(center.x, center.y, Math.max(1, horizon * .35), center.x, center.y, horizon * 2.8); + lens.addColorStop(0, 'rgba(0, 0, 0, .36)'); + lens.addColorStop(.48, 'rgba(123, 170, 255, .11)'); + lens.addColorStop(.78, 'rgba(221, 175, 255, .035)'); + lens.addColorStop(1, 'rgba(84, 148, 255, 0)'); + ctx.fillStyle = lens; + ctx.beginPath(); + ctx.arc(center.x, center.y, horizon * 2.8, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }; + + const drawLocalWells = snapshot => { + const anchors = localAnchors(snapshot); + if (!anchors.length) return; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + anchors.forEach(anchor => { + const center = physicalToScreen(anchor); + if (!center || center.x < -160 || center.y < -160 + || center.x > container.clientWidth + 160 || center.y > container.clientHeight + 160) return; + const radius = screenRadius(anchor); + const orbitRadius = Math.max(radius * 2.5, Math.min(96, screenDistance(anchor, + finite(anchor.systemOrbitRadius || anchor.orbitRadius) || radius * 5, radius * 5))); + const glow = ctx.createRadialGradient(center.x, center.y, Math.max(1, radius * .5), + center.x, center.y, orbitRadius * 1.15); + glow.addColorStop(0, 'rgba(255, 210, 116, .08)'); + glow.addColorStop(.55, 'rgba(245, 169, 80, .025)'); + glow.addColorStop(1, 'rgba(245, 169, 80, 0)'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(center.x, center.y, orbitRadius * 1.15, 0, Math.PI * 2); + ctx.fill(); + for (let ring = 1; ring <= 2; ring += 1) { + ctx.beginPath(); + ctx.ellipse(center.x, center.y, orbitRadius * ring / 2, + orbitRadius * ring * .70 / 2, .18, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(255, 191, 116, ${.045 + ring * .018})`; + ctx.lineWidth = .7; + ctx.stroke(); + } + }); + ctx.restore(); + }; + + const drawTrails = () => { + if (reducedMotion()) return; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + trails.forEach(points => { + if (points.length < 2) return; + const gradient = ctx.createLinearGradient(points[0].x, points[0].y, + points[points.length - 1].x, points[points.length - 1].y); + gradient.addColorStop(0, 'rgba(154, 216, 255, .01)'); + gradient.addColorStop(1, 'rgba(154, 216, 255, .20)'); + ctx.strokeStyle = gradient; + ctx.lineWidth = 1.1; + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y); + ctx.stroke(); + }); + ctx.restore(); + }; + + const sample = stamp => { + if (!latest || stamp - lastSample < SAMPLE_INTERVAL) return; + lastSample = stamp; + const seen = new Set(); + const allNodes = Array.isArray(latest && latest.nodes) ? latest.nodes : []; + if (allNodes.length > TRAIL_NODE_LIMIT) { + trails.clear(); + return; + } + bestNodes(latest).forEach(node => { + const screen = physicalToScreen(node); + if (!screen) return; + const id = String(node.id || ''); + if (!id) return; + seen.add(id); + const points = trails.get(id) || []; + points.push({ x: screen.x, y: screen.y }); + if (points.length > TRAIL_POINTS) points.splice(0, points.length - TRAIL_POINTS); + trails.set(id, points); + }); + trails.forEach((_points, id) => { if (!seen.has(id)) trails.delete(id); }); + }; + + const draw = stamp => { + if (destroyed) return; + const bounds = resize(); + ctx.clearRect(0, 0, bounds.width, bounds.height); + if (active && engine && typeof engine.getPhysicsSnapshot === 'function') { + latest = engine.getPhysicsSnapshot() || latest; + } + if (active && latest) { + sample(stamp); + const physicalCenter = snapshotCenter(latest); + const center = physicalToScreen(physicalCenter); + if (center) drawGrid({ ...center, radius: screenRadius(physicalCenter) }, bounds); + drawLocalWells(latest); + drawTrails(); + } + /* Paused physics retains one static spacetime paint, then releases the compositor. + Local wells and guide rings are deliberately still visible under reduced motion; + only sampled velocity trails are suppressed there. */ + if (active && !document.hidden && !(latest && latest.paused)) frame = requestAnimationFrame(draw); + else frame = 0; + }; + + const wake = () => { + if (!frame && !destroyed && active && !document.hidden) frame = requestAnimationFrame(draw); + }; + const onFrame = event => { + latest = event && event.detail ? event.detail : latest; + if (active) wake(); + }; + container.addEventListener('engraphisgraphphysicschange', onFrame); + const onVisibilityChange = () => { if (!document.hidden) wake(); }; + document.addEventListener('visibilitychange', onVisibilityChange); + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize); + if (observer) observer.observe(container); + return { + setEngine(next) { engine = next || engine; }, + setEnabled(on) { + active = on === true; + if (!active) trails.clear(); + wake(); + }, + setSnapshot(snapshot) { latest = snapshot || null; if (active) wake(); }, + destroy() { + destroyed = true; + cancelAnimationFrame(frame); + container.removeEventListener('engraphisgraphphysicschange', onFrame); + document.removeEventListener('visibilitychange', onVisibilityChange); + if (observer) observer.disconnect(); + canvas.remove(); + trails.clear(); + }, + }; + } + + window.EngraphisSpacetime = { create }; +})(); diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html index 16532ee9..7eab1fad 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -7,7 +7,7 @@ Engraphis Ledger - + @@ -71,6 +71,7 @@ Subscribe to Pro
Connecting… + Engine v2 · bi-temporal
@@ -81,22 +82,6 @@ -
-
-

Runtime savings

-

Estimated context saved

-

Loading receipt-backed estimate…

-
-
- - tokens avoided -
-
- - -
-
-
@@ -113,15 +98,6 @@

What changed in this workspaceSessions

-
-
-

Runtime savings

Estimated context saved

- -
-

Loading receipt-backed estimate…

-

Measures estimated prompt-context reduction; it does not measure provider billing.

-
-

Needs a decision

High-signal records surfaced from local memory.

@@ -245,7 +221,7 @@

Choose a memory

- +
@@ -269,7 +245,7 @@

How this workspace connects

Open Graph & Relationships to load the graph.
0 entities · 0 relations - Community islands + Galaxy gravity

The graph is a visual summary. Open the Analyse tab to inspect entities and relations with keyboard controls.

@@ -282,7 +258,7 @@

How this workspace connects

- + @@ -297,7 +273,7 @@

How this workspace connects

- +
@@ -322,8 +298,9 @@

Rendering

Layout

+ - + @@ -352,7 +329,7 @@

Motion

Relation flowparticles
Entity labelsnames
Freeze simulationpause physics
- +
@@ -370,15 +347,15 @@

Saved views

Tune the simulation — forces, size, scope
- - - - - - - - - + + + + + + + + +
@@ -387,6 +364,18 @@

Saved views

+
+ Spacetime — black-hole orbit controls +
+ + + + + +
+
Pause orbitsphysics
+

Drag and release a node to slingshot it into a new orbit.

+
@@ -394,8 +383,8 @@

Saved views