diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 5b3c71a..37d368e 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -60,6 +60,7 @@ import { createGraphSimulation, INCREMENTAL_GRAVITY, LINK_DISTANCE, + type DepthAnchorMode, type GraphRenderSettings, type LayoutNode, type Position, @@ -218,6 +219,14 @@ export interface PhysicsTuning { clusterPull: number; /** Per-tick velocity damping (d3 default 0.4). */ velocityDecay: number; + /** Inference-depth anchoring mode (see DepthAnchor in core). */ + depthMode: DepthAnchorMode; + /** Depth-anchor pull strength. */ + depthStrength: number; + /** Spacing between inference levels (world units). */ + depthSpacing: number; + /** Inner ring radius, radial mode (world units). */ + depthBaseRadius: number; } export const DEFAULT_PHYSICS_TUNING: PhysicsTuning = { @@ -227,6 +236,13 @@ export const DEFAULT_PHYSICS_TUNING: PhysicsTuning = { gravity: INCREMENTAL_GRAVITY, clusterPull: CLUSTER_PULL_STRENGTH, velocityDecay: 0.4, + depthMode: "off", + // 0.8 by sweep on the real graph: 215/216 arrows read upward with + // organic sag (mean |dev| from level ~34u at spacing 60); 1.5 gives + // crisp rows (dev ~18) if the promoted default wants them. + depthStrength: 0.8, + depthSpacing: 60, + depthBaseRadius: 220, }; /** Clamp anything into a usable tuning (number inputs can be empty/NaN). */ @@ -241,6 +257,10 @@ export function sanitizePhysicsTuning(input: Partial | null | und gravity: Math.min(Math.max(num(input?.gravity, d.gravity), 0), 1), clusterPull: Math.min(Math.max(num(input?.clusterPull, d.clusterPull), 0), 2), velocityDecay: Math.min(Math.max(num(input?.velocityDecay, d.velocityDecay), 0), 0.99), + depthMode: input?.depthMode === "vertical" || input?.depthMode === "radial" ? input.depthMode : "off", + depthStrength: Math.min(Math.max(num(input?.depthStrength, d.depthStrength), 0), 2), + depthSpacing: Math.min(Math.max(num(input?.depthSpacing, d.depthSpacing), 1), 500), + depthBaseRadius: Math.min(Math.max(num(input?.depthBaseRadius, d.depthBaseRadius), 0), 2000), }; } @@ -670,6 +690,14 @@ export class GraphCanvasRenderer { setPhysicsTuning(input: Partial): void { this.physicsTuning = sanitizePhysicsTuning(input); this.buildSim(); + // Reheat: a tuning change re-anneals the layout from its current + // state, so mode toggles (off/vertical/radial) visibly RE-FORM the + // graph instead of waiting for the next drag. Mid-drag is safe — + // buildSim preserved the pinned node's fx/fy. + if (this.sim) { + this.sim.alpha(1); + this.simActive = true; + } this.markDirty(); } @@ -1085,6 +1113,16 @@ export class GraphCanvasRenderer { linkDistance: t.linkDistance, clusterPull: data.clusters.length > 0 ? { clusters: data.clusters, strength: t.clusterPull } : undefined, + depthAnchor: + t.depthMode === "off" + ? undefined + : { + mode: t.depthMode, + strength: t.depthStrength, + spacing: t.depthSpacing, + baseRadius: t.depthBaseRadius, + center: this.simCenter, + }, }); this.simById = new Map(data.nodes.map((n) => [n.id, n as LayoutNode])); } diff --git a/components/graph-view.tsx b/components/graph-view.tsx index c0f64fb..822a6bf 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -665,6 +665,41 @@ export function GraphView() { Physics + + setPhysicsTuning((t) => ({ ...t, depthStrength: v }))} + /> + setPhysicsTuning((t) => ({ ...t, depthSpacing: v }))} + /> + setPhysicsTuning((t) => ({ ...t, depthBaseRadius: v }))} + /> a.startsWith("--depth=")); +const depthMode: "off" | "vertical" | "radial" = + depthArg === "--depth=vertical" ? "vertical" : depthArg === "--depth=radial" ? "radial" : "off"; import { incrementalLayout } from "../src/core/layout.ts"; import type { GraphNode, NodeMapping, Position } from "../src/core/contracts.ts"; @@ -74,6 +81,16 @@ function main() { positions = incrementalLayout(batchNodes, activeEdges, positions, { // The same partition the real settle would see for this batch. clusters: detectCommunities(batchNodes, activeEdges).communities, + ...(depthMode === "off" + ? {} + : { + depthAnchor: { + mode: depthMode, + strength: 0.8, + spacing: 60, + ...(depthMode === "radial" ? { baseRadius: 220 } : {}), + }, + }), }); for (const [id, prev] of before) { const now = positions.get(id)!; diff --git a/src/core/layout.ts b/src/core/layout.ts index e2da57e..26fad48 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -43,6 +43,122 @@ export interface LayoutNode extends SimulationNodeDatum { /** A link as d3 sees it (id strings resolve to node refs on init). */ type SimLink = SimulationLinkDatum; +/** 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>, + edges: ReadonlyArray, +): Map { + const valid = new Set(nodes.map((n) => n.id)); + const adj = new Map(); + 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(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const comp = new Map(); + 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[] = Array.from({ length: compCount }, () => new Set()); + 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(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(); + 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; @@ -59,6 +175,8 @@ export interface SimOptions { 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; /** 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 @@ -140,10 +258,15 @@ export function createGraphSimulation( .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). + const mode = options.depthAnchor?.mode ?? "off"; const g = options.gravity; - if (g) { + if (g && mode !== "radial") { sim.force("x", forceX(g.x).strength(g.strength)); - sim.force("y", forceY(g.y).strength(g.strength)); + if (mode !== "vertical") sim.force("y", forceY(g.y).strength(g.strength)); } // Cluster centroid pull: the membership index is resolved ONCE (nodes @@ -161,6 +284,13 @@ export function createGraphSimulation( 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); @@ -177,11 +307,72 @@ export function createGraphSimulation( const ci = nodeCluster[i]!; if (ci < 0 || (cnt[ci] ?? 0) < 2) continue; const n = nodes[i]!; - n.vx = (n.vx ?? 0) + (sumX[ci]! / cnt[ci]! - (n.x ?? 0)) * strength * alpha; - n.vy = (n.vy ?? 0) + (sumY[ci]! / cnt[ci]! - (n.y ?? 0)) * strength * alpha; + 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 (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) { + const depths = inferenceDepths(nodes, edges); + const strength = da.strength; + const spacing = da.spacing; + const c = da.center ?? { x: 0, y: 0 }; + if (da.mode === "vertical") { + const baseY = c.y; + sim.force("depth", (alpha: number) => { + for (let i = 0; i < nodes.length; i++) { + 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++) { + 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; } @@ -199,6 +390,9 @@ export interface IncrementalLayoutOptions { clusters?: ReadonlyArray<{ members: ReadonlyArray }>; /** 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; } /** @@ -333,20 +527,49 @@ export function incrementalLayout( 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, }); + 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; - n.x = a.x + (x - a.x) * retention; - n.y = a.y + (y - a.y) * retention; + 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; + } } } } diff --git a/tests/layout.test.ts b/tests/layout.test.ts index 15e6fc0..7e71f44 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -6,7 +6,7 @@ */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { createGraphSimulation, incrementalLayout } from "../src/core/layout.ts"; +import { createGraphSimulation, incrementalLayout, inferenceDepths } from "../src/core/layout.ts"; import { settleGraphLayout } from "../src/core/layout-settle.ts"; import type { GraphNode, GraphEdge, Position } from "../src/core/contracts.ts"; @@ -168,3 +168,104 @@ describe("clusterPull (the centroid force)", () => { assert.ok(pulled > 0, "spread is finite/positive"); }); }); + +describe("inferenceDepths (SCC-safe ranking)", () => { + const nodes = (ids: string[]) => ids.map((id) => ({ id })); + + it("ranks a chain, a diamond takes the longest path, orphans are depth 0", () => { + // chain a->b->c; diamond a->d, b->d, c->d (d reachable at 1 and 2 and 3) + const ns = nodes(["a", "b", "c", "d", "loner"]); + const edges = [ + { from: "a", to: "b" }, + { from: "b", to: "c" }, + { from: "a", to: "d" }, + { from: "b", to: "d" }, + { from: "c", to: "d" }, + ]; + const d = inferenceDepths(ns, edges); + assert.equal(d.get("a"), 0); + assert.equal(d.get("b"), 1); + assert.equal(d.get("c"), 2); + assert.equal(d.get("d"), 3); // longest path, not shortest + assert.equal(d.get("loner"), 0); + }); + + it("cycles share a depth (mutual support = equal footing)", () => { + // x <-> y (a cycle), fed by s; z follows x. + const ns = nodes(["s", "x", "y", "z"]); + const edges = [ + { from: "s", to: "x" }, + { from: "x", to: "y" }, + { from: "y", to: "x" }, + { from: "x", to: "z" }, + ]; + const d = inferenceDepths(ns, edges); + assert.equal(d.get("s"), 0); + assert.equal(d.get("x"), d.get("y"), "cycle members share a depth"); + assert.equal(d.get("x"), 1); + assert.equal(d.get("z"), 2); + // Deterministic across calls. + assert.deepEqual([...inferenceDepths(ns, edges).entries()], [...d.entries()]); + }); +}); + +describe("depthAnchor force", () => { + const nodes = (ids: string[]) => ids.map((id) => ({ id, x: 0, y: 0 })); + + it("vertical: the premise lands below its conclusion", () => { + const ns = nodes(["base", "derived"]); + const edges = [{ from: "base", to: "derived" }]; + const sim = createGraphSimulation(ns, edges, { + depthAnchor: { mode: "vertical", strength: 1.2, spacing: 100, center: { x: 0, y: 0 } }, + }); + for (let i = 0; i < 300; i++) sim.tick(); + const byId = new Map(sim.nodes().map((n) => [n.id, n])); + const baseY = byId.get("base")!.y!; + const derivedY = byId.get("derived")!.y!; + assert.ok(baseY > derivedY, `base (${baseY.toFixed(0)}) should sit BELOW derived (${derivedY.toFixed(0)})`); + assert.ok( + baseY - derivedY > 60, + `level separation should be substantial (got ${(baseY - derivedY).toFixed(0)})`, + ); + }); + + it("radial: inference depth becomes radius", () => { + const ns = nodes(["base", "mid", "top"]); + const edges = [ + { from: "base", to: "mid" }, + { from: "mid", to: "top" }, + ]; + const sim = createGraphSimulation(ns, edges, { + depthAnchor: { mode: "radial", strength: 1.2, spacing: 80, baseRadius: 100, center: { x: 0, y: 0 } }, + }); + for (let i = 0; i < 300; i++) sim.tick(); + const r = (id: string) => { + const n = sim.nodes().find((x) => x.id === id)!; + return Math.hypot(n.x!, n.y!); + }; + assert.ok(r("base") < r("mid"), `base ring (${r("base").toFixed(0)}) inside mid (${r("mid").toFixed(0)})`); + assert.ok(r("mid") < r("top"), `mid ring (${r("mid").toFixed(0)}) inside top (${r("top").toFixed(0)})`); + }); + + it("settle with depthAnchor: old nodes keep x memory but snap to their level", () => { + // Node "old" was previously placed at y=300 (off-level). With + // vertical anchoring a re-settle must move it to the baseline + // while preserving (roughly) its x arrangement. + const previous = new Map([["old", { x: 250, y: 300 }], ["peer", { x: 260, y: 290 }]]); + const next = incrementalLayout( + [{ id: "old" }, { id: "peer" }, { id: "child" }], + [ + { from: "old", to: "child" }, + { from: "peer", to: "child" }, + ], + previous, + { depthAnchor: { mode: "vertical", strength: 0.6, spacing: 80 } }, + ); + const old = next.get("old")!; + // The baseline defaults to the anchor centroid: (300+290)/2 = 295. + assert.ok(Math.abs(old.y - 295) < 20, `old node snaps to the baseline (y=${old.y.toFixed(1)})`); + assert.ok(old.x > 150, `x memory preserved (x=${old.x.toFixed(1)})`); + const child = next.get("child")!; + assert.ok(child.y < old.y - 40, `child rises above its premises (child=${child.y.toFixed(1)}, old=${old.y.toFixed(1)})`); + }); +});