diff --git a/plugins/festinger/festinger/main.py b/plugins/festinger/festinger/main.py index a5aeddf..d1caead 100644 --- a/plugins/festinger/festinger/main.py +++ b/plugins/festinger/festinger/main.py @@ -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 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 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 ──