Files
learnharness/src/core/layout.ts
T
amosblomqvist af666aaa3f physics: bundles join their cluster; depth runs over the logical graph
Owner-approved brief (orchestrator, physics arc): two laws.

1. IMP-NODE CLUSTER ASSIGNMENT. Imp nodes were unclustered (the partition
   runs over stored propositions only) — no centroid pull, outside the
   painted hulls, bundles sitting on the periphery pointing outward.
   Now: a derived imp node's cluster is a function of its derivation —
   MAJORITY among its PREMISES' birth sessions (owner: premises, NOT the
   conclusion), ties to the chain's OWN birth (its provenance's first
   point — the asserting session; deterministic, coincides with the
   unanimous common case). Imp nodes are always re-derived, never
   birthed, so the assignment must be exactly this.

   Seam chosen: core/session-clusters.ts exports impClusterKey + a
   shared attachImpToCommunities that mutates a communities list's
   members IN PLACE. graphSnapshot attaches AFTER the hull filter (a
   2-proposition community + its bundle never resurrects a hull — the
   filter counts propositions); settleGraphLayout attaches to the
   unfiltered partition (settle pulls every community; hulls are a
   canvas-only concern). Both cluster-membership consumers — the sim's
   centroid pull and buildRegions' rasterization — read it through the
   members lists that went through the one helper, so the resting
   ledger and the live sim agree on where bundles belong.

2. DEPTHS OVER THE LOGICAL GRAPH. inferenceDepths ran Tarjan over the
   DRAWN edges (premise→imp→conclusion), inflating every multi-premise
   conclusion one hop (depth(imp)+1 instead of depth(premises)+1) and
   ranking the geometry point itself. Now:
   - deriveView additionally emits logicalEdges — every chain contracted
     to direct premise→conclusion arrows over PROPOSITION ids only
     (per-premise, deduped — a conclusion's rank is max over premises,
     which a single arbitrary premise edge could not express).
   - GraphSnapshot carries logicalEdges; createGraphSimulation /
     incrementalLayout accept a logicalEdges option: the depth
     machinery (orphan detection AND the rank pass) reads it, springs
     keep the drawn edges. Absent = edges (legacy behavior).
   - Imp nodes join the ORPHAN-EXEMPTION mask explicitly — never pinned
     to a level, keep the full regular forces (gravity, cluster pull,
     springs), settle naturally between premise and conclusion levels;
     the rank map is computed over propositions only, so arg_ ids are
     absent from it by construction.
   Wiring: settle + canvas (CanvasData.logicalEdges → buildSim) +
   replay-layout all pass the logical set.

   EXTRA FIX (in-slice discovery, one line, graph-server domain):
   server/graph.ts graphClusters — the practice picker's /api/graph/
   clusters — partitioned snap.NODES (the canvas image, imp nodes riding
   it since chains-primary), leaking arg_ ids into nodeIds: pickable ids
   that silently vanish inside chainsForSelection while nodeCount lied.
   Now partitions snap.propositions — same purity law as the chain
   table's 767a559 fix.

