metaball
This commit is contained in:
@@ -2020,9 +2020,33 @@ let sim = null;
|
||||
|
||||
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) {{
|
||||
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 => {{
|
||||
const safe = dim.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
defs.append('marker')
|
||||
@@ -2033,12 +2057,12 @@ function buildMarkers(dims) {{
|
||||
}});
|
||||
}}
|
||||
|
||||
// ── Hull computation ──────────────────────────────────────────────────────────
|
||||
// Group by dimension: each dim bubble encloses all concepts + parents in that dim.
|
||||
function computeHulls(nd, ed) {{
|
||||
// ── Dim grouping ──────────────────────────────────────────────────────────────
|
||||
// Group nodes by dimension (concept + parent both included).
|
||||
function computeDimGroups(nd, ed) {{
|
||||
const byId = {{}};
|
||||
nd.forEach(n => byId[n.id] = n);
|
||||
const groups = {{}}; // dim → Set of node refs
|
||||
const groups = {{}};
|
||||
for (const e of ed) {{
|
||||
const src = typeof e.source === 'object' ? e.source : byId[e.source];
|
||||
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(tgt);
|
||||
}}
|
||||
return Object.entries(groups).map(([dim, 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';
|
||||
return Object.entries(groups).map(([dim, nodeSet]) => ({{ dim, nodes: [...nodeSet] }}));
|
||||
}}
|
||||
|
||||
// Centroid of a node set
|
||||
@@ -2187,37 +2172,48 @@ function renderGraph(nodes, edges) {{
|
||||
const showHulls = document.getElementById('show-hulls').checked;
|
||||
const showEdgeLabels = document.getElementById('show-edgelabels').checked;
|
||||
|
||||
// ── Hulls ──
|
||||
// Updated on every tick; bootstrap empty so D3 has a selection to update.
|
||||
// ── Metaball blobs ────────────────────────────────────────────────────────
|
||||
// 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() {{
|
||||
if (!showHulls) {{ hullLayer.selectAll('*').remove(); return; }}
|
||||
const groups = computeHulls(nd, ed);
|
||||
const groups = computeDimGroups(nd, ed);
|
||||
|
||||
// Paths
|
||||
hullLayer.selectAll('path.hull')
|
||||
// One <g class="dim-blob"> per dimension, with the metaball filter applied.
|
||||
const blobGroups = hullLayer.selectAll('g.dim-blob')
|
||||
.data(groups, g => g.dim)
|
||||
.join('path').attr('class', 'hull')
|
||||
.attr('d', g => hullPath(g.nodes))
|
||||
.attr('fill', g => hexWithAlpha(dimColor(g.dim), 0.08))
|
||||
.attr('stroke', g => dimColor(g.dim))
|
||||
.attr('stroke-width', 1.8)
|
||||
.attr('stroke-opacity', 0.45)
|
||||
.attr('stroke-dasharray', '6 4');
|
||||
.join('g').attr('class', 'dim-blob')
|
||||
.attr('filter', 'url(#metaball-filter)')
|
||||
.attr('opacity', BLOB_OPACITY);
|
||||
|
||||
// Bubble labels (dimension name at hull centroid top)
|
||||
hullLayer.selectAll('text.hull-label')
|
||||
// Inside each dim group: one circle per node.
|
||||
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)
|
||||
.join('text').attr('class', 'hull-label')
|
||||
.join('text').attr('class', 'blob-label')
|
||||
.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('fill', g => dimColor(g.dim))
|
||||
.attr('font-size', '11px')
|
||||
.attr('font-size', '12px')
|
||||
.attr('font-family', 'monospace')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('opacity', 0.75)
|
||||
.attr('opacity', 0.8)
|
||||
.style('pointer-events', 'none')
|
||||
.text(g => `[${g.dim}]`);
|
||||
.text(g => g.dim);
|
||||
}}
|
||||
|
||||
// ── Links ──
|
||||
|
||||
Reference in New Issue
Block a user