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:
amosblomqvist
2026-09-13 15:05:05 +03:00
parent f5edc7f0a5
commit 652a25e382
3 changed files with 120 additions and 8 deletions
+25 -4
View File
@@ -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;