- Add `ietf auto` command: fetches, analyzes, embeds, extracts ideas, and refreshes gaps across all sources with cost-based auto-approval - Fix SourceDocument→Draft conversion in auto fetch step - Fix gap_analysis method name in auto command - Process all 270 unrated ETSI/ISO/ITU/NIST drafts (761 total, all rated) - Update web UI templates and data layer for multi-source support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
683 lines
32 KiB
HTML
683 lines
32 KiB
HTML
{% extends "base.html" %}
|
|
{% set active_page = "authors" %}
|
|
|
|
{% block title %}Author Network — IETF Draft Analyzer{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<script src="/static/js/plotly.min.js"></script>
|
|
<script src="/static/js/d3.v7.min.js"></script>
|
|
<style>
|
|
#networkSvg {
|
|
width: 100%;
|
|
height: 600px;
|
|
cursor: grab;
|
|
}
|
|
#networkSvg:active { cursor: grabbing; }
|
|
#networkSvg .node { cursor: pointer; }
|
|
#networkSvg .node circle { stroke: rgba(255,255,255,0.15); stroke-width: 1.5px; transition: r 0.2s; }
|
|
#networkSvg .node:hover circle { stroke: #60a5fa; stroke-width: 2.5px; }
|
|
#networkSvg .node text { pointer-events: none; }
|
|
#networkSvg .link { stroke-opacity: 0.25; }
|
|
#networkSvg .link:hover { stroke-opacity: 0.7; }
|
|
.tooltip-card {
|
|
position: absolute; pointer-events: none; z-index: 50;
|
|
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
|
|
padding: 10px 14px; font-size: 12px; color: #e2e8f0;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
max-width: 280px; opacity: 0; transition: opacity 0.15s;
|
|
}
|
|
.tooltip-card.visible { opacity: 1; }
|
|
.legend-swatch { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
|
.cluster-card { transition: all 0.2s; }
|
|
.cluster-card:hover { border-color: #3b82f6 !important; }
|
|
.filter-btn { transition: all 0.15s; }
|
|
.filter-btn:hover { background: rgba(59, 130, 246, 0.2); }
|
|
.filter-btn.active { background: rgba(59, 130, 246, 0.3); border-color: #3b82f6; color: #60a5fa; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mb-6">
|
|
<h1 class="text-2xl font-bold text-white">Author Network</h1>
|
|
<p class="text-slate-400 text-sm mt-1">Interactive collaboration graph of {{ network.nodes | length }} authors across {{ orgs | length }} organizations. Authors are connected when they co-authored 2+ drafts together. Node size reflects number of drafts authored; color represents organization. Clusters are detected via connected-component analysis (BFS) — authors in the same cluster share direct or indirect co-authorship links.</p>
|
|
</div>
|
|
|
|
<!-- Summary stats -->
|
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
|
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-blue-400"></div>
|
|
<div class="text-xs text-slate-500 uppercase tracking-wider">Authors Shown</div>
|
|
<div class="text-2xl font-bold text-white mt-1">{{ network.nodes | length }}</div>
|
|
</div>
|
|
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-purple-500 to-purple-400"></div>
|
|
<div class="text-xs text-slate-500 uppercase tracking-wider">Organizations</div>
|
|
<div class="text-2xl font-bold text-white mt-1">{{ orgs | length }}</div>
|
|
</div>
|
|
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-emerald-500 to-emerald-400"></div>
|
|
<div class="text-xs text-slate-500 uppercase tracking-wider">Co-Author Links</div>
|
|
<div class="text-2xl font-bold text-white mt-1">{{ network.edges | length }}</div>
|
|
</div>
|
|
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-amber-500 to-amber-400"></div>
|
|
<div class="text-xs text-slate-500 uppercase tracking-wider">Clusters</div>
|
|
<div class="text-2xl font-bold text-white mt-1">{{ network.clusters | length }}</div>
|
|
</div>
|
|
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-rose-500 to-rose-400"></div>
|
|
<div class="text-xs text-slate-500 uppercase tracking-wider">Multi-Draft</div>
|
|
<div class="text-2xl font-bold text-white mt-1">{{ authors | selectattr('draft_count', 'gt', 1) | list | length }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- D3 Force-directed Network Graph -->
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6 relative">
|
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
|
|
<div>
|
|
<h2 class="text-sm font-semibold text-slate-300">Co-Authorship Network</h2>
|
|
<p class="text-xs text-slate-500 mt-0.5">Node size = draft count. Color = organization. Edge thickness = shared drafts. Drag nodes to rearrange. Scroll to zoom.</p>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
<button id="resetZoom" class="text-xs px-3 py-1.5 rounded-lg border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 transition">Reset View</button>
|
|
<select id="highlightOrg" class="text-xs px-3 py-1.5 rounded-lg border border-slate-700 bg-slate-800 text-slate-300 focus:outline-none focus:border-blue-500">
|
|
<option value="">All Organizations</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="relative">
|
|
<svg id="networkSvg"></svg>
|
|
<div id="tooltip" class="tooltip-card"></div>
|
|
</div>
|
|
<!-- Legend -->
|
|
<div id="legend" class="flex flex-wrap gap-3 mt-3 pt-3 border-t border-slate-800"></div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
<!-- Organization bar chart -->
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
|
<h2 class="text-sm font-semibold text-slate-300 mb-1">Organizations by Draft Count</h2>
|
|
<p class="text-xs text-slate-500 mb-3">Color intensity = number of authors from that org.</p>
|
|
<div id="orgChart" style="height: 500px;"></div>
|
|
</div>
|
|
|
|
<!-- Cross-org collaboration -->
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
|
<h2 class="text-sm font-semibold text-slate-300 mb-1">Cross-Organization Collaboration</h2>
|
|
<p class="text-xs text-slate-500 mb-3">Organizations co-authoring drafts together.</p>
|
|
<div id="crossOrgChart" style="height: 500px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Collaboration Clusters -->
|
|
{% if network.clusters %}
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6">
|
|
<h2 class="text-sm font-semibold text-slate-300 mb-1">Collaboration Clusters</h2>
|
|
<p class="text-xs text-slate-500 mb-4">Clusters are formed by connected-component analysis of the co-authorship graph: authors who share 2+ drafts are linked, and all authors reachable through such links form a cluster. This reveals research teams and institutional collaboration patterns — a cluster of 20 authors from 3 organizations means those groups actively co-author across org boundaries. Click a cluster to highlight it in the graph above.</p>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="clusterGrid">
|
|
{% for c in network.clusters[:12] %}
|
|
<div class="cluster-card bg-slate-800/50 rounded-lg border border-slate-700/50 p-4 cursor-pointer" data-cluster-id="{{ c.id }}">
|
|
<!-- Header — click to highlight in graph -->
|
|
<div class="flex items-center justify-between mb-2" onclick="highlightCluster({{ c.id }})">
|
|
<span class="text-sm font-semibold text-white">Cluster #{{ c.id + 1 }}</span>
|
|
<div class="flex gap-1.5 items-center">
|
|
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400">{{ c.size }} authors</span>
|
|
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400">{{ c.draft_count }} drafts</span>
|
|
<svg class="w-4 h-4 text-slate-500 transition-transform cluster-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Org mix -->
|
|
<div class="flex flex-wrap gap-1 mb-2">
|
|
{% for org, count in c.org_mix.items() %}
|
|
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-300">{{ org }} ({{ count }})</span>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Preview: first 3 members -->
|
|
<div class="text-xs text-slate-500 mb-2 truncate" title="{{ c.members | join(', ') }}">
|
|
{{ c.members[:3] | join(', ') }}{% if c.members | length > 3 %} +{{ c.members | length - 3 }} more{% endif %}
|
|
</div>
|
|
|
|
<!-- Preview: first 3 drafts -->
|
|
{% if c.drafts %}
|
|
<div class="border-t border-slate-700/50 pt-2 mt-2">
|
|
{% for d in c.drafts[:3] %}
|
|
<div class="text-xs truncate mb-0.5" title="{{ d.name }}: {{ d.title }}">
|
|
<a href="/drafts/{{ d.name }}" class="text-blue-400/70 hover:text-blue-300 transition" onclick="event.stopPropagation()">{{ d.title }}</a>
|
|
</div>
|
|
{% endfor %}
|
|
{% if c.draft_count > 3 %}
|
|
<div class="text-xs text-slate-600 mt-1">+{{ c.draft_count - 3 }} more drafts</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Expanded detail (hidden by default) -->
|
|
<div class="cluster-detail hidden mt-3 border-t border-slate-700/50 pt-3" id="authorCluster-{{ c.id }}">
|
|
<!-- All members with org -->
|
|
<h4 class="text-xs font-semibold text-slate-300 mb-2 uppercase tracking-wide">All {{ c.size }} Authors</h4>
|
|
<div class="max-h-48 overflow-y-auto mb-3 space-y-1">
|
|
{% for member in c.members %}
|
|
<div class="text-xs flex items-center justify-between gap-2">
|
|
<a href="/drafts?q={{ member | urlencode }}" class="text-slate-300 hover:text-blue-400 transition truncate" onclick="event.stopPropagation()">{{ member }}</a>
|
|
{% set member_org = c.member_orgs[member] if c.member_orgs is defined and member in c.member_orgs else '' %}
|
|
{% if member_org %}
|
|
<span class="text-slate-600 text-[10px] truncate max-w-[120px] flex-shrink-0">{{ member_org }}</span>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- All drafts -->
|
|
{% if c.drafts %}
|
|
<h4 class="text-xs font-semibold text-slate-300 mb-2 uppercase tracking-wide">All {{ c.draft_count }} Drafts</h4>
|
|
<div class="max-h-48 overflow-y-auto space-y-1.5">
|
|
{% for d in c.drafts %}
|
|
<div class="text-xs" title="{{ d.name }}">
|
|
<a href="/drafts/{{ d.name }}" class="text-blue-400/70 hover:text-blue-300 transition" onclick="event.stopPropagation()">{{ d.title }}</a>
|
|
<span class="text-slate-600 font-mono text-[10px] ml-1">{{ d.name | replace('draft-', '') | truncate(25) }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Top Authors Table and Org Stats side by side -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
|
<!-- Top authors table -->
|
|
<div class="lg:col-span-2 bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
|
<div class="p-4 border-b border-slate-800 flex items-center justify-between">
|
|
<h2 class="text-sm font-semibold text-slate-300">Top Authors</h2>
|
|
<span class="text-xs text-slate-500">Showing top {{ authors | length }}</span>
|
|
</div>
|
|
<div class="overflow-x-auto max-h-[600px] overflow-y-auto">
|
|
<table class="w-full text-sm" id="authorsTable">
|
|
<thead class="sticky top-0 z-10">
|
|
<tr class="border-b border-slate-800 bg-slate-900">
|
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400 cursor-pointer hover:text-slate-200" data-sort="index">#</th>
|
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400 cursor-pointer hover:text-slate-200" data-sort="name">Author</th>
|
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400 cursor-pointer hover:text-slate-200" data-sort="org">Organization</th>
|
|
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400 cursor-pointer hover:text-slate-200" data-sort="drafts">Drafts</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800/50" id="authorsBody">
|
|
{% for a in authors %}
|
|
<tr class="hover:bg-slate-800/50 transition author-row" data-name="{{ a.name }}" data-org="{{ a.affiliation }}" data-count="{{ a.draft_count }}">
|
|
<td class="px-4 py-2.5 text-slate-500 text-xs">{{ loop.index }}</td>
|
|
<td class="px-4 py-2.5">
|
|
<a href="/drafts?q={{ a.name | urlencode }}" class="text-blue-400 hover:text-blue-300 transition">{{ a.name }}</a>
|
|
</td>
|
|
<td class="px-4 py-2.5 text-slate-500 text-xs truncate max-w-[200px]" title="{{ a.affiliation }}">{{ a.affiliation }}</td>
|
|
<td class="px-4 py-2.5 text-right">
|
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium
|
|
{% if a.draft_count >= 5 %}bg-green-500/20 text-green-400
|
|
{% elif a.draft_count >= 3 %}bg-blue-500/20 text-blue-400
|
|
{% else %}bg-slate-700/50 text-slate-400{% endif %}">
|
|
{{ a.draft_count }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Organization stats cards -->
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
|
<div class="p-4 border-b border-slate-800">
|
|
<h2 class="text-sm font-semibold text-slate-300">Organization Stats</h2>
|
|
</div>
|
|
<div class="max-h-[600px] overflow-y-auto divide-y divide-slate-800/50">
|
|
{% for o in orgs %}
|
|
<div class="px-4 py-3 hover:bg-slate-800/30 transition cursor-pointer org-card" data-org="{{ o.org }}" onclick="filterByOrg('{{ o.org | e }}')">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-sm text-slate-200 font-medium truncate max-w-[180px]" title="{{ o.org }}">{{ o.org }}</span>
|
|
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400">{{ o.draft_count }} drafts</span>
|
|
</div>
|
|
<div class="flex items-center gap-3 text-xs text-slate-500">
|
|
<span>{{ o.author_count }} author{{ 's' if o.author_count != 1 }}</span>
|
|
<span class="text-slate-700">|</span>
|
|
<span>{{ (o.draft_count / o.author_count) | round(1) }} drafts/author</span>
|
|
</div>
|
|
<div class="mt-1.5 w-full bg-slate-800 rounded-full h-1.5">
|
|
<div class="bg-blue-500/60 h-1.5 rounded-full" style="width: {{ (o.draft_count / orgs[0].draft_count * 100) | round }}%"></div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
// --- Shared Plotly config ---
|
|
const PLOTLY_LAYOUT = {
|
|
paper_bgcolor: 'transparent',
|
|
plot_bgcolor: 'transparent',
|
|
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 12 },
|
|
margin: { t: 20, r: 20, b: 40, l: 50 },
|
|
xaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
|
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
|
};
|
|
const CFG = { responsive: true, displayModeBar: false };
|
|
|
|
const PALETTE = [
|
|
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
|
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
|
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
|
|
'#fb923c', '#34d399', '#c084fc', '#38bdf8', '#fbbf24',
|
|
];
|
|
|
|
// --- Data from server ---
|
|
const network = {{ network | tojson }};
|
|
|
|
// ===========================================================
|
|
// D3.js Force-Directed Co-Authorship Network
|
|
// ===========================================================
|
|
(function() {
|
|
if (network.nodes.length === 0) {
|
|
document.getElementById('networkSvg').outerHTML =
|
|
'<p class="text-slate-500 text-sm text-center py-20">No co-authorship data available</p>';
|
|
return;
|
|
}
|
|
|
|
const svg = d3.select('#networkSvg');
|
|
const container = svg.node().parentElement;
|
|
const width = container.clientWidth;
|
|
const height = 600;
|
|
svg.attr('viewBox', [0, 0, width, height]);
|
|
|
|
// Build org color map (top orgs by frequency)
|
|
const orgCounts = {};
|
|
network.nodes.forEach(n => {
|
|
if (n.org) orgCounts[n.org] = (orgCounts[n.org] || 0) + 1;
|
|
});
|
|
const orgsSorted = Object.entries(orgCounts).sort((a,b) => b[1] - a[1]);
|
|
const orgColor = {};
|
|
orgsSorted.forEach(([org], i) => {
|
|
orgColor[org] = i < PALETTE.length ? PALETTE[i] : '#475569';
|
|
});
|
|
|
|
// Populate org dropdown
|
|
const orgSelect = document.getElementById('highlightOrg');
|
|
orgsSorted.slice(0, 30).forEach(([org, cnt]) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = org;
|
|
opt.textContent = `${org} (${cnt})`;
|
|
orgSelect.appendChild(opt);
|
|
});
|
|
|
|
// Populate legend
|
|
const legendEl = document.getElementById('legend');
|
|
orgsSorted.slice(0, 12).forEach(([org]) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'flex items-center gap-1.5 text-xs text-slate-400';
|
|
item.innerHTML = `<span class="legend-swatch" style="background:${orgColor[org]}"></span>${org}`;
|
|
legendEl.appendChild(item);
|
|
});
|
|
|
|
// Build cluster lookup
|
|
const clusterOf = {};
|
|
(network.clusters || []).forEach(c => {
|
|
c.members.forEach(m => { clusterOf[m] = c.id; });
|
|
});
|
|
|
|
// 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,
|
|
weight: e.weight,
|
|
}));
|
|
|
|
// Size scale
|
|
const maxDrafts = d3.max(nodes, n => n.draft_count) || 1;
|
|
const rScale = d3.scaleSqrt().domain([1, maxDrafts]).range([4, 22]);
|
|
|
|
// 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 => 40 + 60 / Math.sqrt(d.weight))
|
|
.strength(d => Math.min(0.15, 0.05 * d.weight))
|
|
)
|
|
.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) + 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');
|
|
const zoom = d3.zoom()
|
|
.scaleExtent([0.2, 5])
|
|
.on('zoom', (event) => g.attr('transform', event.transform));
|
|
svg.call(zoom);
|
|
|
|
document.getElementById('resetZoom').addEventListener('click', () => {
|
|
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);
|
|
});
|
|
|
|
// Draw edges
|
|
const linkGroup = g.append('g').attr('class', 'links');
|
|
const link = linkGroup.selectAll('line')
|
|
.data(links)
|
|
.join('line')
|
|
.attr('class', 'link')
|
|
.attr('stroke', '#475569')
|
|
.attr('stroke-width', d => Math.max(1, d.weight * 1.5));
|
|
|
|
// Draw nodes
|
|
const nodeGroup = g.append('g').attr('class', 'nodes');
|
|
const node = nodeGroup.selectAll('g')
|
|
.data(nodes)
|
|
.join('g')
|
|
.attr('class', 'node')
|
|
.call(d3.drag()
|
|
.on('start', dragStarted)
|
|
.on('drag', dragged)
|
|
.on('end', dragEnded)
|
|
);
|
|
|
|
node.append('circle')
|
|
.attr('r', d => rScale(d.draft_count))
|
|
.attr('fill', d => orgColor[d.org] || '#475569')
|
|
.attr('opacity', 0.85);
|
|
|
|
// Labels for nodes with 3+ drafts
|
|
node.filter(d => d.draft_count >= 3)
|
|
.append('text')
|
|
.text(d => {
|
|
const parts = d.name.split(' ');
|
|
return parts[parts.length - 1];
|
|
})
|
|
.attr('dy', d => -(rScale(d.draft_count) + 4))
|
|
.attr('text-anchor', 'middle')
|
|
.attr('fill', '#94a3b8')
|
|
.attr('font-size', '9px')
|
|
.attr('font-family', 'Inter, system-ui, sans-serif');
|
|
|
|
// Tooltip
|
|
const tooltip = document.getElementById('tooltip');
|
|
|
|
node.on('mouseover', function(event, d) {
|
|
const draftList = (d.drafts || []).slice(0, 5).map(dn => {
|
|
const short = dn.replace(/^draft-/, '');
|
|
return `<div class="truncate text-slate-400">${short}</div>`;
|
|
}).join('');
|
|
const moreCount = (d.drafts || []).length > 5 ? `<div class="text-slate-500 mt-1">+${d.drafts.length - 5} more</div>` : '';
|
|
|
|
tooltip.innerHTML = `
|
|
<div class="font-semibold text-white mb-1">${d.name}</div>
|
|
<div class="text-slate-400 text-xs mb-2">${d.org || 'Unknown org'}</div>
|
|
<div class="flex gap-4 text-xs mb-2">
|
|
<span><span class="text-blue-400 font-medium">${d.draft_count}</span> drafts</span>
|
|
<span>avg <span class="text-emerald-400 font-medium">${d.avg_score}</span></span>
|
|
</div>
|
|
<div class="text-xs">${draftList}${moreCount}</div>
|
|
`;
|
|
tooltip.classList.add('visible');
|
|
|
|
// Highlight connected
|
|
const connected = new Set();
|
|
links.forEach(l => {
|
|
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
if (sid === d.id) connected.add(tid);
|
|
if (tid === d.id) connected.add(sid);
|
|
});
|
|
connected.add(d.id);
|
|
|
|
node.select('circle')
|
|
.attr('opacity', n => connected.has(n.id) ? 1 : 0.15);
|
|
node.selectAll('text')
|
|
.attr('opacity', n => connected.has(n.id) ? 1 : 0.15);
|
|
link
|
|
.attr('stroke-opacity', l => {
|
|
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
return (sid === d.id || tid === d.id) ? 0.7 : 0.03;
|
|
});
|
|
})
|
|
.on('mousemove', function(event) {
|
|
const rect = container.getBoundingClientRect();
|
|
tooltip.style.left = (event.clientX - rect.left + 15) + 'px';
|
|
tooltip.style.top = (event.clientY - rect.top - 10) + 'px';
|
|
})
|
|
.on('mouseout', function() {
|
|
tooltip.classList.remove('visible');
|
|
node.select('circle').attr('opacity', 0.85);
|
|
node.selectAll('text').attr('opacity', 1);
|
|
link.attr('stroke-opacity', 0.25);
|
|
})
|
|
.on('click', function(event, d) {
|
|
// Navigate to drafts search for this author
|
|
window.open(`/drafts?q=${encodeURIComponent(d.name)}`, '_blank');
|
|
});
|
|
|
|
// Tick handler
|
|
simulation.on('tick', () => {
|
|
link
|
|
.attr('x1', d => d.source.x)
|
|
.attr('y1', d => d.source.y)
|
|
.attr('x2', d => d.target.x)
|
|
.attr('y2', d => d.target.y);
|
|
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
});
|
|
|
|
// Drag handlers
|
|
function dragStarted(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
d.fx = d.x; d.fy = d.y;
|
|
}
|
|
function dragged(event, d) {
|
|
d.fx = event.x; d.fy = event.y;
|
|
}
|
|
function dragEnded(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
d.fx = null; d.fy = null;
|
|
}
|
|
|
|
// Org filter dropdown
|
|
orgSelect.addEventListener('change', function() {
|
|
const org = this.value;
|
|
if (!org) {
|
|
node.select('circle').attr('opacity', 0.85);
|
|
node.selectAll('text').attr('opacity', 1);
|
|
link.attr('stroke-opacity', 0.25);
|
|
return;
|
|
}
|
|
const inOrg = new Set(nodes.filter(n => n.org === org).map(n => n.id));
|
|
node.select('circle')
|
|
.attr('opacity', n => inOrg.has(n.id) ? 1 : 0.08);
|
|
node.selectAll('text')
|
|
.attr('opacity', n => inOrg.has(n.id) ? 1 : 0.08);
|
|
link.attr('stroke-opacity', l => {
|
|
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
return (inOrg.has(sid) && inOrg.has(tid)) ? 0.6 : 0.02;
|
|
});
|
|
});
|
|
|
|
// Toggle expand/collapse on cluster card chevron click
|
|
document.querySelectorAll('.cluster-card').forEach(card => {
|
|
card.addEventListener('click', function(e) {
|
|
// Don't toggle if clicking a link or the highlight header
|
|
if (e.target.closest('a') || e.target.closest('[onclick*="highlightCluster"]')) return;
|
|
const detail = card.querySelector('.cluster-detail');
|
|
const chevron = card.querySelector('.cluster-chevron');
|
|
if (detail) {
|
|
detail.classList.toggle('hidden');
|
|
chevron.style.transform = detail.classList.contains('hidden') ? '' : 'rotate(180deg)';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Expose cluster highlighting globally
|
|
window.highlightCluster = function(clusterId) {
|
|
const cluster = (network.clusters || []).find(c => c.id === clusterId);
|
|
if (!cluster) return;
|
|
const members = new Set(cluster.members);
|
|
|
|
// Reset org dropdown
|
|
orgSelect.value = '';
|
|
|
|
// Highlight matching nodes and dim others
|
|
node.select('circle')
|
|
.transition().duration(300)
|
|
.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) || 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;
|
|
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
return (members.has(sid) && members.has(tid)) ? 0.7 : 0.02;
|
|
});
|
|
|
|
// Highlight cluster card
|
|
document.querySelectorAll('.cluster-card').forEach(c => {
|
|
const isActive = c.dataset.clusterId == clusterId;
|
|
c.style.borderColor = isActive ? '#3b82f6' : '';
|
|
});
|
|
|
|
// 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 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)
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Filter by org (called from org stats cards)
|
|
window.filterByOrg = function(org) {
|
|
orgSelect.value = org;
|
|
orgSelect.dispatchEvent(new Event('change'));
|
|
};
|
|
})();
|
|
|
|
// ===========================================================
|
|
// Organization Bar Chart (Plotly)
|
|
// ===========================================================
|
|
const orgsData = {{ orgs_data | tojson }};
|
|
const orgNames = orgsData.map(o => o.org).reverse();
|
|
const orgDrafts = orgsData.map(o => o.draft_count).reverse();
|
|
const orgAuthors = orgsData.map(o => o.author_count).reverse();
|
|
|
|
Plotly.newPlot('orgChart', [{
|
|
y: orgNames, x: orgDrafts,
|
|
type: 'bar', orientation: 'h',
|
|
marker: {
|
|
color: orgAuthors,
|
|
colorscale: [[0, '#1e3a5f'], [0.5, '#3b82f6'], [1, '#60a5fa']],
|
|
showscale: true,
|
|
colorbar: {
|
|
title: { text: 'Authors', font: { color: '#94a3b8', size: 10 } },
|
|
tickfont: { color: '#94a3b8', size: 10 },
|
|
thickness: 12, len: 0.5,
|
|
},
|
|
},
|
|
text: orgDrafts.map((d, i) => `${d} drafts, ${orgAuthors[i]} authors`),
|
|
textposition: 'none',
|
|
hovertemplate: '<b>%{y}</b><br>%{text}<extra></extra>',
|
|
}], {
|
|
...PLOTLY_LAYOUT,
|
|
margin: { t: 10, r: 80, b: 40, l: 180 },
|
|
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Draft Count', font: { size: 11 } } },
|
|
}, CFG);
|
|
|
|
// ===========================================================
|
|
// Cross-Org Collaboration Chart (Plotly)
|
|
// ===========================================================
|
|
const crossOrg = {{ cross_org | tojson }};
|
|
if (crossOrg.length > 0) {
|
|
const coLabels = crossOrg.map(c => `${c.org_a} + ${c.org_b}`).reverse();
|
|
const coValues = crossOrg.map(c => c.shared_drafts).reverse();
|
|
|
|
Plotly.newPlot('crossOrgChart', [{
|
|
y: coLabels, x: coValues,
|
|
type: 'bar', orientation: 'h',
|
|
marker: {
|
|
color: coValues.map((v, i) => {
|
|
const pct = i / Math.max(coValues.length - 1, 1);
|
|
return `hsl(${160 + pct * 60}, 65%, 50%)`;
|
|
}),
|
|
},
|
|
hovertemplate: '<b>%{y}</b><br>%{x} shared draft(s)<extra></extra>',
|
|
}], {
|
|
...PLOTLY_LAYOUT,
|
|
margin: { t: 10, r: 40, b: 40, l: 240 },
|
|
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Shared Drafts', font: { size: 11 } }, dtick: 1 },
|
|
yaxis: { ...PLOTLY_LAYOUT.yaxis, automargin: true, tickfont: { size: 10 } },
|
|
}, CFG);
|
|
} else {
|
|
document.getElementById('crossOrgChart').innerHTML =
|
|
'<p class="text-slate-500 text-sm text-center mt-20">No cross-org data available</p>';
|
|
}
|
|
|
|
// ===========================================================
|
|
// Sortable Authors Table
|
|
// ===========================================================
|
|
(function() {
|
|
const table = document.getElementById('authorsTable');
|
|
const tbody = document.getElementById('authorsBody');
|
|
let sortCol = null, sortAsc = true;
|
|
|
|
table.querySelectorAll('th[data-sort]').forEach(th => {
|
|
th.addEventListener('click', () => {
|
|
const col = th.dataset.sort;
|
|
if (sortCol === col) { sortAsc = !sortAsc; } else { sortCol = col; sortAsc = true; }
|
|
|
|
table.querySelectorAll('th[data-sort]').forEach(h =>
|
|
h.textContent = h.textContent.replace(/ [▲▼]/, ''));
|
|
th.textContent += sortAsc ? ' ▲' : ' ▼';
|
|
|
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
rows.sort((a, b) => {
|
|
let va, vb;
|
|
if (col === 'name') { va = a.dataset.name.toLowerCase(); vb = b.dataset.name.toLowerCase(); }
|
|
else if (col === 'org') { va = a.dataset.org.toLowerCase(); vb = b.dataset.org.toLowerCase(); }
|
|
else if (col === 'drafts') { va = parseInt(a.dataset.count); vb = parseInt(b.dataset.count); }
|
|
else { va = parseInt(a.cells[0].textContent); vb = parseInt(b.cells[0].textContent); }
|
|
|
|
if (typeof va === 'number') return sortAsc ? va - vb : vb - va;
|
|
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
|
|
});
|
|
rows.forEach(r => tbody.appendChild(r));
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|