Idea quality pipeline, web UI features, academic paper

- Tighten idea extraction prompts (1-4 ideas, no sub-features) reducing
  1,907 ideas to 468 across 434 drafts (78% reduction)
- Add embedding-based dedup (ietf dedup-ideas) for same-draft similarity
- Add novelty scoring (ietf ideas score) and filtering (ietf ideas filter)
  using Claude to rate ideas 1-5, removing 49 generic building blocks
- Final count: 419 high-quality ideas (avg 1.1/draft)
- Web UI: gap explorer with live draft generation and pre-generated demos
- Web UI: D3.js author collaboration network (498 nodes, 1142 edges,
  68 clusters, org filtering, interactive zoom/pan)
- Academic paper: 15-page LaTeX workshop paper analyzing the 434-draft
  AI agent standards landscape
- Save improvement ideas backlog to data/reports/improvement-ideas.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 22:17:57 +01:00
parent 3c3d7e649f
commit 6e3a387778
29 changed files with 6575 additions and 240 deletions

View File

@@ -0,0 +1,598 @@
{% extends "base.html" %}
{% set active_page = "authors" %}
{% block title %}Author Network — IETF Draft Analyzer{% endblock %}
{% block extra_head %}
<script src="https://d3js.org/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</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">Connected groups of authors who co-author drafts. Click a cluster to highlight it in the graph.</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 }}" onclick="highlightCluster({{ c.id }})">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-white">Cluster #{{ c.id + 1 }}</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400">{{ c.size }} authors</span>
</div>
<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>
<div class="text-xs text-slate-500 truncate" title="{{ c.members | join(', ') }}">
{{ c.members[:5] | join(', ') }}{% if c.members | length > 5 %} +{{ c.members | length - 5 }} more{% 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 to avoid mutating)
const nodes = network.nodes.map(n => ({...n}));
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
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)
)
.force('charge', d3.forceManyBody().strength(-120).distanceMax(300))
.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));
// 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;
});
});
// 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 = '';
node.select('circle')
.transition().duration(300)
.attr('opacity', n => members.has(n.id) ? 1 : 0.08);
node.selectAll('text')
.transition().duration(300)
.attr('opacity', n => members.has(n.id) ? 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 => {
c.classList.toggle('border-blue-500', c.dataset.clusterId == clusterId);
});
// Zoom to fit cluster members
const clusterNodes = nodes.filter(n => members.has(n.id));
if (clusterNodes.length > 0) {
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)
);
}
};
// 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 %}