Fix author network graph: tune force sim, reduce node count

- Raise co-authorship threshold from 1 to 2 shared drafts (498→156 nodes)
- Tune D3 force parameters for large graphs: capped link strength,
  wider distance, adaptive charge, lower velocity decay
- Add initial circular layout to prevent explosion on load
- Fix cluster highlighting with fallback name matching and
  position validation before zoom-to-fit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 21:04:18 +01:00
parent 757b781c67
commit 3e36802500
2 changed files with 43 additions and 27 deletions

View File

@@ -508,18 +508,18 @@ def get_author_network_full(db: Database) -> dict:
"org": aff, "draft_count": cnt, "drafts": drafts, "avg_score": avg "org": aff, "draft_count": cnt, "drafts": drafts, "avg_score": avg
} }
# Build node set: authors with 2+ drafts OR 1+ co-authorship # Build node set: authors with meaningful collaboration (2+ shared drafts)
node_set = set() node_set = set()
edges = [] edges = []
for a, b, shared in pairs: for a, b, shared in pairs:
if shared >= 1: if shared >= 2:
node_set.add(a) node_set.add(a)
node_set.add(b) node_set.add(b)
edges.append({"source": a, "target": b, "weight": shared}) edges.append({"source": a, "target": b, "weight": shared})
# Also include authors with 2+ drafts even if no co-authorships # Also include authors with 3+ drafts even if no co-authorships
for name, info in author_info.items(): for name, info in author_info.items():
if info["draft_count"] >= 2: if info["draft_count"] >= 3:
node_set.add(name) node_set.add(name)
nodes = [] nodes = []

View File

@@ -276,8 +276,12 @@ const network = {{ network | tojson }};
c.members.forEach(m => { clusterOf[m] = c.id; }); c.members.forEach(m => { clusterOf[m] = c.id; });
}); });
// Prepare simulation data (deep copy to avoid mutating) // Prepare simulation data (deep copy + initial positions to prevent explosion)
const nodes = network.nodes.map(n => ({...n})); const nodes = network.nodes.map((n, i) => ({
...n,
x: width / 2 + (Math.cos(i * 2 * Math.PI / network.nodes.length) * Math.min(width, height) * 0.35),
y: height / 2 + (Math.sin(i * 2 * Math.PI / network.nodes.length) * Math.min(width, height) * 0.35),
}));
const links = network.edges.map(e => ({ const links = network.edges.map(e => ({
source: e.source, source: e.source,
target: e.target, target: e.target,
@@ -288,18 +292,23 @@ const network = {{ network | tojson }};
const maxDrafts = d3.max(nodes, n => n.draft_count) || 1; const maxDrafts = d3.max(nodes, n => n.draft_count) || 1;
const rScale = d3.scaleSqrt().domain([1, maxDrafts]).range([4, 22]); const rScale = d3.scaleSqrt().domain([1, maxDrafts]).range([4, 22]);
// Force simulation // Force simulation — tuned for large graphs (498 nodes, 1142 edges)
const nNodes = nodes.length;
const chargeStrength = nNodes > 300 ? -60 : nNodes > 100 ? -100 : -120;
const simulation = d3.forceSimulation(nodes) const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links) .force('link', d3.forceLink(links)
.id(d => d.id) .id(d => d.id)
.distance(d => 80 / Math.sqrt(d.weight)) .distance(d => 40 + 60 / Math.sqrt(d.weight))
.strength(d => 0.3 * d.weight) .strength(d => Math.min(0.15, 0.05 * d.weight))
) )
.force('charge', d3.forceManyBody().strength(-120).distanceMax(300)) .force('charge', d3.forceManyBody().strength(chargeStrength).distanceMax(400))
.force('center', d3.forceCenter(width / 2, height / 2)) .force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(d => rScale(d.draft_count) + 3)) .force('collision', d3.forceCollide().radius(d => rScale(d.draft_count) + 2).strength(0.7))
.force('x', d3.forceX(width / 2).strength(0.03)) .force('x', d3.forceX(width / 2).strength(0.02))
.force('y', d3.forceY(height / 2).strength(0.03)); .force('y', d3.forceY(height / 2).strength(0.02))
.alphaDecay(0.02)
.velocityDecay(0.4);
// Zoom behavior // Zoom behavior
const g = svg.append('g'); const g = svg.append('g');
@@ -462,12 +471,13 @@ const network = {{ network | tojson }};
// Reset org dropdown // Reset org dropdown
orgSelect.value = ''; orgSelect.value = '';
// Highlight matching nodes and dim others
node.select('circle') node.select('circle')
.transition().duration(300) .transition().duration(300)
.attr('opacity', n => members.has(n.id) ? 1 : 0.08); .attr('opacity', n => members.has(n.id) || members.has(n.name) ? 1 : 0.08);
node.selectAll('text') node.selectAll('text')
.transition().duration(300) .transition().duration(300)
.attr('opacity', n => members.has(n.id) ? 1 : 0.08); .attr('opacity', n => members.has(n.id) || members.has(n.name) ? 1 : 0.08);
link.transition().duration(300) link.transition().duration(300)
.attr('stroke-opacity', l => { .attr('stroke-opacity', l => {
const sid = typeof l.source === 'object' ? l.source.id : l.source; const sid = typeof l.source === 'object' ? l.source.id : l.source;
@@ -477,23 +487,29 @@ const network = {{ network | tojson }};
// Highlight cluster card // Highlight cluster card
document.querySelectorAll('.cluster-card').forEach(c => { document.querySelectorAll('.cluster-card').forEach(c => {
c.classList.toggle('border-blue-500', c.dataset.clusterId == clusterId); const isActive = c.dataset.clusterId == clusterId;
c.style.borderColor = isActive ? '#3b82f6' : '';
}); });
// Zoom to fit cluster members // Zoom to fit cluster members (only if simulation has settled enough)
const clusterNodes = nodes.filter(n => members.has(n.id)); const clusterNodes = nodes.filter(n =>
if (clusterNodes.length > 0) { (members.has(n.id) || members.has(n.name)) && isFinite(n.x) && isFinite(n.y));
if (clusterNodes.length > 1) {
const xs = clusterNodes.map(n => n.x); const xs = clusterNodes.map(n => n.x);
const ys = clusterNodes.map(n => n.y); const ys = clusterNodes.map(n => n.y);
const x0 = Math.min(...xs) - 50, x1 = Math.max(...xs) + 50; const pad = 80;
const y0 = Math.min(...ys) - 50, y1 = Math.max(...ys) + 50; const x0 = Math.min(...xs) - pad, x1 = Math.max(...xs) + pad;
const scale = Math.min(width / (x1 - x0), height / (y1 - y0), 3); const y0 = Math.min(...ys) - pad, y1 = Math.max(...ys) + pad;
const dx = x1 - x0, dy = y1 - y0;
if (dx > 0 && dy > 0) {
const scale = Math.min(width / dx, height / dy, 3);
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2; const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
svg.transition().duration(500).call( svg.transition().duration(750).call(
zoom.transform, zoom.transform,
d3.zoomIdentity.translate(width/2, height/2).scale(scale).translate(-cx, -cy) d3.zoomIdentity.translate(width/2, height/2).scale(scale).translate(-cx, -cy)
); );
} }
}
}; };
// Filter by org (called from org stats cards) // Filter by org (called from org stats cards)