Orphan discs in regions + orphan exemption from depth anchoring
Two follow-ups on the semantic-cluster wave, both user-reported: 1. Region coloring: edge-only dilation (the anti-double-counting rule from the Louvain era) left 114 of 338 cluster members completely uncolored — semantic clusters contain members that share no intra-cluster edge with anyone (90 edge-orphans + isolated micro-topics), and 20 of 45 clusters painted nothing at all. Fix: a disc (radius = dilation, same as a round cap) for exactly the members no intra-cluster edge stroke reached. Every member is painted by its edges' caps OR one disc, never both — the no-double-counting rule survives. Verified live: all 74 orphans-in-clusters sit on tinted region pixels. 2. Depth modes (ORPHAN RULE): a node with no edges has no dependency structure, so depth-0 stamping put 90 unconnected propositions on the vertical baseline / radial base ring — meaning they don't have. Orphans are now exempt from the depth force and get the regular view's unprojected forces instead (full center gravity + full cluster pull). Structured-node projections unchanged (x-only gravity in vertical, none in radial; tangential-only cluster pull in radial). Gravity in depth modes is a custom force with EXACTLY d3's forceX/forceY math (strength × alpha — the first draft forgot the alpha and shifted the settle). Verified live: radial orphans follow their topics instead of the base ring; vertical orphans spread 1006 world units (6/90 near the baseline band vs 90/90 before).
This commit is contained in:
@@ -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<string>();
|
||||
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;
|
||||
|
||||
+53
-4
@@ -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<string>();
|
||||
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<LayoutNode>(g.x).strength(g.strength));
|
||||
if (mode !== "vertical") sim.force("y", forceY<LayoutNode>(g.y).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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)})`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user