This commit is contained in:
2026-04-23 17:51:13 +02:00
parent 251aa037d9
commit b6d11706e1
+60 -64
View File
@@ -2020,9 +2020,33 @@ let sim = null;
function nodeR(d) {{ return 5 + Math.min(Math.log1p(d.saliency) * 5, 16); }} function nodeR(d) {{ return 5 + Math.min(Math.log1p(d.saliency) * 5, 16); }}
// ── Arrow markers ───────────────────────────────────────────────────────────── // ── Arrow markers + metaball filter ──────────────────────────────────────────
// BLOB_R: radius of the circle drawn at each node for the metaball effect.
// Larger = blobs reach further and merge sooner.
// BLOB_BLUR: Gaussian blur sigma. Controls how far each blob "spreads".
// BLOB_MULT / BLOB_CUT: alpha-threshold matrix. new_a = MULT*old_a - CUT.
// Threshold fires where old_a > CUT/MULT, i.e. within ≈ blur distance.
const BLOB_R = 44;
const BLOB_BLUR = 16;
const BLOB_MULT = 28;
const BLOB_CUT = 11;
const BLOB_OPACITY = 0.20; // overall transparency of the blob layer
function buildMarkers(dims) {{ function buildMarkers(dims) {{
defs.selectAll('marker').remove(); defs.selectAll('marker').remove();
// Single shared metaball filter — applied per-dim group so colors stay separate.
defs.selectAll('#metaball-filter').remove();
const f = defs.append('filter')
.attr('id', 'metaball-filter')
.attr('x', '-60%').attr('y', '-60%')
.attr('width', '220%').attr('height', '220%')
.attr('color-interpolation-filters', 'sRGB');
f.append('feGaussianBlur')
.attr('in', 'SourceGraphic').attr('stdDeviation', BLOB_BLUR).attr('result', 'blur');
f.append('feColorMatrix')
.attr('in', 'blur').attr('type', 'matrix')
.attr('values', `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${{BLOB_MULT}} -${{BLOB_CUT}}`);
dims.forEach(dim => {{ dims.forEach(dim => {{
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_'); const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
defs.append('marker') defs.append('marker')
@@ -2033,12 +2057,12 @@ function buildMarkers(dims) {{
}}); }});
}} }}
// ── Hull computation ────────────────────────────────────────────────────────── // ── Dim grouping ──────────────────────────────────────────────────────────────
// Group by dimension: each dim bubble encloses all concepts + parents in that dim. // Group nodes by dimension (concept + parent both included).
function computeHulls(nd, ed) {{ function computeDimGroups(nd, ed) {{
const byId = {{}}; const byId = {{}};
nd.forEach(n => byId[n.id] = n); nd.forEach(n => byId[n.id] = n);
const groups = {{}}; // dim → Set of node refs const groups = {{}};
for (const e of ed) {{ for (const e of ed) {{
const src = typeof e.source === 'object' ? e.source : byId[e.source]; const src = typeof e.source === 'object' ? e.source : byId[e.source];
const tgt = typeof e.target === 'object' ? e.target : byId[e.target]; const tgt = typeof e.target === 'object' ? e.target : byId[e.target];
@@ -2047,46 +2071,7 @@ function computeHulls(nd, ed) {{
groups[e.dim].add(src); groups[e.dim].add(src);
groups[e.dim].add(tgt); groups[e.dim].add(tgt);
}} }}
return Object.entries(groups).map(([dim, nodeSet]) => ({{ return Object.entries(groups).map(([dim, nodeSet]) => ({{ dim, nodes: [...nodeSet] }}));
dim, nodes: [...nodeSet]
}}));
}}
// Build a padded convex hull SVG path from a list of node objects.
// Falls back to circle (1 node) or capsule (2 nodes).
const HULL_PAD = 32;
function hullPath(nodes) {{
const pts = nodes.map(n => [n.x, n.y]);
if (pts.length === 0) return '';
if (pts.length === 1) {{
const [x, y] = pts[0]; const r = HULL_PAD;
return `M${{x-r}},${{y}} A${{r}},${{r}} 0 1,0 ${{x+r}},${{y}} A${{r}},${{r}} 0 1,0 ${{x-r}},${{y}}`;
}}
// Centroid
const cx = d3.mean(pts, p => p[0]);
const cy = d3.mean(pts, p => p[1]);
// Expand each point outward from centroid by HULL_PAD
const expanded = pts.map(p => {{
const dx = p[0]-cx, dy = p[1]-cy;
const l = Math.sqrt(dx*dx+dy*dy) || 1;
return [p[0]+dx/l*HULL_PAD, p[1]+dy/l*HULL_PAD];
}});
if (pts.length === 2) {{
// Fallback: hull of the 4 corners of a rectangle around the segment
const [a, b] = expanded;
const nx = -(b[1]-a[1]), ny = b[0]-a[0];
const nl = Math.sqrt(nx*nx+ny*ny) || 1;
const ox = nx/nl*HULL_PAD*0.5, oy = ny/nl*HULL_PAD*0.5;
expanded.push([a[0]+ox,a[1]+oy],[b[0]+ox,b[1]+oy],[b[0]-ox,b[1]-oy],[a[0]-ox,a[1]-oy]);
}}
const hull = d3.polygonHull(expanded);
if (!hull) return '';
return 'M' + hull.map(p => p.join(',')).join('L') + 'Z';
}} }}
// Centroid of a node set // Centroid of a node set
@@ -2187,37 +2172,48 @@ function renderGraph(nodes, edges) {{
const showHulls = document.getElementById('show-hulls').checked; const showHulls = document.getElementById('show-hulls').checked;
const showEdgeLabels = document.getElementById('show-edgelabels').checked; const showEdgeLabels = document.getElementById('show-edgelabels').checked;
// ── Hulls ── // ── Metaball blobs ────────────────────────────────────────────────────────
// Updated on every tick; bootstrap empty so D3 has a selection to update. // One filtered <g> per dimension. Each contains a circle per node.
// The blur+threshold SVG filter merges nearby circles into smooth organic
// blobs. Nodes in multiple dimensions sit inside overlapping blobs — the
// color mixing shows their cross-dimension membership visually.
// Labels are rendered in a separate unfiltered layer so they stay crisp.
function updateHulls() {{ function updateHulls() {{
if (!showHulls) {{ hullLayer.selectAll('*').remove(); return; }} if (!showHulls) {{ hullLayer.selectAll('*').remove(); return; }}
const groups = computeHulls(nd, ed); const groups = computeDimGroups(nd, ed);
// Paths // One <g class="dim-blob"> per dimension, with the metaball filter applied.
hullLayer.selectAll('path.hull') const blobGroups = hullLayer.selectAll('g.dim-blob')
.data(groups, g => g.dim) .data(groups, g => g.dim)
.join('path').attr('class', 'hull') .join('g').attr('class', 'dim-blob')
.attr('d', g => hullPath(g.nodes)) .attr('filter', 'url(#metaball-filter)')
.attr('fill', g => hexWithAlpha(dimColor(g.dim), 0.08)) .attr('opacity', BLOB_OPACITY);
.attr('stroke', g => dimColor(g.dim))
.attr('stroke-width', 1.8)
.attr('stroke-opacity', 0.45)
.attr('stroke-dasharray', '6 4');
// Bubble labels (dimension name at hull centroid top) // Inside each dim group: one circle per node.
hullLayer.selectAll('text.hull-label') blobGroups.each(function(g) {{
d3.select(this).selectAll('circle.bn')
.data(g.nodes, n => n.id)
.join('circle').attr('class', 'bn')
.attr('cx', n => n.x).attr('cy', n => n.y)
.attr('r', BLOB_R)
.attr('fill', dimColor(g.dim));
}});
// Dimension labels — rendered without filter so they stay sharp.
// Positioned at the top of each blob cluster.
hullLayer.selectAll('text.blob-label')
.data(groups, g => g.dim) .data(groups, g => g.dim)
.join('text').attr('class', 'hull-label') .join('text').attr('class', 'blob-label')
.attr('x', g => centroid(g.nodes)[0]) .attr('x', g => centroid(g.nodes)[0])
.attr('y', g => Math.min(...g.nodes.map(n => n.y)) - HULL_PAD - 4) .attr('y', g => Math.min(...g.nodes.map(n => n.y)) - BLOB_R * 0.6)
.attr('text-anchor', 'middle') .attr('text-anchor', 'middle')
.attr('fill', g => dimColor(g.dim)) .attr('fill', g => dimColor(g.dim))
.attr('font-size', '11px') .attr('font-size', '12px')
.attr('font-family', 'monospace') .attr('font-family', 'monospace')
.attr('font-weight', 'bold') .attr('font-weight', 'bold')
.attr('opacity', 0.75) .attr('opacity', 0.8)
.style('pointer-events', 'none') .style('pointer-events', 'none')
.text(g => `[${g.dim}]`); .text(g => g.dim);
}} }}
// ── Links ── // ── Links ──