Tests (+13): impClusterKey pins (unanimous; cross-session majority; tie
→ chain's own birth, not the conclusion's); attach pins (members gain
the imp id, sorted; single-premise attaches nothing — no node exists;
dangling mints nothing; home absent from the list stays unattached);
snapshot pins (imp id rides cluster members to both consumers; the hull
filter counts propositions); logical-depth pins (multi-premise
conclusion at premises'+1 NOT +2; imp absent from the rank map);
depth-force exemption pin (rows pin crisply, arg_x floats unpinned —
old behavior stamped it onto the conclusion's row); deriveView logical
arrows (per-premise, deduped, proposition ids only).
2026-09-30 04:30:25 +03:00

669 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The layout engine — d3-force, everywhere (DECIDED 2026-09-13).
*
* One engine owns the equilibrium: the merge-time incremental settle AND
* the live drag physics run the SAME force set (links as springs, charge
* repulsion, weak gravity), so the positions ledger and the transient
* simulation agree on what "resting" means. (FA2 was replaced after a
* research round: it has no annealing/pinning/settle model and mixing it
* with a different live engine visibly re-flows the graph on every drag.)
*
* Layout EVOLVES instead of reshuffling (Option A): each settle warm-starts
* from the positions ledger and spring-ties every previously-positioned
* node toward its anchor (a per-node anchor force). New nodes seed near
* their positioned neighbors; orphans seed on the rim OUTSIDE the core and
* are anchored there, so they read as "not yet connected knowledge".
*
* Design constraints (kept deliberately, for live physics):
* - PURE: no fs, no server types — this module runs server-side (the
* merge settle) and in the browser (the live simulation, via the core
* barrel). A pinned node is just fx/fy; an anchored node is a spring.
* - DETERMINISTIC: seeds derive from node id; d3-force ticks are
* deterministic — same inputs, same layout.
*/
import {
forceSimulation,
forceLink,
forceManyBody,
forceX,
forceY,
type Simulation,
type SimulationLinkDatum,
type SimulationNodeDatum,
} from "d3-force";
import type { GraphEdge, GraphNode, Position } from "./contracts.ts";
import { seededRandom } from "./question-core.ts";
/** A node the d3 simulation mutates (x/y/vx/vy; fx/fy = pinned). */
export interface LayoutNode extends SimulationNodeDatum {
id: string;
}
/** A link as d3 sees it (id strings resolve to node refs on init). */
type SimLink = SimulationLinkDatum<LayoutNode>;
/** How the inference rank is expressed spatially (playground, 2026-09-13):
* the graph's `follows`/`supports` edges encode derivation (premise →
* conclusion), so depth-0 nodes (no incoming edges — the fundamentals)
* can anchor a shared baseline and every derivation level rises from it.
* `vertical`: levels are horizontal rows, arrows point up.
* `radial`: levels are concentric rings, foundations at the center, the
* outer boundary IS the edge of the user's knowledge.
* `off`: today's free layout. */
export type DepthAnchorMode = "off" | "vertical" | "radial";
export interface DepthAnchor {
mode: DepthAnchorMode;
/** Anchor pull toward the level (same units as clusterPull). */
strength: number;
/** Distance between consecutive inference levels (world units). */
spacing: number;
/** Baseline point: vertical mode uses .y as the depth-0 line; radial
* mode uses the full point as the ring center. Default (0,0). */
center?: { x: number; y: number };
/** Inner ring radius (radial only; depth-0 nodes sit at this ring). */
baseRadius?: number;
}
/** Inference depth per node: the longest chain of directed edges from a
* base node. CYCLE-SAFE (2026-09-13, user requirement — the DAG property
* is typical, not guaranteed): Tarjan SCC condensation collapses each
* strongly connected component to one super-node, ranks the (always
* acyclic) condensation, and every cycle member shares its component's
* depth — mutual support reads as equal footing. Iterative Tarjan (no
* recursion depth limits), O(V+E), deterministic. */
export function inferenceDepths(
nodes: ReadonlyArray<Pick<GraphNode, "id">>,
edges: ReadonlyArray<LayoutEdge>,
): Map<string, number> {
const valid = new Set(nodes.map((n) => n.id));
const adj = new Map<string, string[]>();
for (const e of edges) {
if (e.from === e.to || !valid.has(e.from) || !valid.has(e.to)) continue;
let list = adj.get(e.from);
if (!list) adj.set(e.from, (list = []));
list.push(e.to);
}
// Iterative Tarjan SCC.
const num = new Map<string, number>();
const low = new Map<string, number>();
const onStack = new Set<string>();
const stack: string[] = [];
const comp = new Map<string, number>();
let counter = 0;
let compCount = 0;
for (const start of valid) {
if (num.has(start)) continue;
num.set(start, counter);
low.set(start, counter);
counter++;
stack.push(start);
onStack.add(start);
const work: [string, number][] = [[start, 0]];
while (work.length > 0) {
const frame = work[work.length - 1]!;
const v = frame[0];
const outs = adj.get(v) ?? [];
if (frame[1] < outs.length) {
const i = frame[1];
frame[1] = i + 1;
const w = outs[i]!;
if (!num.has(w)) {
num.set(w, counter);
low.set(w, counter);
counter++;
stack.push(w);
onStack.add(w);
work.push([w, 0]);
} else if (onStack.has(w)) {
low.set(v, Math.min(low.get(v)!, num.get(w)!));
}
} else {
work.pop();
if (low.get(v) === num.get(v)) {
let w: string;
do {
w = stack.pop()!;
onStack.delete(w);
comp.set(w, compCount);
} while (w !== v);
compCount++;
}
if (work.length > 0) {
const parent = work[work.length - 1]![0];
low.set(parent, Math.min(low.get(parent)!, low.get(v)!));
}
}
}
}
// Condensation predecessors, then longest path. Tarjan emits sinks
// first, so descending comp index = topological order (sources first).
const preds: Set<number>[] = Array.from({ length: compCount }, () => new Set<number>());
for (const e of edges) {
const a = comp.get(e.from);
const b = comp.get(e.to);
if (a == null || b == null || a === b) continue;
preds[b]!.add(a);
}
const depth = new Array<number>(compCount).fill(0);
for (let c = compCount - 1; c >= 0; c--) {
let d = 0;
for (const p of preds[c]!) d = Math.max(d, depth[p]! + 1);
depth[c] = d;
}
const out = new Map<string, number>();
for (const n of nodes) out.set(n.id, depth[comp.get(n.id)!] ?? 0);
return out;
}
export interface SimOptions {
/** Link spring rest length (world units). */
linkDistance?: number;
/** Link spring stiffness MULTIPLIER (1 = d3's default
* 1/min(degree) stiffness). */
linkStrength?: number;
/** Many-body repulsion strength (negative). */
chargeStrength?: number;
/** Center-of-mass gravity: pulls loose components inward. Weak —
* anchors, not gravity, hold the evolved layout's frame. */
gravity?: { x: number; y: number; strength: number };
/** Alpha decay per tick — higher = snappier settle. The merge settle
* uses d3's default 0.0228 (~300 ticks); the live sim passes ~0.05. */
alphaDecay?: number;
/** Velocity damping per tick (d3 default 0.4). Higher = more friction. */
velocityDecay?: number;
/** Inference-depth anchoring (see DepthAnchor). Off by default. */
depthAnchor?: DepthAnchor;
/** The LOGICAL edge list (proposition→conclusion, imp hops
* contracted) for the depth machinery — orphan detection AND the
* rank pass (owner, 2026-09-30). Absent = `edges` (legacy
* behavior). Springs ALWAYS run over `edges` — the drawn arrows. */
logicalEdges?: ReadonlyArray<LayoutEdge>;
/** Cluster centroid pull (the "virtual node" without the node):
* members of each cluster accelerate toward their own cluster's
* centroid. Two O(n) passes per tick — cheaper than a real virtual
* node (which would need one extra link spring per member plus a
* maintained position). Strength 0 or absent = force off.
* Callers supply the partition (Louvain output shape). */
clusterPull?: {
clusters: ReadonlyArray<{ members: ReadonlyArray<string> }>;
strength: number;
};
}
/** Link input: edges are undirected for layout purposes. */
export interface LayoutEdge {
from: string;
to: string;
}
export const LINK_DISTANCE = 40;
/** Implication-node id prefix (the mint law lives in core/graph.ts's
* newGraphChainId). Layout-side: links TOUCHING an imp node get HALF
* the base rest length — the argument's geometry (a converging point)
* sits tight against its premises/conclusion instead of spread like a
* real proposition citizen (owner ruling, rendering arc 2026-09-30). */
const IMP_ID_PREFIX = "arg_";
const impLinkId = (v: string | number | { id: string }): string =>
typeof v === "object" ? v.id : String(v);
export const CHARGE_STRENGTH = -240; // 2026-09-17: −120 → −240 (user: repulsion default too weak); the default is shared with render-settings (gm-centered in CHARGE_RANGE).
/** Per-tick anchor re-blend: fraction of an anchored node's displacement
* from its previous position that SURVIVES each tick. 0.85 = gentle
* evolution where forces are strong, stillness where they are not —
* the exact FA2-era constant. Position-space (alpha-independent), so it
* cannot be overpowered by d3's long-range link springs the way a force-
* space anchor can (forceLink is a Hooke spring: strength*(d-L)*alpha). */
const ANCHOR_RETENTION = 0.85;
/** Gravity strengths. d3 manyBody repulsion falls off as 1/d, so two
* 20-node blobs at distance d push with ~n1*n2*|charge|/d; cluster
* spacing settles where that equals g*d*n/2 — g=0.03 lands clusters
* ~400 world units apart (the FA2-era density), and the anchor re-blend
* (position-space, alpha-independent) keeps gravity's transient sag
* from ever moving an anchored node at rest. */
const BOOTSTRAP_GRAVITY = 0.2;
/** Exported: the live sim reuses this so its rest state matches the
* server settle's equilibrium (one engine, one rest). */
export const INCREMENTAL_GRAVITY = 0.12;
/** Exported: default cluster-centroid pull strength (tuned by replay —
* compact enough to read as blobs, weak enough that cluster shape is
* still the edge network's). */
export const CLUSTER_PULL_STRENGTH = 0.15;
/**
* The one force set. Returns a STOPPED simulation — callers drive it
* manually (tick-per-frame live, or a bounded tick loop when settling).
* Deterministic; mutates `nodes` in place (d3 semantics: impure by design,
* zero per-tick allocation).
*/
export function createGraphSimulation(
nodes: LayoutNode[],
edges: ReadonlyArray<LayoutEdge>,
options: SimOptions = {},
): Simulation<LayoutNode, SimLink> {
// Link stiffness: d3's default is 1/min(deg) (degree-scaled springs).
// linkStrength multiplies it, so 1 = unchanged default behavior.
const degree = new Map<string, number>();
for (const e of edges) {
degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
}
const linkStrength = options.linkStrength ?? 1;
const linkDistance = options.linkDistance ?? LINK_DISTANCE;
const sim = forceSimulation<LayoutNode, SimLink>(nodes)
.force(
"link",
forceLink<LayoutNode, SimLink>(
edges.map((e) => ({ source: e.from, target: e.to })),
)
.id((d) => d.id)
// Per-link rest length (rendering arc, owner 2026-09-30): a
// link touching an implication node (arg_-prefixed id — the
// string form pre-init, the node ref after) gets HALF the
// base distance, default or user-tuned alike.
.distance(
(l) =>
impLinkId(l.source).startsWith(IMP_ID_PREFIX) || impLinkId(l.target).startsWith(IMP_ID_PREFIX)
? linkDistance / 2
: linkDistance,
)
.strength(
(l) =>
(linkStrength * 1) /
Math.min(degree.get((l.source as LayoutNode).id) ?? 1, degree.get((l.target as LayoutNode).id) ?? 1),
),
)
.force("charge", forceManyBody<LayoutNode>().strength(options.chargeStrength ?? CHARGE_STRENGTH))
.alphaDecay(options.alphaDecay ?? 0.0228)
.velocityDecay(options.velocityDecay ?? 0.4)
.stop();
// Gravity, mode-projected: in vertical mode the depth force OWNS the
// y axis, so gravity goes x-only (it would fight the level lines); in
// radial mode a center pull is purely radial and would fight the
// rings, so it is dropped entirely (rings already bound the radius).
// Depth mode projections (DECIDED 2026-09-13), with the ORPHAN RULE
// (user-decided, same day): a node with no edges at all has no
// dependency structure — depth is a property of inference chains, so
// stamping unconnected propositions onto the baseline / base ring
// assigns them meaning they don't have. Orphans are exempt from the
// depth force and keep the REGULAR view's forces instead (full center
// gravity + full cluster pull, unprojected).
const mode = options.depthAnchor?.mode ?? "off";
// The depth machinery reads the LOGICAL edge list when supplied —
// structure is inference over propositions, not the drawn geometry.
const depthEdges = options.logicalEdges ?? edges;
let orphan: Uint8Array | null = null;
if (mode !== "off") {
const connected = new Set<string>();
const valid = new Set(nodes.map((n) => n.id));
for (const e of depthEdges) {
// Self-loops and dangling endpoints are not a structure (same
// guards as inferenceDepths).
if (e.from === e.to || !valid.has(e.from) || !valid.has(e.to)) continue;
connected.add(e.from);
connected.add(e.to);
}
orphan = new Uint8Array(nodes.length);
nodes.forEach((n, i) => {
// IMP EXEMPTION joins the mask (owner, 2026-09-30): depth is a
// property of inference over PROPOSITIONS; an implication node
// is pure geometry — never pinned to a level, keeps the full
// regular forces (gravity, cluster pull, springs), and settles
// naturally between premise and conclusion levels.
if (!connected.has(n.id) || n.id.startsWith(IMP_ID_PREFIX)) orphan![i] = 1;
});
}
const g = options.gravity;
if (g && mode === "off") {
sim.force("x", forceX<LayoutNode>(g.x).strength(g.strength));
sim.force("y", forceY<LayoutNode>(g.y).strength(g.strength));
} else if (g) {
// Mode-projected gravity, EXACTLY d3's forceX/forceY math (restoring
// force × strength × alpha): structured nodes get x-only in vertical
// mode (the depth force owns y) and none in radial mode (the rings
// own the radius); ORPHANS get the full regular-view pull in every
// mode.
const orphanMask = orphan!;
sim.force("gravity", (alpha: number) => {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i]!;
if (orphanMask[i]) {
n.vx = (n.vx ?? 0) + (g.x - (n.x ?? 0)) * g.strength * alpha;
n.vy = (n.vy ?? 0) + (g.y - (n.y ?? 0)) * g.strength * alpha;
} else if (mode === "vertical") {
n.vx = (n.vx ?? 0) + (g.x - (n.x ?? 0)) * g.strength * alpha;
}
// radial + structured: nothing — the rings own the radius.
}
});
}
// Cluster centroid pull: the membership index is resolved ONCE (nodes
// don't change clusters mid-sim); each tick is two allocation-free
// O(n) passes (sum, then pull). Singletons (<2 members) skip themselves.
const cp = options.clusterPull;
if (cp && cp.strength > 0 && cp.clusters.length > 0) {
const clusterIdx = new Map<string, number>();
cp.clusters.forEach((c, i) => {
for (const id of c.members) clusterIdx.set(id, i);
});
const nodeCluster = nodes.map((n) => clusterIdx.get(n.id) ?? -1);
const k = cp.clusters.length;
const sumX = new Float64Array(k);
const sumY = new Float64Array(k);
const cnt = new Float64Array(k);
const strength = cp.strength;
// The cluster centroid pull also respects the depth mode: vertical
// → x-only (the depth force owns y); radial → tangential only
// (the radial component would drag members off their rings; the
// tangential component still tightens each cluster's angular
// spread, which is all clustering needs to do there).
const ringCx = options.depthAnchor?.center?.x ?? 0;
const ringCy = options.depthAnchor?.center?.y ?? 0;
sim.force("clusterPull", (alpha: number) => {
sumX.fill(0);
sumY.fill(0);
cnt.fill(0);
for (let i = 0; i < nodes.length; i++) {
const ci = nodeCluster[i]!;
if (ci < 0) continue;
const n = nodes[i]!;
sumX[ci] = sumX[ci]! + (n.x ?? 0);
sumY[ci] = sumY[ci]! + (n.y ?? 0);
cnt[ci] = cnt[ci]! + 1;
}
for (let i = 0; i < nodes.length; i++) {
const ci = nodeCluster[i]!;
if (ci < 0 || (cnt[ci] ?? 0) < 2) continue;
const n = nodes[i]!;
const px = n.x ?? 0;
const py = n.y ?? 0;
const ux = sumX[ci]! / cnt[ci]! - px;
const uy = sumY[ci]! / cnt[ci]! - py;
if (orphan && orphan[i]) {
// Orphans: full regular-view pull (see the ORPHAN RULE above).
n.vx = (n.vx ?? 0) + ux * strength * alpha;
n.vy = (n.vy ?? 0) + uy * strength * alpha;
} else if (mode === "vertical") {
n.vx = (n.vx ?? 0) + ux * strength * alpha;
} else if (mode === "radial") {
const rx = px - ringCx;
const ry = py - ringCy;
const r = Math.hypot(rx, ry);
if (r > 1e-9) {
// Tangential component: u − (u·r̂)r̂
const dot = (ux * rx + uy * ry) / (r * r);
n.vx = (n.vx ?? 0) + (ux - dot * rx) * strength * alpha;
n.vy = (n.vy ?? 0) + (uy - dot * ry) * strength * alpha;
}
} else {
n.vx = (n.vx ?? 0) + ux * strength * alpha;
n.vy = (n.vy ?? 0) + uy * strength * alpha;
}
}
});
}
// ── Inference-depth anchoring ────────────────────────────────────────
// The rank is COMPUTED (not simulated): per-node target scalar derived
// from inferenceDepths, then one O(n) pull pass per tick. Soft knob —
// high strength approaches pinned rows/rings while low strength leaves
// organic sag.
const da = options.depthAnchor;
if (da && da.mode !== "off" && da.strength > 0) {
// Propositions only: imp ids are absent from the rank map by
// construction (belt under the orphan-mask exemption).
const depths = inferenceDepths(
nodes.filter((n) => !n.id.startsWith(IMP_ID_PREFIX)),
depthEdges,
);
const strength = da.strength;
const spacing = da.spacing;
const c = da.center ?? { x: 0, y: 0 };
const orphanMask = orphan; // ORPHAN RULE: depth skips unstructured nodes
if (da.mode === "vertical") {
const baseY = c.y;
sim.force("depth", (alpha: number) => {
for (let i = 0; i < nodes.length; i++) {
if (orphanMask?.[i]) continue;
const n = nodes[i]!;
const targetY = baseY - (depths.get(n.id) ?? 0) * spacing;
n.vy = (n.vy ?? 0) + (targetY - (n.y ?? 0)) * strength * alpha;
}
});
} else {
const baseR = da.baseRadius ?? 0;
const cx0 = c.x;
const cy0 = c.y;
sim.force("depth", (alpha: number) => {
for (let i = 0; i < nodes.length; i++) {
if (orphanMask?.[i]) continue;
const n = nodes[i]!;
const dx = (n.x ?? 0) - cx0;
const dy = (n.y ?? 0) - cy0;
// Degenerate direction at the exact ring center: nudge
// outward on a deterministic axis.
const r = Math.hypot(dx, dy);
const ux = r > 1e-9 ? dx / r : 1;
const uy = r > 1e-9 ? dy / r : 0;
const target = baseR + (depths.get(n.id) ?? 0) * spacing;
const mag = (target - r) * strength * alpha;
n.vx = (n.vx ?? 0) + ux * mag;
n.vy = (n.vy ?? 0) + uy * mag;
}
});
}
}
return sim;
}
export interface IncrementalLayoutOptions {
/** Manual ticks for the settle (default 300 — d3's anneal to alphaMin). */
ticks?: number;
/** Per-tick anchor re-blend (default 0.85; 1 = fully pinned). */
anchorRetention?: number;
/** Center-of-mass gravity strength override (tuning/A-B experiments;
* defaults to the internal bootstrap/incremental constants). */
gravityStrength?: number;
/** Cluster partition (Louvain output shape). Supplied by callers so
* this module stays louvain-free (the browser bundle must not carry
* embedding ledger; absent = no cluster pull. */
clusters?: ReadonlyArray<{ members: ReadonlyArray<string> }>;
/** Cluster centroid pull strength (default CLUSTER_PULL_STRENGTH). */
clusterPullStrength?: number;
/** Inference-depth anchoring (see DepthAnchor). `center` defaults to
* the anchored core's centroid. */
depthAnchor?: DepthAnchor;
/** The LOGICAL edge list for the depth pass (see SimOptions). */
logicalEdges?: ReadonlyArray<LayoutEdge>;
}
/**
* One incremental settle. `nodes`/`edges` describe the CURRENT graph;
* `previous` is the positions ledger (empty on the very first settle —
* then this degrades to a seeded-scatter bootstrap). Returns positions
* for EVERY node in `nodes`.
*/
export function incrementalLayout(
nodes: ReadonlyArray<Pick<GraphNode, "id">>,
edges: ReadonlyArray<LayoutEdge>,
previous: ReadonlyMap<string, Position>,
options: IncrementalLayoutOptions = {},
): Map<string, Position> {
const ticks = options.ticks ?? 300;
const retention = options.anchorRetention ?? ANCHOR_RETENTION;
// Anchors: the warm-start set (the previous rest state).
const anchors = new Map<string, Position>();
for (const node of nodes) {
const prev = previous.get(node.id);
if (prev) anchors.set(node.id, { x: prev.x, y: prev.y });
}
// The anchored core's bounding box + centroid, for rim seeds.
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
let cx = 0;
let cy = 0;
for (const a of anchors.values()) {
minX = Math.min(minX, a.x);
maxX = Math.max(maxX, a.x);
minY = Math.min(minY, a.y);
maxY = Math.max(maxY, a.y);
cx += a.x;
cy += a.y;
}
const hasCore = anchors.size > 0;
if (hasCore) {
cx /= anchors.size;
cy /= anchors.size;
}
const coreRadius = hasCore ? Math.max(maxX - minX, maxY - minY) / 2 : 1;
// Adjacency for neighbor seeding (undirected: edges include assertions).
const adjacency = new Map<string, string[]>();
const linkTo = (a: string, b: string): void => {
if (a === b) return;
let list = adjacency.get(a);
if (!list) adjacency.set(a, (list = []));
list.push(b);
};
for (const e of edges) {
linkTo(e.from, e.to);
linkTo(e.to, e.from);
}
// The rim radius derives from the CONNECTED core's mean radius — orphans
// never inflate it (a max-span-based radius feeds back: each batch's
// rim pushes the next batch's rim farther out, and the layout runs
// away). 1.7x matches the density the FA2-era settle produced
// (orphan mean r ~338 vs connected ~194).
let rimRadius = 0;
let connectedAnchored = 0;
for (const [id, a] of anchors) {
if ((adjacency.get(id)?.length ?? 0) === 0) continue;
connectedAnchored++;
rimRadius += Math.hypot(a.x - cx, a.y - cy);
}
if (connectedAnchored > 0) rimRadius = (rimRadius / connectedAnchored) * 1.7;
else if (hasCore) rimRadius = coreRadius * 1.15; // no connected anchors yet: bbox fallback
rimRadius += 4;
// Seed every node.
const simNodes: LayoutNode[] = nodes.map((node) => {
const rand = seededRandom(node.id);
const anchor = anchors.get(node.id);
let x: number;
let y: number;
if (anchor) {
// Warm start: resume from the previous rest position.
x = anchor.x;
y = anchor.y;
} else {
// New node: seed near its positioned neighbors (the cluster it
// arrived with), else on the rim outside the core, else scatter.
let sx = 0;
let sy = 0;
let found = 0;
for (const nbr of adjacency.get(node.id) ?? []) {
const na = anchors.get(nbr);
if (na) {
sx += na.x;
sy += na.y;
found++;
}
}
if (found > 0) {
x = sx / found + (rand() - 0.5) * 8;
y = sy / found + (rand() - 0.5) * 8;
} else if (hasCore) {
const angle = rand() * Math.PI * 2;
const radius = rimRadius + rand() * 8;
x = cx + Math.cos(angle) * radius;
y = cy + Math.sin(angle) * radius;
// An orphan's intended rest spot IS the rim seed: anchor it
// there so gravity cannot sink it into the core. (A later
// batch linking it to the graph pulls it in naturally —
// but then it also has a previous position, and its anchor
// becomes that previous position, exactly like any node.)
anchors.set(node.id, { x, y });
} else {
// Bootstrap: the classic seeded scatter.
x = (rand() - 0.5) * 100;
y = (rand() - 0.5) * 100;
}
}
// d3 is fine with coincident seeds (no division), but nudge exact
// overlaps of a brand-new node onto its only neighbor anyway.
if (x === 0 && y === 0) x = 0.5;
return { id: node.id, x, y };
});
const sim = createGraphSimulation(simNodes, edges, {
gravity: {
x: cx,
y: cy,
strength: options.gravityStrength ?? (hasCore ? INCREMENTAL_GRAVITY : BOOTSTRAP_GRAVITY),
},
clusterPull: options.clusters
? { clusters: options.clusters, strength: options.clusterPullStrength ?? CLUSTER_PULL_STRENGTH }
: undefined,
depthAnchor:
options.depthAnchor && options.depthAnchor.mode !== "off"
? { ...options.depthAnchor, center: options.depthAnchor.center ?? { x: cx, y: cy } }
: options.depthAnchor,
logicalEdges: options.logicalEdges,
});
const dMode = options.depthAnchor?.mode ?? "off";
const dCenter = options.depthAnchor?.center ?? { x: cx, y: cy };
const byId = new Map(simNodes.map((n) => [n.id, n]));
for (let i = 0; i < ticks; i++) {
sim.tick();
// Anchor re-blend (position space, alpha-independent — see the
// ANCHOR_RETENTION note). Pinned nodes (fx/fy) are left to d3.
// DEPTH-MODE PROJECTION: the re-blend preserves the user's
// arrangement in the FREE dimension only — x in vertical mode, the
// angle in radial mode — because the depth force owns the
// constrained dimension (a node's level/ring is meaning, not
// memory). Off-mode: full 2D blend as before.
if (hasCore && retention < 1) {
for (const [id, a] of anchors) {
const n = byId.get(id);
if (!n || n.fx != null || n.fy != null) continue;
const x = n.x ?? 0;
const y = n.y ?? 0;
if (dMode === "vertical") {
n.x = a.x + (x - a.x) * retention;
} else if (dMode === "radial") {
const rx = x - dCenter.x;
const ry = y - dCenter.y;
const r = Math.hypot(rx, ry);
const ax = a.x - dCenter.x;
const ay = a.y - dCenter.y;
const ar = Math.hypot(ax, ay);
if (r > 1e-9 && ar > 1e-9) {
const theta = Math.atan2(ry, rx);
const thetaA = Math.atan2(ay, ax);
const blended = thetaA + Math.atan2(Math.sin(theta - thetaA), Math.cos(theta - thetaA)) * retention;
n.x = dCenter.x + r * Math.cos(blended);
n.y = dCenter.y + r * Math.sin(blended);
}
} else {
n.x = a.x + (x - a.x) * retention;
n.y = a.y + (y - a.y) * retention;
}
}
}
}
const out = new Map<string, Position>();
for (const n of simNodes) out.set(n.id, { x: n.x ?? 0, y: n.y ?? 0 });
return out;
}