Inference-depth anchoring playground: vertical + radial modes
The graph's follows/supports edges encode derivation (premise -> conclusion); the layout can now express that rank spatially, as a session-only playground toggle (nothing promoted yet): - inferenceDepths: longest chain from a base node, CYCLE-SAFE by SCC condensation (Tarjan, iterative) — the DAG property is typical, not guaranteed; cycle members share their component's depth (mutual support = equal footing). O(V+E), pure, deterministic. - DepthAnchor force: per-node target scalar — vertical mode rows (baseline at the anchor centroid, arrows up), radial mode rings (foundations center, outer boundary = edge of knowledge). The rank is computed, not simulated: pairwise up-forces can't give baselines. - Mode projections: gravity x-only in vertical / dropped in radial; cluster pull x-only in vertical / tangential-only in radial; the anchor re-blend preserves user arrangement in the FREE dimension only (x / angle) — meaning owns the constrained axis, memory owns the free one. - setPhysicsTuning reheats (alpha 1): toggling a mode re-forms the graph live and commits the new rest state; drag release is springy along the constrained axis (node returns toward its level/ring). - Sweep on the real graph: strength 0.8 = 215/216 arrows correct with organic sag; 1.5 = crisp rows. 174 base nodes, max chain depth 7, zero cycles in current data. replay-layout --depth=<mode> bakes a mode into the ledger for rest-state experiments.
This commit is contained in:
@@ -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<PhysicsTuning> | 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<PhysicsTuning>): 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]));
|
||||
}
|
||||
|
||||
@@ -665,6 +665,41 @@ export function GraphView() {
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Physics
|
||||
</span>
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
Depth anchor
|
||||
<select
|
||||
className="rounded border bg-background px-1 py-0.5 text-xs"
|
||||
value={physicsTuning.depthMode}
|
||||
onChange={(e) =>
|
||||
setPhysicsTuning((t) => ({
|
||||
...t,
|
||||
depthMode: e.target.value as PhysicsTuning["depthMode"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="vertical">Vertical (up)</option>
|
||||
<option value="radial">Radial (outward)</option>
|
||||
</select>
|
||||
</label>
|
||||
<NumberField
|
||||
label="Depth strength"
|
||||
value={physicsTuning.depthStrength}
|
||||
step={0.05}
|
||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, depthStrength: v }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Depth spacing"
|
||||
value={physicsTuning.depthSpacing}
|
||||
step={10}
|
||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, depthSpacing: v }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Base ring radius"
|
||||
value={physicsTuning.depthBaseRadius}
|
||||
step={20}
|
||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, depthBaseRadius: v }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Node repulsion"
|
||||
value={physicsTuning.chargeStrength}
|
||||
|
||||
@@ -7,10 +7,17 @@
|
||||
* Reads ONLY the ledgers (nodes / edges / mappings / sources); writes ONLY
|
||||
* data/graph/positions.json — no LLM calls, no extraction, no chat records.
|
||||
*
|
||||
* npx tsx scripts/replay-layout.ts
|
||||
* npx tsx scripts/replay-layout.ts [--depth=vertical|radial]
|
||||
*
|
||||
* --depth regenerates the ledger WITH inference-depth anchoring (the
|
||||
* playground mode baked into the rest state); omit it for the free layout.
|
||||
*/
|
||||
import { graphStore } from "../src/store/graph.ts";
|
||||
import { detectCommunities } from "../src/core/clusters.ts";
|
||||
// --depth=vertical|radial: bake the playground depth mode into the ledger.
|
||||
const depthArg = process.argv.find((a) => 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)!;
|
||||
|
||||
+229
-6
@@ -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<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;
|
||||
@@ -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<LayoutNode>(g.x).strength(g.strength));
|
||||
sim.force("y", forceY<LayoutNode>(g.y).strength(g.strength));
|
||||
if (mode !== "vertical") sim.force("y", forceY<LayoutNode>(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<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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+102
-1
@@ -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)})`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user