diff --git a/src/webui/data.py b/src/webui/data.py index 0307e4d..97875ae 100644 --- a/src/webui/data.py +++ b/src/webui/data.py @@ -508,18 +508,18 @@ def get_author_network_full(db: Database) -> dict: "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() edges = [] for a, b, shared in pairs: - if shared >= 1: + if shared >= 2: node_set.add(a) node_set.add(b) 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(): - if info["draft_count"] >= 2: + if info["draft_count"] >= 3: node_set.add(name) nodes = [] diff --git a/src/webui/templates/authors.html b/src/webui/templates/authors.html index 1c041c2..6adfef2 100644 --- a/src/webui/templates/authors.html +++ b/src/webui/templates/authors.html @@ -276,8 +276,12 @@ const network = {{ network | tojson }}; c.members.forEach(m => { clusterOf[m] = c.id; }); }); - // Prepare simulation data (deep copy to avoid mutating) - const nodes = network.nodes.map(n => ({...n})); + // Prepare simulation data (deep copy + initial positions to prevent explosion) + 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 => ({ source: e.source, target: e.target, @@ -288,18 +292,23 @@ const network = {{ network | tojson }}; const maxDrafts = d3.max(nodes, n => n.draft_count) || 1; 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) .force('link', d3.forceLink(links) .id(d => d.id) - .distance(d => 80 / Math.sqrt(d.weight)) - .strength(d => 0.3 * d.weight) + .distance(d => 40 + 60 / Math.sqrt(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('collision', d3.forceCollide().radius(d => rScale(d.draft_count) + 3)) - .force('x', d3.forceX(width / 2).strength(0.03)) - .force('y', d3.forceY(height / 2).strength(0.03)); + .force('collision', d3.forceCollide().radius(d => rScale(d.draft_count) + 2).strength(0.7)) + .force('x', d3.forceX(width / 2).strength(0.02)) + .force('y', d3.forceY(height / 2).strength(0.02)) + .alphaDecay(0.02) + .velocityDecay(0.4); // Zoom behavior const g = svg.append('g'); @@ -462,12 +471,13 @@ const network = {{ network | tojson }}; // Reset org dropdown orgSelect.value = ''; + // Highlight matching nodes and dim others node.select('circle') .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') .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) .attr('stroke-opacity', l => { const sid = typeof l.source === 'object' ? l.source.id : l.source; @@ -477,22 +487,28 @@ const network = {{ network | tojson }}; // Highlight cluster card 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 - const clusterNodes = nodes.filter(n => members.has(n.id)); - if (clusterNodes.length > 0) { + // Zoom to fit cluster members (only if simulation has settled enough) + const clusterNodes = nodes.filter(n => + (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 ys = clusterNodes.map(n => n.y); - const x0 = Math.min(...xs) - 50, x1 = Math.max(...xs) + 50; - const y0 = Math.min(...ys) - 50, y1 = Math.max(...ys) + 50; - const scale = Math.min(width / (x1 - x0), height / (y1 - y0), 3); - const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2; - svg.transition().duration(500).call( - zoom.transform, - d3.zoomIdentity.translate(width/2, height/2).scale(scale).translate(-cx, -cy) - ); + const pad = 80; + const x0 = Math.min(...xs) - pad, x1 = Math.max(...xs) + pad; + 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; + svg.transition().duration(750).call( + zoom.transform, + d3.zoomIdentity.translate(width/2, height/2).scale(scale).translate(-cx, -cy) + ); + } } };