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
|
// The region IS the dilated skeleton: intra-cluster edges as
|
||||||
// round-cap strokes (Minkowski dilation — points become discs at
|
// round-cap strokes (Minkowski dilation). NO blanket per-node discs
|
||||||
// line ends via the caps). NO per-node discs: a cluster is its
|
// — they double-counted where nodes sit on edges (user-identified
|
||||||
// connected points, and discs double-counted where nodes sit on
|
// hot spots, when clusters were edge-based).
|
||||||
// edges (user-identified hot spots).
|
|
||||||
this.regionMeta = [];
|
this.regionMeta = [];
|
||||||
data.clusters.forEach((cluster, ci) => {
|
data.clusters.forEach((cluster, ci) => {
|
||||||
const hue = clusterHue(cluster.id);
|
const hue = clusterHue(cluster.id);
|
||||||
@@ -1217,6 +1216,8 @@ export class GraphCanvasRenderer {
|
|||||||
let members = 0;
|
let members = 0;
|
||||||
let sumX = 0;
|
let sumX = 0;
|
||||||
let sumY = 0;
|
let sumY = 0;
|
||||||
|
// Endpoints actually covered by a stroke's round caps.
|
||||||
|
const touched = new Set<string>();
|
||||||
for (const e of data.edges) {
|
for (const e of data.edges) {
|
||||||
if (clusterOf.get(e.from) !== ci) continue;
|
if (clusterOf.get(e.from) !== ci) continue;
|
||||||
if (clusterOf.get(e.to) !== ci) continue;
|
if (clusterOf.get(e.to) !== ci) continue;
|
||||||
@@ -1225,9 +1226,29 @@ export class GraphCanvasRenderer {
|
|||||||
if (!a || !b) continue;
|
if (!a || !b) continue;
|
||||||
rctx.moveTo((a.x - minX) / cell, (a.y - minY) / cell);
|
rctx.moveTo((a.x - minX) / cell, (a.y - minY) / cell);
|
||||||
rctx.lineTo((b.x - minX) / cell, (b.y - minY) / cell);
|
rctx.lineTo((b.x - minX) / cell, (b.y - minY) / cell);
|
||||||
|
touched.add(e.from);
|
||||||
|
touched.add(e.to);
|
||||||
}
|
}
|
||||||
rctx.stroke();
|
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).
|
// Label anchor: member centroid (world coords).
|
||||||
for (const id of cluster.members) {
|
for (const id of cluster.members) {
|
||||||
if (!isVisible(id)) continue;
|
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
|
// 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
|
// radial mode a center pull is purely radial and would fight the
|
||||||
// rings, so it is dropped entirely (rings already bound the radius).
|
// 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";
|
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;
|
const g = options.gravity;
|
||||||
if (g && mode !== "radial") {
|
if (g && mode === "off") {
|
||||||
sim.force("x", forceX<LayoutNode>(g.x).strength(g.strength));
|
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
|
// Cluster centroid pull: the membership index is resolved ONCE (nodes
|
||||||
@@ -311,7 +353,11 @@ export function createGraphSimulation(
|
|||||||
const py = n.y ?? 0;
|
const py = n.y ?? 0;
|
||||||
const ux = sumX[ci]! / cnt[ci]! - px;
|
const ux = sumX[ci]! / cnt[ci]! - px;
|
||||||
const uy = sumY[ci]! / cnt[ci]! - py;
|
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;
|
n.vx = (n.vx ?? 0) + ux * strength * alpha;
|
||||||
} else if (mode === "radial") {
|
} else if (mode === "radial") {
|
||||||
const rx = px - ringCx;
|
const rx = px - ringCx;
|
||||||
@@ -342,10 +388,12 @@ export function createGraphSimulation(
|
|||||||
const strength = da.strength;
|
const strength = da.strength;
|
||||||
const spacing = da.spacing;
|
const spacing = da.spacing;
|
||||||
const c = da.center ?? { x: 0, y: 0 };
|
const c = da.center ?? { x: 0, y: 0 };
|
||||||
|
const orphanMask = orphan; // ORPHAN RULE: depth skips unstructured nodes
|
||||||
if (da.mode === "vertical") {
|
if (da.mode === "vertical") {
|
||||||
const baseY = c.y;
|
const baseY = c.y;
|
||||||
sim.force("depth", (alpha: number) => {
|
sim.force("depth", (alpha: number) => {
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
if (orphanMask?.[i]) continue;
|
||||||
const n = nodes[i]!;
|
const n = nodes[i]!;
|
||||||
const targetY = baseY - (depths.get(n.id) ?? 0) * spacing;
|
const targetY = baseY - (depths.get(n.id) ?? 0) * spacing;
|
||||||
n.vy = (n.vy ?? 0) + (targetY - (n.y ?? 0)) * strength * alpha;
|
n.vy = (n.vy ?? 0) + (targetY - (n.y ?? 0)) * strength * alpha;
|
||||||
@@ -357,8 +405,9 @@ export function createGraphSimulation(
|
|||||||
const cy0 = c.y;
|
const cy0 = c.y;
|
||||||
sim.force("depth", (alpha: number) => {
|
sim.force("depth", (alpha: number) => {
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
if (orphanMask?.[i]) continue;
|
||||||
const n = nodes[i]!;
|
const n = nodes[i]!;
|
||||||
const dx = (n.x ?? 0) - cx0;
|
const dx = (n.x ?? 0) - cx0;
|
||||||
const dy = (n.y ?? 0) - cy0;
|
const dy = (n.y ?? 0) - cy0;
|
||||||
// Degenerate direction at the exact ring center: nudge
|
// Degenerate direction at the exact ring center: nudge
|
||||||
// outward on a deterministic axis.
|
// outward on a deterministic axis.
|
||||||
|
|||||||
@@ -270,4 +270,46 @@ describe("depthAnchor force", () => {
|
|||||||
const child = next.get("child")!;
|
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)})`);
|
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