diff --git a/src/core/layout.ts b/src/core/layout.ts index b958577..0686dc6 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -291,7 +291,11 @@ export function createGraphSimulation( // 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). + // gravity + full cluster pull, unprojected). AMENDED (owner, + // 2026-09-30 physics follow-up): in VERTICAL mode the central pole is + // a HORIZONTAL force for everyone — structured and orphan/imp alike — + // y belongs to the depth rows and to nothing else; the full regular + // pull survives only in radial mode (where the rings own the radius). 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. @@ -311,9 +315,10 @@ export function createGraphSimulation( 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. + // is pure geometry — never pinned to a level, keeps the + // regular forces (cluster pull, springs, and gravity — + // horizontal-only in vertical mode, like everyone), and + // settles naturally between premise and conclusion levels. if (!connected.has(n.id) || n.id.startsWith(IMP_ID_PREFIX)) orphan![i] = 1; }); } @@ -323,19 +328,21 @@ export function createGraphSimulation( sim.force("y", forceY(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. + // force × strength × alpha). VERTICAL (owner, 2026-09-30 physics + // follow-up): the central pole is horizontal-ONLY for EVERY node — + // structured (the depth force owns y) and orphan/imp alike (an + // unstructured node has no row to sit on and no pole to sag + // toward; its y is free). RADIAL: orphans keep the full regular + // pull; structured nodes get nothing (the rings own the radius). const orphanMask = orphan!; sim.force("gravity", (alpha: number) => { for (let i = 0; i < nodes.length; i++) { const n = nodes[i]!; - if (orphanMask[i]) { + if (mode === "vertical") { + n.vx = (n.vx ?? 0) + (g.x - (n.x ?? 0)) * g.strength * alpha; + } else 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. } diff --git a/tests/layout.test.ts b/tests/layout.test.ts index 077a166..27b15fd 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -224,7 +224,27 @@ describe("depths over the LOGICAL graph (physics arc, owner 2026-09-30)", () => assert.equal(depths.get("c"), 1); }); - it("the depth force EXEMPTS the imp node like an orphan (full gravity, never pinned to a row)", () => { + it("VERTICAL mode: the central pole is horizontal-ONLY for everyone — orphans included (owner, 2026-09-30)", () => { + const mk = (id: string, x: number, y: number) => ({ id, x, y }); + const sim = createGraphSimulation( + [mk("a", 0, 0), mk("b", 40, 0), mk("lonely", 500, 400)], + [{ from: "a", to: "b" }], + { + depthAnchor: { mode: "vertical", strength: 1, spacing: 60 }, + gravity: { x: 0, y: 0, strength: 0.1 }, + chargeStrength: 0, + }, + ); + for (let i = 0; i < 400; i++) sim.tick(); + const pos = new Map((sim.nodes() as unknown as { id: string; x: number; y: number }[]).map((n) => [n.id, n])); + // The orphan converges on x (the pole is still a pole)... + assert.ok(Math.abs(pos.get("lonely")!.x) < 50, `lonely x pulled to center (got ${pos.get("lonely")!.x.toFixed(1)})`); + // ...but its y is UNTOUCHED — no row to sit on, no sag toward the + // pole (old behavior dragged it to y≈0). + assert.ok(Math.abs(pos.get("lonely")!.y - 400) < 5, `lonely y free (got ${pos.get("lonely")!.y.toFixed(1)})`); + }); + + it("the depth force EXEMPTS the imp node like an orphan (never pinned to a row)", () => { const mk = (id: string, x: number, y: number) => ({ id, x, y }); const sim = createGraphSimulation( [mk("a", 0, 0), mk("b", 40, 0), mk("c", 0, -480), mk("arg_x", 20, -100)], @@ -256,9 +276,9 @@ describe("depths over the LOGICAL graph (physics arc, owner 2026-09-30)", () => assert.ok(Math.abs(pos.get("a")!.y) < 50, `a on row 0 (got ${pos.get("a")!.y.toFixed(1)})`); assert.ok(Math.abs(pos.get("b")!.y) < 50, `b on row 0 (got ${pos.get("b")!.y.toFixed(1)})`); assert.ok(Math.abs(pos.get("c")!.y + 500) < 50, `c on row 1 (got ${pos.get("c")!.y.toFixed(1)})`); - // The imp node is exempt: pulled by full gravity toward the - // center, NOT stamped onto c's row (old behavior pinned it to - // −500 as a depth-1 "node"). + // The imp node is exempt: NOT stamped onto c's row (old behavior + // pinned it to −500 as a depth-1 "node"); springs alone place + // it between the levels. assert.ok(Math.abs(pos.get("arg_x")!.y) < 250, `arg_x unpinned, floats between levels (got ${pos.get("arg_x")!.y.toFixed(1)})`); }); }); @@ -363,27 +383,30 @@ describe("depthAnchor force", () => { assert.ok(child.y < old.y - 40, `child rises above its premises (child=${child.y.toFixed(1)}, old=${old.y.toFixed(1)})`); }); - it("orphans are exempt from depth anchoring — regular forces govern them (ORPHAN RULE)", () => { + it("orphans are exempt from depth anchoring — y is FREE, the pole is horizontal (ORPHAN RULE, amended 2026-09-30)", () => { // A premise→conclusion chain plus an unconnected loner. Gravity's - // center is FAR from the baseline, so the two are distinguishable: - // a depth-stamped orphan would sit on the baseline; an exempt one - // follows gravity instead. + // center is FAR off-axis, so the forces are distinguishable: a + // depth-stamped orphan would sit on the baseline; the pre-amend + // exempt one sagged to gravity's y. Since the owner's 2026-09-30 + // amendment the vertical pole is horizontal-ONLY for everyone — + // the orphan keeps its y and follows gravity on x alone. const ns = [ { id: "base", x: 0, y: 0 }, { id: "derived", x: 10, y: 0 }, - { id: "loner", x: 0, y: 0 }, + { id: "loner", x: 400, y: -80 }, ]; const sim = createGraphSimulation(ns, [{ from: "base", to: "derived" }], { gravity: { x: 0, y: 500, strength: 0.1 }, depthAnchor: { mode: "vertical", strength: 1.5, spacing: 120, center: { x: 0, y: 0 } }, + chargeStrength: 0, }); for (let i = 0; i < 400; i++) sim.tick(); const byId = new Map(sim.nodes().map((n) => [n.id, n])); assert.ok(Math.abs(byId.get("base")!.y!) < 40, "structured base sits on the baseline"); assert.ok(byId.get("derived")!.y! < -60, "derived rises a level"); - const lonerY = byId.get("loner")!.y!; - assert.ok(lonerY > 250, `the orphan follows gravity, not the baseline (y=${lonerY.toFixed(0)})`); - assert.ok(Math.abs(lonerY - 500) < 120, `the orphan converges on gravity's center (y=${lonerY.toFixed(0)})`); + const loner = byId.get("loner")!; + assert.ok(Math.abs(loner.y! - (-80)) < 5, `the orphan's y is untouched (y=${loner.y!.toFixed(0)})`); + assert.ok(Math.abs(loner.x!) < 60, `the orphan still converges on the pole horizontally (x=${loner.x!.toFixed(0)})`); }); it("radial: orphans are exempt — they gather at the center, not on the base ring", () => {