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:
@@ -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 = []
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user