diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 37d368e..b9bab57 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -1205,10 +1205,9 @@ export class GraphCanvasRenderer { }); // The region IS the dilated skeleton: intra-cluster edges as - // round-cap strokes (Minkowski dilation — points become discs at - // line ends via the caps). NO per-node discs: a cluster is its - // connected points, and discs double-counted where nodes sit on - // edges (user-identified hot spots). + // round-cap strokes (Minkowski dilation). NO blanket per-node discs + // — they double-counted where nodes sit on edges (user-identified + // hot spots, when clusters were edge-based). this.regionMeta = []; data.clusters.forEach((cluster, ci) => { const hue = clusterHue(cluster.id); @@ -1217,6 +1216,8 @@ export class GraphCanvasRenderer { let members = 0; let sumX = 0; let sumY = 0; + // Endpoints actually covered by a stroke's round caps. + const touched = new Set(); for (const e of data.edges) { if (clusterOf.get(e.from) !== ci) continue; if (clusterOf.get(e.to) !== ci) continue; @@ -1225,9 +1226,29 @@ export class GraphCanvasRenderer { if (!a || !b) continue; rctx.moveTo((a.x - minX) / cell, (a.y - minY) / cell); rctx.lineTo((b.x - minX) / cell, (b.y - minY) / cell); + touched.add(e.from); + touched.add(e.to); } rctx.stroke(); + // Member discs for the SEMANTIC era (DECIDED 2026-09-13): clusters + // are now topics by meaning, and many members (orphans, isolated + // micro-topics) share NO intra-cluster edge with anyone — edge + // strokes alone left them completely uncolored. A disc (same + // radius as a round cap = dilation) covers exactly the members no + // stroke reached: every member is painted by its edges' caps OR + // one disc, never both — the no-double-counting rule survives. + rctx.fillStyle = `hsla(${hue}, 65%, 60%, ${tuning.edgeAlpha})`; + const discR = (tuning.dilation * s) / cell; + for (const id of cluster.members) { + if (touched.has(id) || !isVisible(id)) continue; + const n = this.nodeById.get(id); + if (!n) continue; + rctx.beginPath(); + rctx.arc((n.x - minX) / cell, (n.y - minY) / cell, discR, 0, Math.PI * 2); + rctx.fill(); + } + // Label anchor: member centroid (world coords). for (const id of cluster.members) { if (!isVisible(id)) continue; diff --git a/src/core/layout.ts b/src/core/layout.ts index a555240..df388db 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -262,11 +262,53 @@ export function createGraphSimulation( // 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"; + let orphan: Uint8Array | null = null; + if (mode !== "off") { + const connected = new Set(); + const valid = new Set(nodes.map((n) => n.id)); + for (const e of edges) { + // 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) => { + if (!connected.has(n.id)) orphan![i] = 1; + }); + } const g = options.gravity; - if (g && mode !== "radial") { + if (g && mode === "off") { sim.force("x", forceX(g.x).strength(g.strength)); - if (mode !== "vertical") sim.force("y", forceY(g.y).strength(g.strength)); + 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. + 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 @@ -311,7 +353,11 @@ export function createGraphSimulation( const py = n.y ?? 0; const ux = sumX[ci]! / cnt[ci]! - px; const uy = sumY[ci]! / cnt[ci]! - py; - if (mode === "vertical") { + 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; @@ -342,10 +388,12 @@ export function createGraphSimulation( 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; @@ -357,8 +405,9 @@ export function createGraphSimulation( 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 dx = (n.x ?? 0) - cx0; const dy = (n.y ?? 0) - cy0; // Degenerate direction at the exact ring center: nudge // outward on a deterministic axis. diff --git a/tests/layout.test.ts b/tests/layout.test.ts index 9ae9fc9..f9e1a11 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -270,4 +270,46 @@ describe("depthAnchor force", () => { 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)})`); }); + + it("orphans are exempt from depth anchoring — regular forces govern them (ORPHAN RULE)", () => { + // 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. + const ns = [ + { id: "base", x: 0, y: 0 }, + { id: "derived", x: 10, y: 0 }, + { id: "loner", x: 0, y: 0 }, + ]; + 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 } }, + }); + 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)})`); + }); + + it("radial: orphans are exempt — they gather at the center, not on the base ring", () => { + const ns = [ + { id: "base", x: 0, y: 0 }, + { id: "mid", x: 10, y: 0 }, + { id: "loner", x: 300, y: 300 }, + ]; + const sim = createGraphSimulation(ns, [{ from: "base", to: "mid" }], { + gravity: { x: 0, y: 0, strength: 0.1 }, + depthAnchor: { mode: "radial", strength: 1.5, spacing: 80, baseRadius: 150, center: { x: 0, y: 0 } }, + }); + for (let i = 0; i < 400; 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(Math.abs(r("base") - 150) < 40, `base sits on the base ring (r=${r("base").toFixed(0)})`); + assert.ok(r("loner") < 60, `the orphan follows gravity to the center, not the ring (r=${r("loner").toFixed(0)})`); + }); });