Architecture designer, author cluster names, FP filtering, new pages
- Add /architecture page: system-of-systems view with 8 layers, component cards, gap markers, source coverage chart, and clickable detail sidebar - Give author clusters meaningful names from orgs + draft topic keywords - Filter false positives (73 drafts, 54 ideas) from idea clusters, architecture, ideas listing, and search results - Add NIST source fetcher with curated catalog of 11 AI publications - New pages: trends, complexity, sources, false positives, idea analysis - Clickable gap cards with full details (evidence, priority, nearby work) - Component detail panel with linked drafts and top ideas Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
105
src/webui/app.py
105
src/webui/app.py
@@ -55,6 +55,14 @@ from webui.data import (
|
||||
get_ask_synthesize,
|
||||
get_category_summary,
|
||||
global_search,
|
||||
get_architecture,
|
||||
get_source_comparison,
|
||||
get_false_positive_profile,
|
||||
get_citation_influence,
|
||||
get_bcp_analysis,
|
||||
get_trends_data,
|
||||
get_complexity_data,
|
||||
get_idea_analysis,
|
||||
)
|
||||
|
||||
app = Flask(
|
||||
@@ -306,6 +314,17 @@ def idea_clusters():
|
||||
return render_template("idea_clusters.html", clusters=data)
|
||||
|
||||
|
||||
@app.route("/architecture")
|
||||
def architecture():
|
||||
data = get_architecture(db())
|
||||
return render_template("architecture.html", arch=data)
|
||||
|
||||
|
||||
@app.route("/api/architecture")
|
||||
def api_architecture():
|
||||
return jsonify(get_architecture(db()))
|
||||
|
||||
|
||||
@app.route("/similarity")
|
||||
def similarity():
|
||||
network = get_similarity_graph(db())
|
||||
@@ -331,7 +350,9 @@ def authors():
|
||||
@app.route("/citations")
|
||||
def citations():
|
||||
graph = get_citation_graph(db())
|
||||
return render_template("citations.html", graph=graph)
|
||||
influence = get_citation_influence(db())
|
||||
bcp = get_bcp_analysis(db())
|
||||
return render_template("citations.html", graph=graph, influence=influence, bcp=bcp)
|
||||
|
||||
|
||||
@app.route("/monitor")
|
||||
@@ -674,6 +695,88 @@ def create_app(dev: bool = False) -> Flask:
|
||||
return app
|
||||
|
||||
|
||||
# ── Sources & False Positives ────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/sources")
|
||||
def sources_page():
|
||||
data = get_source_comparison(db())
|
||||
return render_template("sources.html", data=data)
|
||||
|
||||
|
||||
@app.route("/false-positives")
|
||||
def false_positives_page():
|
||||
data = get_false_positive_profile(db())
|
||||
return render_template("false_positives.html", data=data)
|
||||
|
||||
|
||||
@app.route("/api/sources")
|
||||
def api_sources():
|
||||
data = get_source_comparison(db())
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/false-positives")
|
||||
def api_false_positives():
|
||||
data = get_false_positive_profile(db())
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
# ── Citation Influence & BCP ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/api/citations/influence")
|
||||
def api_citation_influence():
|
||||
return jsonify(get_citation_influence(db()))
|
||||
|
||||
|
||||
@app.route("/api/citations/bcp")
|
||||
def api_bcp_analysis():
|
||||
return jsonify(get_bcp_analysis(db()))
|
||||
|
||||
|
||||
# ── Trends & Complexity ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/trends")
|
||||
def trends():
|
||||
data = get_trends_data(db())
|
||||
return render_template("trends_analysis.html", data=data)
|
||||
|
||||
|
||||
@app.route("/complexity")
|
||||
def complexity():
|
||||
data = get_complexity_data(db())
|
||||
return render_template("complexity.html", data=data)
|
||||
|
||||
|
||||
@app.route("/api/trends")
|
||||
def api_trends():
|
||||
data = get_trends_data(db())
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/complexity")
|
||||
def api_complexity():
|
||||
data = get_complexity_data(db())
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
# ── Idea Analysis ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/idea-analysis")
|
||||
def idea_analysis():
|
||||
data = get_idea_analysis(db())
|
||||
return render_template("idea_analysis.html", data=data)
|
||||
|
||||
|
||||
@app.route("/api/idea-analysis")
|
||||
def api_idea_analysis():
|
||||
data = get_idea_analysis(db())
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
|
||||
1693
src/webui/data.py
1693
src/webui/data.py
File diff suppressed because it is too large
Load Diff
465
src/webui/templates/architecture.html
Normal file
465
src/webui/templates/architecture.html
Normal file
@@ -0,0 +1,465 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "architecture" %}
|
||||
|
||||
{% block title %}Architecture — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.arch-layer {
|
||||
border-left: 3px solid;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.arch-layer:hover { background: rgba(255,255,255,0.02); }
|
||||
.layer-transport { border-color: #6366f1; }
|
||||
.layer-identity { border-color: #f59e0b; }
|
||||
.layer-discovery { border-color: #10b981; }
|
||||
.layer-communication { border-color: #3b82f6; }
|
||||
.layer-coordination { border-color: #8b5cf6; }
|
||||
.layer-intelligence { border-color: #ec4899; }
|
||||
.layer-safety { border-color: #ef4444; }
|
||||
.layer-application { border-color: #06b6d4; }
|
||||
|
||||
.comp-node {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.comp-node:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.comp-node.selected {
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
}
|
||||
.gap-marker.selected {
|
||||
box-shadow: 0 0 0 2px #ef4444;
|
||||
}
|
||||
|
||||
.gap-marker {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px dashed rgba(239, 68, 68, 0.4);
|
||||
animation: pulse-border 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { border-color: rgba(239, 68, 68, 0.4); }
|
||||
50% { border-color: rgba(239, 68, 68, 0.8); }
|
||||
}
|
||||
|
||||
.severity-critical { color: #ef4444; }
|
||||
.severity-high { color: #f59e0b; }
|
||||
.severity-medium { color: #3b82f6; }
|
||||
.severity-low { color: #6b7280; }
|
||||
|
||||
.source-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.source-ietf { background: #3b82f6; }
|
||||
.source-w3c { background: #f59e0b; }
|
||||
.source-etsi { background: #f97316; }
|
||||
.source-itu { background: #ec4899; }
|
||||
.source-iso { background: #a855f7; }
|
||||
.source-nist { background: #06b6d4; }
|
||||
|
||||
.detail-panel {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.95));
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.maturity-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(51, 65, 85, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
.maturity-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">System-of-Systems Architecture</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Holistic view of the AI agent standards landscape — {{ arch.stats.total_components }} components across
|
||||
{{ arch.layers|length }} architectural layers, with {{ arch.stats.total_gaps }} identified gaps.
|
||||
Built from {{ arch.stats.total_dependencies }} cross-component relationships.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
{% set severity_colors = {"critical": "red", "high": "amber", "medium": "blue", "low": "slate"} %}
|
||||
<div class="bg-slate-800/50 rounded-lg border border-slate-700/50 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ arch.stats.total_components }}</div>
|
||||
<div class="text-xs text-slate-500">Components</div>
|
||||
</div>
|
||||
<div class="bg-slate-800/50 rounded-lg border border-slate-700/50 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ arch.layers|length }}</div>
|
||||
<div class="text-xs text-slate-500">Layers</div>
|
||||
</div>
|
||||
<div class="bg-slate-800/50 rounded-lg border border-slate-700/50 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ arch.stats.total_dependencies }}</div>
|
||||
<div class="text-xs text-slate-500">Dependencies</div>
|
||||
</div>
|
||||
<div class="bg-slate-800/50 rounded-lg border border-slate-700/50 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-red-400">{{ arch.stats.total_gaps }}</div>
|
||||
<div class="text-xs text-slate-500">Gaps</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layer Coverage Overview -->
|
||||
<div class="bg-slate-800/30 rounded-xl border border-slate-700/50 p-4 mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-slate-300">Coverage by Standards Body</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
{% for src in ['ietf', 'w3c', 'etsi', 'itu', 'iso', 'nist'] %}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="source-dot source-{{ src }}"></span>
|
||||
<span class="text-xs text-slate-500">{{ src|upper }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<svg class="w-3 h-3 text-red-400" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2L2 19h20L12 2z"/></svg>
|
||||
<span class="text-xs text-slate-500">Gaps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="coverageChart"></div>
|
||||
</div>
|
||||
|
||||
<!-- Layered Stack View + Detail Panel side-by-side -->
|
||||
<div id="stackView" class="flex gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
{% for layer in arch.layers | sort(attribute='order', reverse=True) %}
|
||||
<div class="arch-layer layer-{{ layer.id }} rounded-r-lg mb-3 p-4 bg-slate-800/30">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white">{{ layer.label }}</h3>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<span class="text-xs text-slate-500">{{ layer.component_count }} components</span>
|
||||
<span class="text-xs text-slate-500">{{ layer.idea_count }} ideas</span>
|
||||
<span class="text-xs text-slate-500">{{ layer.total_drafts }} drafts</span>
|
||||
{% if layer.gap_count > 0 %}
|
||||
<span class="text-xs text-red-400">{{ layer.gap_count }} gap{{ 's' if layer.gap_count > 1 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Source coverage dots -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
{% for src, count in layer.coverage.items() %}
|
||||
<div class="flex items-center gap-1" title="{{ src|upper }}: {{ count }} drafts">
|
||||
<span class="source-dot source-{{ src }}"></span>
|
||||
<span class="text-xs text-slate-500">{{ count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Components in this layer -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||
{% for comp in arch.components if comp.layer == layer.id %}
|
||||
<div class="comp-node rounded-lg border border-slate-700/50 p-3" data-comp-id="{{ comp.id }}" onclick="selectComponent({{ comp.id }})">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-xs font-semibold text-slate-200 truncate" title="{{ comp.name }}">{{ comp.name }}</span>
|
||||
<span class="text-xs text-slate-500" title="{{ comp.size }} ideas, {{ comp.draft_count }} drafts">{{ comp.size }}i / {{ comp.draft_count }}d</span>
|
||||
</div>
|
||||
<!-- Maturity bar -->
|
||||
<div class="maturity-bar mb-2">
|
||||
<div class="maturity-fill {% if comp.maturity >= 4 %}bg-green-500{% elif comp.maturity >= 3 %}bg-yellow-500{% else %}bg-red-500{% endif %}"
|
||||
style="width: {{ (comp.maturity / 5 * 100)|int }}%"></div>
|
||||
</div>
|
||||
<!-- Source dots -->
|
||||
<div class="flex items-center gap-1">
|
||||
{% for src, cnt in comp.sources.items() %}
|
||||
<span class="source-dot source-{{ src }}" title="{{ src|upper }}: {{ cnt }}"></span>
|
||||
{% endfor %}
|
||||
{% if comp.type_breakdown %}
|
||||
<span class="text-xs text-slate-600 ml-auto">
|
||||
{{ comp.type_breakdown.keys() | list | first }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Gaps in this layer -->
|
||||
{% for gap in arch.gaps if gap.layer == layer.id %}
|
||||
<div class="gap-marker rounded-lg p-3 cursor-pointer" data-gap-id="{{ gap.id }}" onclick="selectGap({{ gap.id }})">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<svg class="w-3.5 h-3.5 severity-{{ gap.severity }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-semibold severity-{{ gap.severity }}">GAP</span>
|
||||
<span class="text-xs text-slate-600">{{ gap.severity }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-300">{{ gap.topic }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1 line-clamp-2">{{ gap.description[:120] }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel (sticky sidebar, shown on component click) -->
|
||||
<div id="detailPanel" class="detail-panel rounded-xl border border-slate-700/50 p-5 hidden w-[420px] shrink-0 sticky top-4 self-start max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-white" id="detailTitle"></h3>
|
||||
<button onclick="closeDetail()" class="text-slate-500 hover:text-slate-300 text-xs">Close</button>
|
||||
</div>
|
||||
<div id="detailContent"></div>
|
||||
</div>
|
||||
</div><!-- end stackView flex -->
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/js/plotly.min.js"></script>
|
||||
<script>
|
||||
const COMPONENTS = {{ arch.components | tojson }};
|
||||
const DEPENDENCIES = {{ arch.dependencies | tojson }};
|
||||
const LAYERS = {{ arch.layers | tojson }};
|
||||
const GAPS = {{ arch.gaps | tojson }};
|
||||
|
||||
const LAYER_COLORS = {
|
||||
transport: '#6366f1', identity: '#f59e0b', discovery: '#10b981',
|
||||
communication: '#3b82f6', coordination: '#8b5cf6', intelligence: '#ec4899',
|
||||
safety: '#ef4444', application: '#06b6d4'
|
||||
};
|
||||
|
||||
const SOURCE_COLORS = {
|
||||
ietf: '#3b82f6', w3c: '#f59e0b', etsi: '#f97316',
|
||||
itu: '#ec4899', iso: '#a855f7', nist: '#06b6d4'
|
||||
};
|
||||
|
||||
function renderCoverageChart() {
|
||||
const sources = ['ietf', 'w3c', 'etsi', 'itu', 'iso', 'nist'];
|
||||
const sourceLabels = { ietf: 'IETF', w3c: 'W3C', etsi: 'ETSI', itu: 'ITU-T', iso: 'ISO/IEC', nist: 'NIST' };
|
||||
// Sort layers top-down (highest order first)
|
||||
const sortedLayers = [...LAYERS].sort((a, b) => b.order - a.order);
|
||||
const labels = sortedLayers.map(l => l.label);
|
||||
|
||||
const traces = sources.map(src => ({
|
||||
y: labels,
|
||||
x: sortedLayers.map(l => (l.coverage[src] || 0)),
|
||||
name: sourceLabels[src],
|
||||
type: 'bar',
|
||||
orientation: 'h',
|
||||
marker: { color: SOURCE_COLORS[src], opacity: 0.85 },
|
||||
hovertemplate: `%{y}<br>${sourceLabels[src]}: %{x} drafts<extra></extra>`,
|
||||
}));
|
||||
|
||||
// Gap markers as annotations
|
||||
const gapAnnotations = [];
|
||||
sortedLayers.forEach(l => {
|
||||
const layerGaps = GAPS.filter(g => g.layer === l.id);
|
||||
if (layerGaps.length > 0) {
|
||||
const total = sources.reduce((s, src) => s + (l.coverage[src] || 0), 0);
|
||||
gapAnnotations.push({
|
||||
x: total + 8,
|
||||
y: l.label,
|
||||
text: `${layerGaps.length} gap${layerGaps.length > 1 ? 's' : ''}`,
|
||||
showarrow: false,
|
||||
font: { size: 10, color: '#f87171' },
|
||||
xanchor: 'left',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Plotly.newPlot('coverageChart', traces, {
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
barmode: 'stack',
|
||||
margin: { l: 180, r: 60, t: 10, b: 30 },
|
||||
xaxis: {
|
||||
title: { text: 'Documents', font: { size: 11, color: '#64748b' } },
|
||||
gridcolor: 'rgba(51,65,85,0.3)',
|
||||
tickfont: { size: 10, color: '#94a3b8' },
|
||||
},
|
||||
yaxis: {
|
||||
tickfont: { size: 11, color: '#cbd5e1' },
|
||||
automargin: true,
|
||||
},
|
||||
annotations: gapAnnotations,
|
||||
height: 320,
|
||||
showlegend: false,
|
||||
}, { responsive: true, displayModeBar: false });
|
||||
}
|
||||
|
||||
function selectComponent(compId) {
|
||||
const comp = COMPONENTS[compId];
|
||||
if (!comp) return;
|
||||
|
||||
document.getElementById('detailPanel').classList.remove('hidden');
|
||||
document.getElementById('detailTitle').textContent = comp.name;
|
||||
|
||||
// Find connected components
|
||||
const connected = DEPENDENCIES.filter(d => d.source === compId || d.target === compId)
|
||||
.map(d => {
|
||||
const otherId = d.source === compId ? d.target : d.source;
|
||||
return { ...COMPONENTS[otherId], sim: d.similarity, idea_a: d.idea_a, idea_b: d.idea_b };
|
||||
})
|
||||
.sort((a, b) => b.sim - a.sim);
|
||||
|
||||
// Find gaps in same layer
|
||||
const layerGaps = GAPS.filter(g => g.layer === comp.layer);
|
||||
|
||||
let html = `
|
||||
<div>
|
||||
<div class="text-xs text-slate-500 mb-2">Layer: <span class="text-slate-300">${comp.layer}</span> · Ideas: <span class="text-slate-300">${comp.size}</span> · Maturity: <span class="text-slate-300">${comp.maturity}/5</span></div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1.5 mt-3">Source Coverage</div>
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
${Object.entries(comp.sources).map(([s, n]) =>
|
||||
`<span class="text-xs px-2 py-0.5 rounded-full" style="background:${SOURCE_COLORS[s]}22; color:${SOURCE_COLORS[s]}">${s.toUpperCase()}: ${n}</span>`
|
||||
).join('')}
|
||||
</div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1.5">Idea Types</div>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
${Object.entries(comp.type_breakdown).map(([t, n]) =>
|
||||
`<span class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-300">${t} (${n})</span>`
|
||||
).join('')}
|
||||
</div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1.5">Top Ideas</div>
|
||||
<div class="space-y-1 mb-3">
|
||||
${comp.top_ideas.map(i =>
|
||||
`<div class="text-xs">• ${i.draft_name ? `<a href="/drafts/${i.draft_name}" class="text-blue-400 hover:text-blue-300">${i.title}</a>` : `<span class="text-slate-300">${i.title}</span>`} <span class="text-slate-600">(${i.type})</span></div>`
|
||||
).join('')}
|
||||
</div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1.5">Drafts (${comp.draft_count})</div>
|
||||
<div class="space-y-1 mb-3 max-h-36 overflow-y-auto">
|
||||
${(comp.drafts || []).map(d =>
|
||||
`<div class="text-xs"><a href="/drafts/${d.name}" class="text-blue-400 hover:text-blue-300">${d.title}</a> <span class="source-dot source-${d.source} ml-1" title="${d.source.toUpperCase()}"></span></div>`
|
||||
).join('')}
|
||||
${comp.draft_count > 20 ? `<div class="text-xs text-slate-600 italic">+ ${comp.draft_count - 20} more</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1.5">Connected Components (${connected.length})</div>
|
||||
<div class="space-y-1.5 max-h-48 overflow-y-auto mb-3">
|
||||
${connected.length > 0 ? connected.map(c =>
|
||||
`<div class="text-xs p-2 rounded bg-slate-800/60 border border-slate-700/30">
|
||||
<span class="text-slate-200">${c.name}</span>
|
||||
<span class="text-slate-500 ml-1">(${c.layer})</span>
|
||||
<span class="text-blue-400 ml-1 font-mono">${c.sim.toFixed(3)}</span>
|
||||
<div class="text-slate-600 mt-0.5">${c.idea_a} ↔ ${c.idea_b}</div>
|
||||
</div>`
|
||||
).join('') : '<div class="text-xs text-slate-600 italic">No connections</div>'}
|
||||
</div>
|
||||
|
||||
${layerGaps.length > 0 ? `
|
||||
<div class="text-xs font-semibold text-red-400 mb-1.5">Gaps in Layer (${layerGaps.length})</div>
|
||||
<div class="space-y-1.5">
|
||||
${layerGaps.map(g =>
|
||||
`<div class="text-xs p-2 rounded gap-marker">
|
||||
<span class="severity-${g.severity} font-semibold">${g.severity.toUpperCase()}</span>
|
||||
<span class="text-slate-300 ml-1">${g.topic}</span>
|
||||
</div>`
|
||||
).join('')}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('detailContent').innerHTML = html;
|
||||
|
||||
// Highlight in stack view
|
||||
document.querySelectorAll('.comp-node').forEach(n => n.classList.remove('selected'));
|
||||
const node = document.querySelector(`[data-comp-id="${compId}"]`);
|
||||
if (node) { node.classList.add('selected'); node.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
|
||||
}
|
||||
|
||||
function selectGap(gapId) {
|
||||
const gap = GAPS.find(g => g.id === gapId);
|
||||
if (!gap) return;
|
||||
|
||||
document.getElementById('detailPanel').classList.remove('hidden');
|
||||
document.getElementById('detailTitle').innerHTML =
|
||||
`<span class="severity-${gap.severity}">GAP:</span> ${gap.topic}`;
|
||||
|
||||
// Components in same layer (what exists nearby)
|
||||
const nearby = COMPONENTS.filter(c => c.layer === gap.layer);
|
||||
// Components in adjacent layers
|
||||
const layerOrder = {};
|
||||
LAYERS.forEach(l => { layerOrder[l.id] = l.order; });
|
||||
const gapOrder = layerOrder[gap.layer] || 0;
|
||||
const adjacent = COMPONENTS.filter(c => {
|
||||
const co = layerOrder[c.layer] || 0;
|
||||
return Math.abs(co - gapOrder) === 1;
|
||||
});
|
||||
|
||||
const sevLabel = { critical: 'Immediate standardization needed', high: 'Should be addressed soon',
|
||||
medium: 'Worth exploring', low: 'Nice to have' };
|
||||
|
||||
let html = `
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full severity-${gap.severity}"
|
||||
style="background: ${gap.severity === 'critical' ? 'rgba(239,68,68,0.15)' : gap.severity === 'high' ? 'rgba(245,158,11,0.15)' : 'rgba(59,130,246,0.15)'}">
|
||||
${gap.severity.toUpperCase()}
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">${gap.category}</span>
|
||||
<span class="text-xs text-slate-600">Layer: ${gap.layer}</span>
|
||||
</div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1">What's Missing</div>
|
||||
<div class="text-xs text-slate-300 leading-relaxed mb-3">${gap.description}</div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1">Evidence</div>
|
||||
<div class="text-xs text-slate-400 leading-relaxed mb-3 p-2 rounded bg-slate-800/60 border border-slate-700/30">${gap.evidence || 'No evidence recorded'}</div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1">Priority</div>
|
||||
<div class="text-xs text-slate-300 mb-3">${sevLabel[gap.severity] || gap.severity}</div>
|
||||
|
||||
${nearby.length > 0 ? `
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1">Existing Work in Same Layer (${nearby.length})</div>
|
||||
<div class="space-y-1 mb-3 max-h-32 overflow-y-auto">
|
||||
${nearby.map(c =>
|
||||
`<div class="text-xs p-2 rounded bg-slate-800/60 border border-slate-700/30 cursor-pointer hover:border-slate-600" onclick="selectComponent(${c.id})">
|
||||
<span class="text-slate-200">${c.name}</span>
|
||||
<span class="text-slate-600 ml-1">${c.size} ideas</span>
|
||||
</div>`
|
||||
).join('')}
|
||||
</div>` : `
|
||||
<div class="text-xs font-semibold text-red-400 mb-1">No Existing Components in Layer</div>
|
||||
<div class="text-xs text-slate-500 mb-3">This layer has no standardization components yet — a greenfield gap.</div>`}
|
||||
|
||||
${adjacent.length > 0 ? `
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1">Adjacent Layer Components (${adjacent.length})</div>
|
||||
<div class="space-y-1 max-h-32 overflow-y-auto">
|
||||
${adjacent.map(c =>
|
||||
`<div class="text-xs p-2 rounded bg-slate-800/60 border border-slate-700/30 cursor-pointer hover:border-slate-600" onclick="selectComponent(${c.id})">
|
||||
<span class="text-slate-200">${c.name}</span>
|
||||
<span class="text-slate-500 ml-1">(${c.layer})</span>
|
||||
<span class="text-slate-600 ml-1">${c.size} ideas</span>
|
||||
</div>`
|
||||
).join('')}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('detailContent').innerHTML = html;
|
||||
|
||||
// Highlight gap card
|
||||
document.querySelectorAll('.comp-node').forEach(n => n.classList.remove('selected'));
|
||||
document.querySelectorAll('.gap-marker').forEach(n => n.classList.remove('selected'));
|
||||
const card = document.querySelector(`[data-gap-id="${gapId}"]`);
|
||||
if (card) { card.classList.add('selected'); }
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
document.getElementById('detailPanel').classList.add('hidden');
|
||||
document.querySelectorAll('.comp-node').forEach(n => n.classList.remove('selected'));
|
||||
document.querySelectorAll('.gap-marker').forEach(n => n.classList.remove('selected'));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderCoverageChart();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -119,7 +119,7 @@
|
||||
<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>
|
||||
<span class="text-sm font-semibold text-white">{{ c.name }}</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>
|
||||
|
||||
@@ -117,6 +117,14 @@
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z"/><circle cx="19" cy="19" r="3" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
Idea Clusters
|
||||
</a>
|
||||
<a href="/idea-analysis" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'idea_analysis' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
Idea Analysis
|
||||
</a>
|
||||
<a href="/architecture" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'architecture' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
Architecture
|
||||
</a>
|
||||
{% if is_admin %}
|
||||
<a href="/gaps" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'gaps' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
@@ -127,6 +135,14 @@
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Timeline
|
||||
</a>
|
||||
<a href="/trends" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'trends' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||
Trends
|
||||
</a>
|
||||
<a href="/complexity" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'complexity' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/></svg>
|
||||
Complexity
|
||||
</a>
|
||||
<a href="/landscape" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'landscape' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||
Landscape
|
||||
@@ -139,6 +155,14 @@
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
||||
Citations
|
||||
</a>
|
||||
<a href="/sources" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'sources' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
|
||||
Sources
|
||||
</a>
|
||||
<a href="/false-positives" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'false_positives' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
|
||||
False Positives
|
||||
</a>
|
||||
<a href="/authors" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'authors' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
Authors
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "citations" %}
|
||||
|
||||
{% block title %}Citation Graph — IETF Draft Analyzer{% endblock %}
|
||||
{% block title %}Citations & Influence — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="/static/js/d3.v7.min.js"></script>
|
||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
<style>
|
||||
#citationSvg {
|
||||
width: 100%;
|
||||
@@ -26,95 +27,368 @@
|
||||
max-width: 320px; opacity: 0; transition: opacity 0.15s;
|
||||
}
|
||||
.tooltip-card.visible { opacity: 1; }
|
||||
.legend-swatch { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
||||
.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; }
|
||||
.tab-btn {
|
||||
transition: all 0.15s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab-btn:hover { color: #e2e8f0; }
|
||||
.tab-btn.active {
|
||||
color: #60a5fa;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Citation Graph</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Cross-reference network: {{ graph.stats.draft_count }} drafts referencing {{ graph.stats.rfc_count }} RFCs. References are extracted from each draft's text (RFC mentions, draft citations, BCP references). Node size reflects influence — how many other documents cite it. Highly-cited RFCs represent foundational standards that AI/agent drafts build upon.</p>
|
||||
<h1 class="text-2xl font-bold text-white">Citations & Influence</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Cross-reference network, citation influence metrics, and BCP dependency analysis across {{ influence.stats.drafts_with_refs }} drafts and {{ influence.stats.total_citations }} total citations.</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<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">Drafts</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.draft_count }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Total Citations</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ influence.stats.total_citations }}</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-orange-500 to-orange-400"></div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Referenced RFCs</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.rfc_count }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Unique RFCs Cited</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ influence.stats.unique_rfcs }}</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">Total Nodes</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.node_count }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Drafts with Refs</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ influence.stats.drafts_with_refs }}</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">Citation Links</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.edge_count }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Avg Refs/Draft</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ influence.stats.avg_refs_per_draft }}</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">BCP Coverage</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ bcp.coverage.coverage_pct }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- D3 Force-directed Citation 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">Cross-Reference Network</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-blue-500 align-middle mr-1"></span>Drafts
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-orange-500 align-middle mr-1 ml-3"></span>RFCs
|
||||
— Node size = influence (in-degree). Drag to rearrange. Scroll to zoom.
|
||||
</p>
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-slate-800 mb-6">
|
||||
<nav class="flex gap-6">
|
||||
<button class="tab-btn active px-1 pb-3 text-sm font-medium text-slate-400" data-tab="graph">Citation Graph</button>
|
||||
<button class="tab-btn px-1 pb-3 text-sm font-medium text-slate-400" data-tab="influence">Influence Analysis</button>
|
||||
<button class="tab-btn px-1 pb-3 text-sm font-medium text-slate-400" data-tab="bcp">BCP Dependencies</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ==================== TAB 1: Citation Graph ==================== -->
|
||||
<div id="tab-graph" class="tab-panel active">
|
||||
<!-- D3 Force-directed Citation 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">Cross-Reference Network</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-blue-500 align-middle mr-1"></span>Drafts
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-orange-500 align-middle mr-1 ml-3"></span>RFCs
|
||||
— Node size = influence. Drag 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="filterCategory" 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 Categories</option>
|
||||
</select>
|
||||
<label class="text-xs text-slate-500 ml-2">Min refs:</label>
|
||||
<input type="range" id="minRefsSlider" min="1" max="15" value="2" class="w-20" style="accent-color: #3b82f6;">
|
||||
<span id="minRefsVal" class="text-xs text-blue-400 font-mono w-4">2</span>
|
||||
</div>
|
||||
</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="filterCategory" 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 Categories</option>
|
||||
</select>
|
||||
<label class="text-xs text-slate-500 ml-2">Min refs:</label>
|
||||
<input type="range" id="minRefsSlider" min="1" max="15" value="2" class="w-20" style="accent-color: #3b82f6;">
|
||||
<span id="minRefsVal" class="text-xs text-blue-400 font-mono w-4">2</span>
|
||||
<div class="relative">
|
||||
<svg id="citationSvg"></svg>
|
||||
<div id="tooltip" class="tooltip-card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<svg id="citationSvg"></svg>
|
||||
<div id="tooltip" class="tooltip-card"></div>
|
||||
|
||||
<!-- Top Referenced RFCs Table -->
|
||||
<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">Most Referenced RFCs</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">RFCs cited by the most drafts in the corpus</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<table class="w-full text-sm" id="rfcTable">
|
||||
<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">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">RFC</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">Cited By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50" id="rfcBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Referenced RFCs Table -->
|
||||
<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">Most Referenced RFCs</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">RFCs cited by the most drafts in the corpus</p>
|
||||
<!-- ==================== TAB 2: Influence Analysis ==================== -->
|
||||
<div id="tab-influence" class="tab-panel">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Top Cited RFCs with details -->
|
||||
<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">Top 20 Most-Cited RFCs</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Foundational standards the AI/agent ecosystem builds upon</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<table class="w-full text-sm">
|
||||
<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">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">RFC</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">Name</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">Cited By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for rfc in influence.top_cited_rfcs %}
|
||||
<tr class="hover:bg-slate-800/50 transition group">
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">{{ loop.index }}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<a href="https://www.rfc-editor.org/rfc/rfc{{ rfc.rfc_id }}" target="_blank" rel="noopener"
|
||||
class="text-orange-400 hover:text-orange-300 transition font-medium text-sm">RFC {{ rfc.rfc_id }}</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-slate-300 text-xs">{{ rfc.name }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium
|
||||
{% if rfc.count >= 20 %}bg-orange-500/20 text-orange-400
|
||||
{% elif rfc.count >= 10 %}bg-blue-500/20 text-blue-400
|
||||
{% else %}bg-slate-700/50 text-slate-400{% endif %}">
|
||||
{{ rfc.count }} drafts
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Citing Drafts -->
|
||||
<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">Top 20 Most-Citing Drafts</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Drafts with the highest outgoing reference count</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<table class="w-full text-sm">
|
||||
<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">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">Draft</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">Category</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">Refs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for d in influence.top_citing_drafts %}
|
||||
<tr class="hover:bg-slate-800/50 transition">
|
||||
<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/{{ d.name }}" class="text-blue-400 hover:text-blue-300 transition text-sm">
|
||||
{{ d.title[:60] }}{% if d.title|length > 60 %}...{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs bg-slate-700/50 text-slate-300">{{ d.category }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/20 text-blue-400">{{ d.count }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<table class="w-full text-sm" id="rfcTable">
|
||||
<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">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">RFC</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">Cited By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50" id="rfcBody">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- PageRank-style influence -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden mb-6">
|
||||
<div class="p-4 border-b border-slate-800">
|
||||
<h2 class="text-sm font-semibold text-slate-300">Influence Score (PageRank-style)</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Drafts ranked by weighted sum of how often their cited RFCs are themselves cited — higher score means citing more foundational standards</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||
<table class="w-full text-sm">
|
||||
<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">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">Draft</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">Category</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">Out-Degree</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">Influence Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for d in influence.top_pagerank %}
|
||||
<tr class="hover:bg-slate-800/50 transition">
|
||||
<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/{{ d.name }}" class="text-blue-400 hover:text-blue-300 transition text-sm">
|
||||
{{ d.title[:60] }}{% if d.title|length > 60 %}...{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs bg-slate-700/50 text-slate-300">{{ d.category }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right text-slate-400">{{ d.out_degree }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-400">{{ d.score }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Citation density by category chart -->
|
||||
<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">Average Citations per Category</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Which categories reference the most external standards</p>
|
||||
<div id="categoryChart" style="height: 400px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Draft-to-Draft network -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Draft-to-Draft Citation Network</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">{{ influence.draft_network|length }} cross-citations between drafts in the corpus</p>
|
||||
<div id="draftNetworkChart" style="height: 500px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== TAB 3: BCP Dependencies ==================== -->
|
||||
<div id="tab-bcp" class="tab-panel">
|
||||
<!-- BCP Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 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-amber-500 to-amber-400"></div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Unique BCPs</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ bcp.coverage.unique_bcps }}</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">Total BCP Refs</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ bcp.coverage.total_bcp_refs }}</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-blue-500 to-blue-400"></div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Drafts with BCPs</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ bcp.coverage.drafts_with_bcp }}</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">BCP Coverage</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ bcp.coverage.coverage_pct }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- BCP Citation Table -->
|
||||
<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">All BCPs by Citation Count</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">{{ bcp.coverage.unique_bcps }} unique BCPs cited across the corpus</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<table class="w-full text-sm">
|
||||
<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">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">BCP</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">Cited By</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">Example Drafts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for b in bcp.bcps %}
|
||||
<tr class="hover:bg-slate-800/50 transition">
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">{{ loop.index }}</td>
|
||||
<td class="px-4 py-2.5 font-medium text-amber-400">BCP {{ b.bcp_id }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium
|
||||
{% if b.count >= 50 %}bg-amber-500/20 text-amber-400
|
||||
{% elif b.count >= 10 %}bg-blue-500/20 text-blue-400
|
||||
{% else %}bg-slate-700/50 text-slate-400{% endif %}">
|
||||
{{ b.count }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs text-slate-500 max-w-[200px] truncate">
|
||||
{{ b.drafts[:3]|join(', ') }}{% if b.total_drafts > 3 %} +{{ b.total_drafts - 3 }} more{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BCP by Category -->
|
||||
<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">BCP Usage by Category</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Which categories rely most heavily on BCPs</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<table class="w-full text-sm">
|
||||
<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">Category</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">BCP Refs</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">Unique BCPs</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">Top BCPs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for cat in bcp.by_category %}
|
||||
<tr class="hover:bg-slate-800/50 transition">
|
||||
<td class="px-4 py-2.5 text-slate-300 text-sm font-medium">{{ cat.category }}</td>
|
||||
<td class="px-4 py-2.5 text-right text-slate-300">{{ cat.total_bcp_refs }}</td>
|
||||
<td class="px-4 py-2.5 text-right text-slate-400">{{ cat.unique_bcps }}</td>
|
||||
<td class="px-4 py-2.5 text-xs text-slate-500">
|
||||
{% for tb in cat.top_bcps[:3] %}
|
||||
<span class="inline-block px-1.5 py-0.5 rounded bg-slate-700/50 text-amber-400 mr-1 mb-0.5">BCP{{ tb.bcp_id }}({{ tb.count }})</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BCP Co-citation Heatmap -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">BCP Co-Citation Heatmap</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">How often pairs of BCPs are cited together in the same draft. Darker = more co-citations.</p>
|
||||
<div id="bcpHeatmap" style="height: 500px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const graph = {{ graph | tojson }};
|
||||
const influence = {{ influence | tojson }};
|
||||
const bcp = {{ bcp | tojson }};
|
||||
|
||||
const PALETTE = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
||||
@@ -122,7 +396,22 @@ const PALETTE = [
|
||||
];
|
||||
|
||||
// ===========================================================
|
||||
// D3.js Force-Directed Citation Network
|
||||
// Tab switching
|
||||
// ===========================================================
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
document.getElementById('tab-' + this.dataset.tab).classList.add('active');
|
||||
|
||||
// Trigger Plotly resize for charts that might not have rendered
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================
|
||||
// D3.js Force-Directed Citation Network (Tab 1)
|
||||
// ===========================================================
|
||||
(function() {
|
||||
if (graph.nodes.length === 0) {
|
||||
@@ -184,27 +473,20 @@ const PALETTE = [
|
||||
const maxInfluence = d3.max(nodes, n => n.influence) || 1;
|
||||
const rScale = d3.scaleSqrt().domain([0, maxInfluence]).range([3, 24]);
|
||||
|
||||
// Color: drafts = blue, rfcs = orange, others = amber
|
||||
function nodeColor(n) {
|
||||
if (n.type === 'rfc') return '#f59e0b';
|
||||
if (n.type === 'bcp') return '#eab308';
|
||||
return '#3b82f6';
|
||||
}
|
||||
|
||||
// Force simulation
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links)
|
||||
.id(d => d.id)
|
||||
.distance(60)
|
||||
.strength(0.15)
|
||||
)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(60).strength(0.15))
|
||||
.force('charge', d3.forceManyBody().strength(-80).distanceMax(350))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(d => rScale(d.influence) + 2))
|
||||
.force('x', d3.forceX(width / 2).strength(0.04))
|
||||
.force('y', d3.forceY(height / 2).strength(0.04));
|
||||
|
||||
// Zoom behavior
|
||||
const g = svg.append('g');
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.15, 5])
|
||||
@@ -215,33 +497,22 @@ const PALETTE = [
|
||||
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', 0.8);
|
||||
.data(links).join('line')
|
||||
.attr('class', 'link').attr('stroke', '#475569').attr('stroke-width', 0.8);
|
||||
|
||||
// Draw nodes
|
||||
const nodeGroup = g.append('g').attr('class', 'nodes');
|
||||
const node = nodeGroup.selectAll('g')
|
||||
.data(nodes)
|
||||
.join('g')
|
||||
.attr('class', 'node')
|
||||
.data(nodes).join('g').attr('class', 'node')
|
||||
.call(d3.drag()
|
||||
.on('start', dragStarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragEnded)
|
||||
);
|
||||
.on('start', dragStarted).on('drag', dragged).on('end', dragEnded));
|
||||
|
||||
node.append('circle')
|
||||
.attr('r', d => rScale(d.influence))
|
||||
.attr('fill', d => nodeColor(d))
|
||||
.attr('opacity', 0.85);
|
||||
|
||||
// Labels for high-influence nodes
|
||||
node.filter(d => d.influence >= 5)
|
||||
.append('text')
|
||||
.text(d => {
|
||||
@@ -251,11 +522,9 @@ const PALETTE = [
|
||||
})
|
||||
.attr('dy', d => -(rScale(d.influence) + 4))
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', '#94a3b8')
|
||||
.attr('font-size', '8px')
|
||||
.attr('fill', '#94a3b8').attr('font-size', '8px')
|
||||
.attr('font-family', 'Inter, system-ui, sans-serif');
|
||||
|
||||
// Tooltip
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
|
||||
node.on('mouseover', function(event, d) {
|
||||
@@ -270,8 +539,6 @@ const PALETTE = [
|
||||
</div>
|
||||
`;
|
||||
tooltip.classList.add('visible');
|
||||
|
||||
// Highlight connected nodes
|
||||
const connected = new Set();
|
||||
links.forEach(l => {
|
||||
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
@@ -280,17 +547,13 @@ const PALETTE = [
|
||||
if (tid === d.id) connected.add(sid);
|
||||
});
|
||||
connected.add(d.id);
|
||||
|
||||
node.select('circle')
|
||||
.attr('opacity', n => connected.has(n.id) ? 1 : 0.1);
|
||||
node.selectAll('text')
|
||||
.attr('opacity', n => connected.has(n.id) ? 1 : 0.1);
|
||||
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.6 : 0.02;
|
||||
});
|
||||
node.select('circle').attr('opacity', n => connected.has(n.id) ? 1 : 0.1);
|
||||
node.selectAll('text').attr('opacity', n => connected.has(n.id) ? 1 : 0.1);
|
||||
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.6 : 0.02;
|
||||
});
|
||||
})
|
||||
.on('mousemove', function(event) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
@@ -311,30 +574,22 @@ const PALETTE = [
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
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 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;
|
||||
}
|
||||
|
||||
// Category filter
|
||||
catSelect.addEventListener('change', function() {
|
||||
const cat = this.value;
|
||||
if (!cat) {
|
||||
@@ -344,43 +599,33 @@ const PALETTE = [
|
||||
return;
|
||||
}
|
||||
const inCat = new Set();
|
||||
nodes.forEach(n => {
|
||||
if (n.type === 'draft' && n.category === cat) inCat.add(n.id);
|
||||
});
|
||||
// Also include RFCs referenced by those drafts
|
||||
nodes.forEach(n => { if (n.type === 'draft' && n.category === cat) inCat.add(n.id); });
|
||||
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 (inCat.has(sid)) inCat.add(tid);
|
||||
});
|
||||
node.select('circle')
|
||||
.attr('opacity', n => inCat.has(n.id) ? 1 : 0.05);
|
||||
node.selectAll('text')
|
||||
.attr('opacity', n => inCat.has(n.id) ? 1 : 0.05);
|
||||
node.select('circle').attr('opacity', n => inCat.has(n.id) ? 1 : 0.05);
|
||||
node.selectAll('text').attr('opacity', n => inCat.has(n.id) ? 1 : 0.05);
|
||||
link.attr('stroke-opacity', l => {
|
||||
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
return inCat.has(sid) ? 0.5 : 0.01;
|
||||
});
|
||||
});
|
||||
|
||||
// Min refs slider (client-side filter)
|
||||
const slider = document.getElementById('minRefsSlider');
|
||||
const sliderVal = document.getElementById('minRefsVal');
|
||||
slider.addEventListener('input', function() {
|
||||
sliderVal.textContent = this.value;
|
||||
const minR = parseInt(this.value);
|
||||
// Show/hide RFC nodes by influence
|
||||
node.select('circle')
|
||||
.attr('opacity', n => {
|
||||
if (n.type === 'draft') return 0.85;
|
||||
return n.influence >= minR ? 0.85 : 0.05;
|
||||
});
|
||||
node.selectAll('text')
|
||||
.attr('opacity', n => {
|
||||
if (n.type === 'draft') return 1;
|
||||
return n.influence >= minR ? 1 : 0.05;
|
||||
});
|
||||
// Filter edges
|
||||
node.select('circle').attr('opacity', n => {
|
||||
if (n.type === 'draft') return 0.85;
|
||||
return n.influence >= minR ? 0.85 : 0.05;
|
||||
});
|
||||
node.selectAll('text').attr('opacity', n => {
|
||||
if (n.type === 'draft') return 1;
|
||||
return n.influence >= minR ? 1 : 0.05;
|
||||
});
|
||||
const visibleRfcs = new Set(nodes.filter(n => n.type !== 'draft' && n.influence >= minR).map(n => n.id));
|
||||
link.attr('stroke-opacity', l => {
|
||||
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
@@ -388,5 +633,155 @@ const PALETTE = [
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// ===========================================================
|
||||
// Plotly: Citation density by category (Tab 2)
|
||||
// ===========================================================
|
||||
(function() {
|
||||
const cats = influence.citations_by_category;
|
||||
if (!cats || cats.length === 0) return;
|
||||
|
||||
Plotly.newPlot('categoryChart', [{
|
||||
type: 'bar',
|
||||
x: cats.map(c => c.category),
|
||||
y: cats.map(c => c.avg_citations),
|
||||
text: cats.map(c => `${c.avg_citations} avg (${c.total_citations} total, ${c.draft_count} drafts)`),
|
||||
hovertemplate: '%{x}<br>Avg: %{y:.1f} refs/draft<br>%{text}<extra></extra>',
|
||||
marker: {
|
||||
color: cats.map((_, i) => PALETTE[i % PALETTE.length]),
|
||||
opacity: 0.85,
|
||||
},
|
||||
}], {
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 11 },
|
||||
margin: { t: 20, r: 20, b: 80, l: 50 },
|
||||
xaxis: {
|
||||
tickangle: -35,
|
||||
gridcolor: 'rgba(71,85,105,0.2)',
|
||||
},
|
||||
yaxis: {
|
||||
title: 'Avg Citations per Draft',
|
||||
gridcolor: 'rgba(71,85,105,0.2)',
|
||||
},
|
||||
}, { responsive: true, displayModeBar: false });
|
||||
})();
|
||||
|
||||
// ===========================================================
|
||||
// Plotly: Draft-to-Draft Network (Tab 2)
|
||||
// ===========================================================
|
||||
(function() {
|
||||
const edges = influence.draft_network;
|
||||
if (!edges || edges.length === 0) {
|
||||
document.getElementById('draftNetworkChart').innerHTML =
|
||||
'<p class="text-slate-500 text-sm text-center py-20">No draft-to-draft citations found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a simple force-layout-like visualization using Plotly scatter
|
||||
// We'll use a circular layout for nodes involved in draft-to-draft citations
|
||||
const nodeSet = new Set();
|
||||
edges.forEach(e => { nodeSet.add(e.source); nodeSet.add(e.target); });
|
||||
const nodeList = [...nodeSet];
|
||||
const nodeIdx = Object.fromEntries(nodeList.map((n, i) => [n, i]));
|
||||
|
||||
// Circular layout
|
||||
const n = nodeList.length;
|
||||
const cx = 0.5, cy = 0.5, radius = 0.4;
|
||||
const nodeX = nodeList.map((_, i) => cx + radius * Math.cos(2 * Math.PI * i / n));
|
||||
const nodeY = nodeList.map((_, i) => cy + radius * Math.sin(2 * Math.PI * i / n));
|
||||
|
||||
// Edge traces
|
||||
const edgeX = [], edgeY = [];
|
||||
edges.forEach(e => {
|
||||
const si = nodeIdx[e.source], ti = nodeIdx[e.target];
|
||||
if (si !== undefined && ti !== undefined) {
|
||||
edgeX.push(nodeX[si], nodeX[ti], null);
|
||||
edgeY.push(nodeY[si], nodeY[ti], null);
|
||||
}
|
||||
});
|
||||
|
||||
// Short names for display
|
||||
const shortNames = nodeList.map(name => {
|
||||
const s = name.replace(/^draft-/, '');
|
||||
return s.length > 25 ? s.slice(0, 23) + '..' : s;
|
||||
});
|
||||
|
||||
const data = [
|
||||
{
|
||||
type: 'scatter', mode: 'lines',
|
||||
x: edgeX, y: edgeY,
|
||||
line: { color: 'rgba(71,85,105,0.3)', width: 1 },
|
||||
hoverinfo: 'none',
|
||||
},
|
||||
{
|
||||
type: 'scatter', mode: 'markers+text',
|
||||
x: nodeX, y: nodeY,
|
||||
text: shortNames,
|
||||
textposition: 'top center',
|
||||
textfont: { size: 8, color: '#94a3b8' },
|
||||
marker: { size: 8, color: '#3b82f6', opacity: 0.85 },
|
||||
hovertext: nodeList.map(name => {
|
||||
const outCount = edges.filter(e => e.source === name).length;
|
||||
const inCount = edges.filter(e => e.target === name).length;
|
||||
return `${name}\nOut: ${outCount} | In: ${inCount}`;
|
||||
}),
|
||||
hoverinfo: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
Plotly.newPlot('draftNetworkChart', data, {
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif' },
|
||||
margin: { t: 10, r: 10, b: 10, l: 10 },
|
||||
xaxis: { visible: false },
|
||||
yaxis: { visible: false },
|
||||
showlegend: false,
|
||||
}, { responsive: true, displayModeBar: false });
|
||||
})();
|
||||
|
||||
// ===========================================================
|
||||
// Plotly: BCP Co-citation Heatmap (Tab 3)
|
||||
// ===========================================================
|
||||
(function() {
|
||||
const labels = bcp.heatmap_labels;
|
||||
const matrix = bcp.heatmap_matrix;
|
||||
if (!labels || labels.length === 0) {
|
||||
document.getElementById('bcpHeatmap').innerHTML =
|
||||
'<p class="text-slate-500 text-sm text-center py-20">No BCP co-citation data</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const displayLabels = labels.map(l => 'BCP ' + l);
|
||||
|
||||
Plotly.newPlot('bcpHeatmap', [{
|
||||
type: 'heatmap',
|
||||
x: displayLabels,
|
||||
y: displayLabels,
|
||||
z: matrix,
|
||||
colorscale: [
|
||||
[0, '#0f172a'],
|
||||
[0.1, '#1e3a5f'],
|
||||
[0.3, '#1d4ed8'],
|
||||
[0.5, '#3b82f6'],
|
||||
[0.7, '#60a5fa'],
|
||||
[1, '#f59e0b'],
|
||||
],
|
||||
hovertemplate: '%{x} + %{y}<br>Co-cited in %{z} drafts<extra></extra>',
|
||||
showscale: true,
|
||||
colorbar: {
|
||||
title: { text: 'Co-citations', font: { color: '#94a3b8', size: 10 } },
|
||||
tickfont: { color: '#94a3b8', size: 9 },
|
||||
},
|
||||
}], {
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 10 },
|
||||
margin: { t: 20, r: 60, b: 80, l: 80 },
|
||||
xaxis: { tickangle: -45, side: 'bottom' },
|
||||
yaxis: { autorange: 'reversed' },
|
||||
}, { responsive: true, displayModeBar: false });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
332
src/webui/templates/complexity.html
Normal file
332
src/webui/templates/complexity.html
Normal file
@@ -0,0 +1,332 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "complexity" %}
|
||||
|
||||
{% block title %}Complexity — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Draft Complexity Matrix</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Correlating structural complexity (pages, authors, citations, ideas) with quality ratings. Does more complexity mean better drafts?</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Avg Pages</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.stats.avg_pages }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">{{ data.stats.pages_coverage_pct }}% have page data</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Avg Authors</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.stats.avg_authors }}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Avg Citations</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.stats.avg_citations }}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Drafts Analyzed</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.stats.total_drafts }}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Metrics</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.metrics | length }} x {{ data.dimensions | length }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">complexity x rating</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scatter Plot Matrix -->
|
||||
<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">Complexity vs Rating Scatter Plots</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">How structural complexity metrics relate to rating dimensions. Each dot is a rated draft. Click to navigate.</p>
|
||||
<div id="scatterMatrix" style="height: 500px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Correlation Table -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden mb-6">
|
||||
<div class="p-4 border-b border-slate-800">
|
||||
<h2 class="text-sm font-semibold text-slate-300">Correlation Matrix</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Pearson correlation between complexity metrics (rows) and rating dimensions (columns). Green = positive, red = negative. Values range from -1 to +1.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 text-left text-xs text-slate-500">
|
||||
<th class="px-4 py-3 font-medium">Metric</th>
|
||||
{% for dim in data.dimensions %}
|
||||
<th class="px-4 py-3 font-medium text-center">{{ dim | capitalize }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50" id="corrTable">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Top 10 Most Complex -->
|
||||
<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">Top 10 Most Complex Drafts</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Ranked by composite complexity (pages + authors + citations + ideas, normalized).</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 text-left text-xs text-slate-500">
|
||||
<th class="px-4 py-2 font-medium">#</th>
|
||||
<th class="px-4 py-2 font-medium">Draft</th>
|
||||
<th class="px-4 py-2 font-medium text-right">Pages</th>
|
||||
<th class="px-4 py-2 font-medium text-right">Authors</th>
|
||||
<th class="px-4 py-2 font-medium text-right">Cites</th>
|
||||
<th class="px-4 py-2 font-medium text-right">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50" id="complexTable">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top 10 Most Efficient -->
|
||||
<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">Top 10 Most Efficient Drafts</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">High ratings relative to low structural complexity. Efficiency = score / complexity.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 text-left text-xs text-slate-500">
|
||||
<th class="px-4 py-2 font-medium">#</th>
|
||||
<th class="px-4 py-2 font-medium">Draft</th>
|
||||
<th class="px-4 py-2 font-medium text-right">Pages</th>
|
||||
<th class="px-4 py-2 font-medium text-right">Authors</th>
|
||||
<th class="px-4 py-2 font-medium text-right">Score</th>
|
||||
<th class="px-4 py-2 font-medium text-right">Efficiency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50" id="efficientTable">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Complexity -->
|
||||
<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">Complexity by Category</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Average complexity metrics per category. Wider bars = more complex category.</p>
|
||||
<div id="catComplexity" style="height: 400px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Source Complexity -->
|
||||
<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">Complexity by Source</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 text-left text-xs text-slate-500">
|
||||
<th class="px-4 py-3 font-medium">Source</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Count</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Avg Pages</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Avg Authors</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Avg Citations</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Avg Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for s in data.source_complexity %}
|
||||
<tr class="hover:bg-slate-800/50 transition">
|
||||
<td class="px-4 py-2 text-slate-300 font-medium">{{ s.source | upper }}</td>
|
||||
<td class="px-4 py-2 text-right text-slate-400">{{ s.count }}</td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">{{ s.avg_pages }}</td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">{{ s.avg_authors }}</td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">{{ s.avg_citations }}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
{% if s.avg_score >= 3.0 %}
|
||||
<span class="score-badge score-high">{{ s.avg_score }}</span>
|
||||
{% elif s.avg_score >= 2.0 %}
|
||||
<span class="score-badge score-mid">{{ s.avg_score }}</span>
|
||||
{% else %}
|
||||
<span class="score-badge score-low">{{ s.avg_score }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const PLOTLY_LAYOUT = {
|
||||
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 12 },
|
||||
margin: { t: 30, r: 20, b: 40, l: 50 },
|
||||
xaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
|
||||
};
|
||||
const CFG = { responsive: true, displayModeBar: false };
|
||||
|
||||
const cdata = {{ data | tojson }};
|
||||
|
||||
// Scatter plot matrix (2x3 subplots)
|
||||
(function() {
|
||||
const pairs = [
|
||||
{ x: 'pages', y: 'novelty', xLabel: 'Pages', yLabel: 'Novelty' },
|
||||
{ x: 'author_count', y: 'maturity', xLabel: 'Authors', yLabel: 'Maturity' },
|
||||
{ x: 'citation_count', y: 'relevance', xLabel: 'Citations', yLabel: 'Relevance' },
|
||||
{ x: 'idea_count', y: 'momentum', xLabel: 'Ideas', yLabel: 'Momentum' },
|
||||
{ x: 'pages', y: 'maturity', xLabel: 'Pages', yLabel: 'Maturity' },
|
||||
{ x: 'citation_count', y: 'novelty', xLabel: 'Citations', yLabel: 'Novelty' },
|
||||
];
|
||||
const colors = ['#3b82f6', '#22c55e', '#a855f7', '#f59e0b', '#06b6d4', '#ef4444'];
|
||||
const traces = [];
|
||||
const annotations = [];
|
||||
|
||||
pairs.forEach((p, i) => {
|
||||
const row = i < 3 ? 1 : 2;
|
||||
const col = (i % 3) + 1;
|
||||
const xaxis = i === 0 ? 'x' : `x${i + 1}`;
|
||||
const yaxis = i === 0 ? 'y' : `y${i + 1}`;
|
||||
const filteredDrafts = p.x === 'pages'
|
||||
? cdata.drafts.filter(d => d.pages !== null)
|
||||
: cdata.drafts;
|
||||
traces.push({
|
||||
x: filteredDrafts.map(d => d[p.x]),
|
||||
y: filteredDrafts.map(d => d[p.y]),
|
||||
text: filteredDrafts.map(d => d.name),
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
marker: { color: colors[i], size: 5, opacity: 0.6 },
|
||||
xaxis: xaxis,
|
||||
yaxis: yaxis,
|
||||
hovertemplate: `<b>%{text}</b><br>${p.xLabel}: %{x}<br>${p.yLabel}: %{y}<extra></extra>`,
|
||||
showlegend: false,
|
||||
});
|
||||
});
|
||||
|
||||
const layout = {
|
||||
...PLOTLY_LAYOUT,
|
||||
grid: { rows: 2, columns: 3, pattern: 'independent' },
|
||||
margin: { t: 30, r: 20, b: 40, l: 50 },
|
||||
};
|
||||
// Set axis labels
|
||||
pairs.forEach((p, i) => {
|
||||
const xKey = i === 0 ? 'xaxis' : `xaxis${i + 1}`;
|
||||
const yKey = i === 0 ? 'yaxis' : `yaxis${i + 1}`;
|
||||
layout[xKey] = { ...PLOTLY_LAYOUT.xaxis, title: { text: p.xLabel, font: { size: 10 } } };
|
||||
layout[yKey] = { ...PLOTLY_LAYOUT.yaxis, title: { text: p.yLabel, font: { size: 10 } } };
|
||||
});
|
||||
|
||||
Plotly.newPlot('scatterMatrix', traces, layout, CFG);
|
||||
|
||||
// Click to navigate
|
||||
document.getElementById('scatterMatrix').on('plotly_click', function(ev) {
|
||||
const pt = ev.points[0];
|
||||
if (pt.text) window.location.href = '/drafts/' + pt.text;
|
||||
});
|
||||
})();
|
||||
|
||||
// Correlation table
|
||||
(function() {
|
||||
const tbody = document.getElementById('corrTable');
|
||||
const metricLabels = { pages: 'Pages', author_count: 'Authors', citation_count: 'Citations', idea_count: 'Ideas', category_count: 'Categories' };
|
||||
cdata.metrics.forEach(metric => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-slate-800/50 transition';
|
||||
let cells = `<td class="px-4 py-2 text-slate-300 font-medium">${metricLabels[metric] || metric}</td>`;
|
||||
cdata.dimensions.forEach(dim => {
|
||||
const val = cdata.correlations[metric][dim];
|
||||
let bgClass, textColor;
|
||||
const absVal = Math.abs(val);
|
||||
if (val > 0.2) { bgClass = 'bg-green-900/30'; textColor = 'text-green-400'; }
|
||||
else if (val < -0.2) { bgClass = 'bg-red-900/30'; textColor = 'text-red-400'; }
|
||||
else if (absVal > 0.1) { bgClass = 'bg-yellow-900/20'; textColor = 'text-yellow-400'; }
|
||||
else { bgClass = ''; textColor = 'text-slate-500'; }
|
||||
cells += `<td class="px-4 py-2 text-center ${bgClass}"><span class="${textColor} font-mono text-xs">${val.toFixed(3)}</span></td>`;
|
||||
});
|
||||
row.innerHTML = cells;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
})();
|
||||
|
||||
// Top 10 Complex table
|
||||
(function() {
|
||||
const tbody = document.getElementById('complexTable');
|
||||
cdata.top_complex.forEach((d, i) => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-slate-800/50 transition';
|
||||
const shortName = d.name.replace('draft-', '').substring(0, 35);
|
||||
const scoreClass = d.score >= 3.0 ? 'score-high' : d.score >= 2.0 ? 'score-mid' : 'score-low';
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-2 text-slate-500 font-mono text-xs">${i + 1}</td>
|
||||
<td class="px-4 py-2"><a href="/drafts/${d.name}" class="text-blue-400 hover:text-blue-300 text-xs font-mono">${shortName}</a></td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">${d.pages !== null ? d.pages : '-'}</td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">${d.author_count}</td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">${d.citation_count}</td>
|
||||
<td class="px-4 py-2 text-right"><span class="score-badge ${scoreClass}">${d.score.toFixed(2)}</span></td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
})();
|
||||
|
||||
// Top 10 Efficient table
|
||||
(function() {
|
||||
const tbody = document.getElementById('efficientTable');
|
||||
cdata.top_efficient.forEach((d, i) => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-slate-800/50 transition';
|
||||
const shortName = d.name.replace('draft-', '').substring(0, 35);
|
||||
const scoreClass = d.score >= 3.0 ? 'score-high' : d.score >= 2.0 ? 'score-mid' : 'score-low';
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-2 text-slate-500 font-mono text-xs">${i + 1}</td>
|
||||
<td class="px-4 py-2"><a href="/drafts/${d.name}" class="text-blue-400 hover:text-blue-300 text-xs font-mono">${shortName}</a></td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">${d.pages !== null ? d.pages : '-'}</td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">${d.author_count}</td>
|
||||
<td class="px-4 py-2 text-right"><span class="score-badge ${scoreClass}">${d.score.toFixed(2)}</span></td>
|
||||
<td class="px-4 py-2 text-right text-green-400 font-mono text-xs">${d.efficiency.toFixed(1)}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
})();
|
||||
|
||||
// Category complexity bar chart
|
||||
(function() {
|
||||
const cats = cdata.category_complexity.slice(0, 12);
|
||||
const catNames = cats.map(c => c.category);
|
||||
Plotly.newPlot('catComplexity', [
|
||||
{
|
||||
y: catNames, x: cats.map(c => c.avg_pages),
|
||||
name: 'Avg Pages', type: 'bar', orientation: 'h',
|
||||
marker: { color: '#3b82f6', opacity: 0.8 },
|
||||
},
|
||||
{
|
||||
y: catNames, x: cats.map(c => c.avg_citations),
|
||||
name: 'Avg Citations', type: 'bar', orientation: 'h',
|
||||
marker: { color: '#22c55e', opacity: 0.8 },
|
||||
},
|
||||
{
|
||||
y: catNames, x: cats.map(c => c.avg_authors * 5),
|
||||
name: 'Avg Authors (x5)', type: 'bar', orientation: 'h',
|
||||
marker: { color: '#a855f7', opacity: 0.8 },
|
||||
},
|
||||
], {
|
||||
...PLOTLY_LAYOUT,
|
||||
barmode: 'group',
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Value' },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, autorange: 'reversed' },
|
||||
legend: { font: { size: 10, color: '#94a3b8' }, orientation: 'h', y: -0.15 },
|
||||
margin: { t: 10, r: 20, b: 60, l: 150 },
|
||||
}, CFG);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
215
src/webui/templates/false_positives.html
Normal file
215
src/webui/templates/false_positives.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "false_positives" %}
|
||||
|
||||
{% block title %}False Positive Profile — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">False Positive Profile</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Analysis of {{ data.count }} drafts flagged as false positives — documents that matched AI/agent search keywords but were determined not to be genuinely about AI agent infrastructure.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Panel -->
|
||||
<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-red-500 to-red-400"></div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">False Positives</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.count }}</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-yellow-500 to-yellow-400"></div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">% of All Drafts</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.pct_of_total }}%</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-blue-500 to-blue-400"></div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">% of Rated</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.pct_of_rated }}%</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-green-500 to-green-400"></div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Total Rated</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.total_rated }}</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">Total Drafts</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.total_drafts }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Box Plots: FP vs Non-FP -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Rating Distributions: FP vs Non-FP</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Box plots comparing each rating dimension between false positives (red) and genuine AI/agent drafts (blue). Shows what rating patterns distinguish false positives.</p>
|
||||
<div id="boxPlots" style="height: 400px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Source distribution -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">False Positives by Source</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Which standards bodies produce the most false positives in our search results.</p>
|
||||
<div id="sourcePie" style="height: 400px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Category distribution -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Categories Assigned to False Positives</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Categories that the classifier assigned to false positive drafts before they were flagged. Shows which categories are most prone to false matches.</p>
|
||||
<div id="catBar" style="height: 400px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Top Terms -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Top Terms in FP Abstracts</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Most frequent words in false positive titles and abstracts (stop words excluded). These terms trigger AI/agent keyword matches but appear in unrelated contexts.</p>
|
||||
<div id="termBar" style="height: 400px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FP Table -->
|
||||
<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">All False Positives</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Complete list of flagged drafts. Click a name to view details.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 text-left text-xs text-slate-500">
|
||||
<th class="px-4 py-3 font-medium">#</th>
|
||||
<th class="px-4 py-3 font-medium">Draft</th>
|
||||
<th class="px-4 py-3 font-medium">Title</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Source</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Relevance</th>
|
||||
<th class="px-4 py-3 font-medium">Categories</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for fp in data.fp_list %}
|
||||
<tr class="hover:bg-slate-800/50 transition">
|
||||
<td class="px-4 py-3 text-slate-500 font-mono text-xs">{{ loop.index }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/drafts/{{ fp.name }}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">{{ fp.name | replace('draft-', '') | truncate(40) }}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-300 text-xs max-w-xs truncate">{{ fp.title }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase bg-slate-800 text-slate-400">{{ fp.source }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="{% if fp.relevance >= 3 %}text-yellow-400{% else %}text-slate-500{% endif %}">{{ fp.relevance }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% for cat in fp.categories[:2] %}
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">{{ cat }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const PLOTLY_LAYOUT = {
|
||||
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
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 fpData = {{ data | tojson }};
|
||||
|
||||
// Box plots: FP vs Non-FP
|
||||
const dims = ['novelty', 'maturity', 'overlap', 'momentum', 'relevance'];
|
||||
const dimLabels = ['Novelty', 'Maturity', 'Overlap', 'Momentum', 'Relevance'];
|
||||
const boxTraces = [];
|
||||
dims.forEach((d, i) => {
|
||||
boxTraces.push({
|
||||
y: fpData.nonfp_dims[d], name: dimLabels[i] + ' (non-FP)',
|
||||
type: 'box', marker: { color: '#3b82f6' }, boxmean: true,
|
||||
legendgroup: dimLabels[i], showlegend: i === 0,
|
||||
});
|
||||
boxTraces.push({
|
||||
y: fpData.fp_dims[d], name: dimLabels[i] + ' (FP)',
|
||||
type: 'box', marker: { color: '#ef4444' }, boxmean: true,
|
||||
legendgroup: dimLabels[i], showlegend: i === 0,
|
||||
});
|
||||
});
|
||||
Plotly.newPlot('boxPlots', boxTraces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
boxmode: 'group',
|
||||
showlegend: true,
|
||||
legend: { font: { size: 10, color: '#94a3b8' }, orientation: 'h', y: 1.1 },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, range: [0.5, 5.5], dtick: 1, title: 'Rating' },
|
||||
margin: { t: 40, r: 10, b: 30, l: 50 },
|
||||
}, CFG);
|
||||
|
||||
// Source pie
|
||||
const srcLabels = Object.keys(fpData.fp_sources);
|
||||
const srcValues = Object.values(fpData.fp_sources);
|
||||
const srcColors = {
|
||||
'ietf': '#3b82f6', 'iso': '#22c55e', 'itu': '#eab308',
|
||||
'w3c': '#a855f7', 'etsi': '#ef4444', 'nist': '#06b6d4',
|
||||
};
|
||||
Plotly.newPlot('sourcePie', [{
|
||||
labels: srcLabels.map(s => s.toUpperCase()),
|
||||
values: srcValues,
|
||||
type: 'pie',
|
||||
marker: { colors: srcLabels.map(s => srcColors[s] || '#64748b') },
|
||||
textinfo: 'label+value',
|
||||
textfont: { color: '#e2e8f0', size: 12 },
|
||||
hovertemplate: '%{label}: %{value} drafts (%{percent})<extra></extra>',
|
||||
hole: 0.4,
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
showlegend: false,
|
||||
margin: { t: 10, r: 10, b: 10, l: 10 },
|
||||
}, CFG);
|
||||
|
||||
// Category bar
|
||||
const catLabels = Object.keys(fpData.fp_categories);
|
||||
const catValues = Object.values(fpData.fp_categories);
|
||||
Plotly.newPlot('catBar', [{
|
||||
x: catValues,
|
||||
y: catLabels,
|
||||
type: 'bar',
|
||||
orientation: 'h',
|
||||
marker: { color: '#ef4444', opacity: 0.7 },
|
||||
hovertemplate: '%{y}: %{x} drafts<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Draft Count' },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, autorange: 'reversed' },
|
||||
margin: { t: 10, r: 10, b: 40, l: 180 },
|
||||
}, CFG);
|
||||
|
||||
// Top terms bar
|
||||
const terms = fpData.top_terms;
|
||||
const termLabels = terms.map(t => t[0]);
|
||||
const termValues = terms.map(t => t[1]);
|
||||
Plotly.newPlot('termBar', [{
|
||||
x: termValues,
|
||||
y: termLabels,
|
||||
type: 'bar',
|
||||
orientation: 'h',
|
||||
marker: { color: '#f59e0b', opacity: 0.7 },
|
||||
hovertemplate: '%{y}: %{x} occurrences<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Occurrences' },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, autorange: 'reversed' },
|
||||
margin: { t: 10, r: 10, b: 40, l: 120 },
|
||||
}, CFG);
|
||||
</script>
|
||||
{% endblock %}
|
||||
330
src/webui/templates/idea_analysis.html
Normal file
330
src/webui/templates/idea_analysis.html
Normal file
@@ -0,0 +1,330 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "idea_analysis" %}
|
||||
|
||||
{% block title %}Idea Novelty Analysis — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Idea Novelty Deep Dive</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Comprehensive analysis of {{ data.total }} technical ideas extracted from IETF AI/agent drafts. Explores novelty distribution, type breakdowns, cross-draft patterns, and correlations with draft ratings.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats panel -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-3xl font-bold text-blue-400">{{ data.total }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Total Ideas</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-3xl font-bold text-purple-400">{{ data.type_count }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Idea Types</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-3xl font-bold text-green-400">{{ data.avg_novelty }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Avg Novelty Score</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-3xl font-bold text-amber-400">{{ data.scored }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Scored Ideas</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-3xl font-bold text-cyan-400">{{ data.embed_pct }}%</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Embeddings ({{ data.embed_count }}/{{ data.total }})</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-3xl font-bold text-rose-400">{{ data.shared_ideas | length }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Shared Ideas (2+ drafts)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1: Novelty histogram + Type bar chart -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Novelty Score Distribution</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">How many ideas at each novelty level (1=incremental, 5=groundbreaking). {{ data.unscored }} ideas have no novelty score yet.</p>
|
||||
<div id="noveltyHist" style="height: 300px;"></div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Ideas by Type (avg novelty color)</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Count of ideas per type. Bar color intensity reflects average novelty score — brighter = more novel.</p>
|
||||
<div id="typeChart" style="height: 300px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Scatter + Sunburst -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Draft Avg Idea Novelty vs Relevance</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Each dot is a draft. X-axis = average novelty of its ideas, Y-axis = relevance score. Bubble size = number of ideas. Click to view draft.</p>
|
||||
<div id="scatterChart" style="height: 380px;"></div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Idea Type Breakdown (Sunburst)</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Hierarchical view: outer ring shows novelty bands (High/Medium/Low) within each type.</p>
|
||||
<div id="sunburstChart" style="height: 380px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ideas per draft distribution -->
|
||||
<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">Ideas per Draft Distribution</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">How many ideas does each draft contribute? Most drafts have 2-4 ideas; some prolific drafts generate 8+.</p>
|
||||
<div id="ipdChart" style="height: 280px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Top 20 most novel ideas -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden mb-6">
|
||||
<div class="p-4 border-b border-slate-800">
|
||||
<h2 class="text-sm font-semibold text-slate-300">Top 20 Most Novel Ideas</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Ideas with novelty score of 4 or 5, sorted by novelty then draft composite score.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 text-left text-xs text-slate-500">
|
||||
<th class="px-4 py-3 font-medium">#</th>
|
||||
<th class="px-4 py-3 font-medium">Idea</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Novelty</th>
|
||||
<th class="px-4 py-3 font-medium">Type</th>
|
||||
<th class="px-4 py-3 font-medium">Draft</th>
|
||||
<th class="px-4 py-3 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for idea in data.top_novel %}
|
||||
<tr class="hover:bg-slate-800/50 transition">
|
||||
<td class="px-4 py-3 text-slate-500 font-mono text-xs">{{ loop.index }}</td>
|
||||
<td class="px-4 py-3 text-slate-200 text-xs font-medium max-w-[200px] truncate" title="{{ idea.title }}">{{ idea.title }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-mono {% if idea.novelty_score == 5 %}bg-green-500/20 text-green-400{% else %}bg-emerald-500/20 text-emerald-400{% endif %}">{{ idea.novelty_score }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] bg-blue-500/20 text-blue-400">{{ idea.type }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/drafts/{{ idea.draft_name }}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">{{ idea.draft_name | replace('draft-', '') | truncate(35) }}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500 max-w-[300px] truncate" title="{{ idea.description }}">{{ idea.description | truncate(120) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared ideas across drafts -->
|
||||
{% if data.shared_ideas %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden mb-6">
|
||||
<div class="p-4 border-b border-slate-800">
|
||||
<h2 class="text-sm font-semibold text-slate-300">Ideas Shared Across Multiple Drafts</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ data.shared_ideas | length }} ideas appear in 2 or more drafts, indicating convergent thinking or common building blocks.</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800/50 max-h-[500px] overflow-y-auto">
|
||||
{% for idea in data.shared_ideas[:30] %}
|
||||
<div class="px-4 py-3 hover:bg-slate-800/50 transition">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="text-sm font-medium text-slate-200">{{ idea.title }}</span>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-mono bg-amber-500/20 text-amber-400">{{ idea.appearances }}x</span>
|
||||
{% for t in idea.types %}
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-700 text-slate-400">{{ t }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{% for d in idea.drafts %}
|
||||
<a href="/drafts/{{ d }}" class="text-[10px] text-slate-600 hover:text-blue-400 transition font-mono">{{ d | replace('draft-', '') | truncate(30) }}</a>
|
||||
{% if not loop.last %}<span class="text-slate-700 text-[10px]">|</span>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Top idea-producing drafts -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden mb-6">
|
||||
<div class="p-4 border-b border-slate-800">
|
||||
<h2 class="text-sm font-semibold text-slate-300">Most Prolific Drafts (by idea count)</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 text-left text-xs text-slate-500">
|
||||
<th class="px-4 py-3 font-medium">#</th>
|
||||
<th class="px-4 py-3 font-medium">Draft</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Ideas</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for d in data.top_idea_drafts %}
|
||||
<tr class="hover:bg-slate-800/50 transition">
|
||||
<td class="px-4 py-3 text-slate-500 font-mono text-xs">{{ loop.index }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/drafts/{{ d.name }}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">{{ d.name | replace('draft-', '') | truncate(45) }}</a>
|
||||
{% if d.draft_title %}
|
||||
<div class="text-[10px] text-slate-600 mt-0.5">{{ d.draft_title | truncate(60) }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-slate-300 font-mono">{{ d.idea_count }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{% if d.score %}
|
||||
<span class="score-badge {% if d.score >= 3.5 %}score-high{% elif d.score >= 2.5 %}score-mid{% else %}score-low{% endif %}">{{ d.score | round(2) }}</span>
|
||||
{% else %}
|
||||
<span class="text-slate-600">--</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedding status note -->
|
||||
<div class="bg-slate-900/50 rounded-xl border border-slate-800 p-5 mb-6">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-2">Embedding Coverage</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="w-full bg-slate-800 rounded-full h-3">
|
||||
<div class="bg-blue-500 h-3 rounded-full transition-all" style="width: {{ data.embed_pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-slate-400">{{ data.embed_count }} / {{ data.total }} ({{ data.embed_pct }}%)</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-2">To complete missing embeddings, run: <code class="bg-slate-800 px-2 py-0.5 rounded text-slate-300">ietf embed-ideas</code>. This requires Ollama running locally. Embeddings enable idea similarity search and clustering.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const PLOTLY_LAYOUT = {
|
||||
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
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 data = {{ data | tojson }};
|
||||
|
||||
// --- Novelty Histogram ---
|
||||
Plotly.newPlot('noveltyHist', [{
|
||||
x: data.novelty_histogram.labels,
|
||||
y: data.novelty_histogram.values,
|
||||
type: 'bar',
|
||||
marker: {
|
||||
color: ['#ef4444', '#f97316', '#eab308', '#22c55e', '#10b981'],
|
||||
line: { color: '#0f172a', width: 1 },
|
||||
},
|
||||
text: data.novelty_histogram.values,
|
||||
textposition: 'outside',
|
||||
textfont: { color: '#94a3b8', size: 12 },
|
||||
hovertemplate: 'Novelty %{x}: %{y} ideas<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Novelty Score', dtick: 1 },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Count' },
|
||||
margin: { t: 30, r: 20, b: 50, l: 60 },
|
||||
}, CFG);
|
||||
|
||||
// --- Type Bar Chart (colored by avg novelty) ---
|
||||
const types = data.by_type.map(t => t.type).reverse();
|
||||
const typeCounts = data.by_type.map(t => t.count).reverse();
|
||||
const typeAvgN = data.by_type.map(t => t.avg_novelty).reverse();
|
||||
const typeColors = typeAvgN.map(n => {
|
||||
// Map avg novelty (1-5) to color intensity: red -> yellow -> green
|
||||
if (n >= 3.5) return '#22c55e';
|
||||
if (n >= 3.0) return '#84cc16';
|
||||
if (n >= 2.5) return '#eab308';
|
||||
if (n >= 2.0) return '#f97316';
|
||||
return '#ef4444';
|
||||
});
|
||||
|
||||
Plotly.newPlot('typeChart', [{
|
||||
y: types, x: typeCounts,
|
||||
type: 'bar', orientation: 'h',
|
||||
marker: { color: typeColors },
|
||||
text: typeAvgN.map(n => `avg N: ${n.toFixed(1)}`),
|
||||
textposition: 'auto',
|
||||
textfont: { color: '#e2e8f0', size: 10 },
|
||||
hovertemplate: '<b>%{y}</b><br>Count: %{x}<br>Avg Novelty: %{text}<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
margin: { t: 10, r: 20, b: 40, l: 120 },
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Count' },
|
||||
}, CFG);
|
||||
|
||||
// --- Scatter: draft avg idea novelty vs relevance ---
|
||||
const scatter = data.scatter_data;
|
||||
const sourceGroups = {};
|
||||
scatter.forEach(d => {
|
||||
if (!sourceGroups[d.source]) sourceGroups[d.source] = { x: [], y: [], size: [], text: [] };
|
||||
sourceGroups[d.source].x.push(d.avg_idea_novelty);
|
||||
sourceGroups[d.source].y.push(d.relevance);
|
||||
sourceGroups[d.source].size.push(Math.max(d.idea_count * 3, 6));
|
||||
sourceGroups[d.source].text.push(d.name);
|
||||
});
|
||||
|
||||
const scatterTraces = Object.entries(sourceGroups).map(([src, d]) => ({
|
||||
x: d.x, y: d.y, text: d.text, name: src,
|
||||
mode: 'markers', type: 'scatter',
|
||||
marker: { size: d.size, opacity: 0.7 },
|
||||
hovertemplate: '<b>%{text}</b><br>Avg Idea Novelty: %{x:.2f}<br>Relevance: %{y}<br>Ideas: %{marker.size:.0f}<extra>' + src + '</extra>',
|
||||
}));
|
||||
|
||||
Plotly.newPlot('scatterChart', scatterTraces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Avg Idea Novelty', range: [0.5, 5.5] },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Draft Relevance Score', range: [0.5, 5.5], dtick: 1 },
|
||||
legend: { font: { size: 10, color: '#94a3b8' } },
|
||||
hovermode: 'closest',
|
||||
margin: { t: 20, r: 20, b: 50, l: 60 },
|
||||
}, CFG);
|
||||
|
||||
document.getElementById('scatterChart').on('plotly_click', function(ev) {
|
||||
const pt = ev.points[0];
|
||||
if (pt.text) window.location.href = '/drafts/' + pt.text;
|
||||
});
|
||||
|
||||
// --- Sunburst ---
|
||||
const sb = data.sunburst;
|
||||
Plotly.newPlot('sunburstChart', [{
|
||||
type: 'sunburst',
|
||||
labels: sb.labels,
|
||||
parents: sb.parents,
|
||||
values: sb.values,
|
||||
branchvalues: 'total',
|
||||
textinfo: 'label+value',
|
||||
textfont: { size: 10, color: '#e2e8f0' },
|
||||
marker: {
|
||||
line: { width: 1, color: '#0f172a' },
|
||||
},
|
||||
insidetextorientation: 'radial',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
margin: { t: 10, r: 10, b: 10, l: 10 },
|
||||
}, CFG);
|
||||
|
||||
// --- Ideas per Draft histogram ---
|
||||
const ipd = data.ideas_per_draft_hist;
|
||||
Plotly.newPlot('ipdChart', [{
|
||||
x: ipd.labels,
|
||||
y: ipd.values,
|
||||
type: 'bar',
|
||||
marker: { color: '#6366f1', line: { color: '#4338ca', width: 1 } },
|
||||
text: ipd.values,
|
||||
textposition: 'outside',
|
||||
textfont: { color: '#94a3b8', size: 10 },
|
||||
hovertemplate: '%{x} ideas/draft: %{y} drafts<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Ideas per Draft', dtick: 1 },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Number of Drafts' },
|
||||
margin: { t: 30, r: 20, b: 50, l: 60 },
|
||||
}, CFG);
|
||||
</script>
|
||||
{% endblock %}
|
||||
198
src/webui/templates/sources.html
Normal file
198
src/webui/templates/sources.html
Normal file
@@ -0,0 +1,198 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "sources" %}
|
||||
|
||||
{% block title %}Cross-Source Comparison — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Cross-Source Comparison</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Comparing drafts across {{ data.summary | length }} standards bodies on rating dimensions, category focus, and output volume.</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary Table -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden mb-6">
|
||||
<div class="p-4 border-b border-slate-800">
|
||||
<h2 class="text-sm font-semibold text-slate-300">Standards Body Summary</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Overview of each source's contribution to the AI/agent standards landscape.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 text-left text-xs text-slate-500">
|
||||
<th class="px-4 py-3 font-medium">Source</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Drafts</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Rated</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Authors</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Ideas</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Avg Score</th>
|
||||
<th class="px-4 py-3 font-medium">Top Category</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for row in data.summary | sort(attribute='drafts', reverse=True) %}
|
||||
<tr class="hover:bg-slate-800/50 transition">
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-semibold uppercase
|
||||
{% if row.source == 'ietf' %}bg-blue-500/20 text-blue-400
|
||||
{% elif row.source == 'iso' %}bg-green-500/20 text-green-400
|
||||
{% elif row.source == 'itu' %}bg-yellow-500/20 text-yellow-400
|
||||
{% elif row.source == 'w3c' %}bg-purple-500/20 text-purple-400
|
||||
{% elif row.source == 'etsi' %}bg-red-500/20 text-red-400
|
||||
{% elif row.source == 'nist' %}bg-cyan-500/20 text-cyan-400
|
||||
{% else %}bg-slate-500/20 text-slate-400{% endif %}">{{ row.source }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-white font-mono">{{ row.drafts }}</td>
|
||||
<td class="px-4 py-3 text-center text-slate-400 font-mono">{{ row.rated }}</td>
|
||||
<td class="px-4 py-3 text-center text-slate-400 font-mono">{{ row.authors }}</td>
|
||||
<td class="px-4 py-3 text-center text-slate-400 font-mono">{{ row.ideas }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{% if row.avg_score >= 3.5 %}
|
||||
<span class="score-badge score-high">{{ row.avg_score }}</span>
|
||||
{% elif row.avg_score >= 2.5 %}
|
||||
<span class="score-badge score-mid">{{ row.avg_score }}</span>
|
||||
{% else %}
|
||||
<span class="score-badge score-low">{{ row.avg_score }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">{{ row.top_category }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Radar 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">Rating Dimensions by Source</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Average rating across five dimensions for each standards body. Larger shapes indicate higher average ratings.</p>
|
||||
<div id="radarChart" style="height: 400px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Stacked 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">Category Distribution by Source</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">What topics each standards body focuses on. Stacked bars show the relative emphasis per source.</p>
|
||||
<div id="stackedBar" style="height: 400px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap -->
|
||||
<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">Sources x Categories Heatmap</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Draft counts per source-category pair. Darker cells = more drafts. Shows where each body concentrates its work.</p>
|
||||
<div id="heatmap" style="height: 400px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Unique/Shared Categories -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Shared Categories</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Categories covered by multiple standards bodies.</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for cat in data.shared_categories %}
|
||||
<a href="/drafts?cat={{ cat }}" class="px-2 py-1 rounded text-xs bg-blue-500/15 text-blue-400 border border-blue-500/20 hover:bg-blue-500/25 transition">{{ cat }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-3">Unique Categories by Source</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Categories only covered by a single standards body.</p>
|
||||
{% for src, cats in data.unique_categories.items() %}
|
||||
{% if cats %}
|
||||
<div class="mb-3">
|
||||
<span class="text-xs font-semibold uppercase text-slate-500">{{ src }}</span>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{% for cat in cats %}
|
||||
<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">{{ cat }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const PLOTLY_LAYOUT = {
|
||||
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
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 data = {{ data | tojson }};
|
||||
|
||||
// Source colors
|
||||
const sourceColors = {
|
||||
'ietf': '#3b82f6', 'iso': '#22c55e', 'itu': '#eab308',
|
||||
'w3c': '#a855f7', 'etsi': '#ef4444', 'nist': '#06b6d4',
|
||||
};
|
||||
function srcColor(src) { return sourceColors[src] || '#64748b'; }
|
||||
|
||||
// Radar Chart
|
||||
const radarDims = ['novelty', 'maturity', 'overlap', 'momentum', 'relevance'];
|
||||
const radarLabels = ['Novelty', 'Maturity', 'Overlap', 'Momentum', 'Relevance'];
|
||||
const radarTraces = Object.entries(data.radar).map(([src, vals]) => ({
|
||||
type: 'scatterpolar',
|
||||
r: radarDims.map(d => vals[d]).concat([vals[radarDims[0]]]),
|
||||
theta: radarLabels.concat([radarLabels[0]]),
|
||||
fill: 'toself',
|
||||
name: `${src.toUpperCase()} (${vals.count})`,
|
||||
opacity: 0.45,
|
||||
line: { color: srcColor(src) },
|
||||
fillcolor: srcColor(src) + '33',
|
||||
}));
|
||||
Plotly.newPlot('radarChart', radarTraces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
polar: {
|
||||
bgcolor: 'rgba(0,0,0,0)',
|
||||
radialaxis: { visible: true, range: [0, 5], gridcolor: '#1e293b', color: '#64748b' },
|
||||
angularaxis: { gridcolor: '#1e293b', color: '#94a3b8' },
|
||||
},
|
||||
legend: { font: { size: 10, color: '#94a3b8' } },
|
||||
margin: { t: 30, r: 60, b: 30, l: 60 },
|
||||
}, CFG);
|
||||
|
||||
// Stacked Bar Chart
|
||||
const heatmap = data.heatmap;
|
||||
const barTraces = heatmap.categories.map((cat, ci) => ({
|
||||
name: cat,
|
||||
type: 'bar',
|
||||
x: heatmap.sources.map(s => s.toUpperCase()),
|
||||
y: heatmap.values.map(row => row[ci]),
|
||||
}));
|
||||
Plotly.newPlot('stackedBar', barTraces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
barmode: 'stack',
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: '' },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Draft Count' },
|
||||
legend: { font: { size: 9, color: '#94a3b8' }, orientation: 'h', y: -0.25 },
|
||||
margin: { t: 10, r: 10, b: 80, l: 50 },
|
||||
}, CFG);
|
||||
|
||||
// Heatmap
|
||||
Plotly.newPlot('heatmap', [{
|
||||
z: heatmap.values,
|
||||
x: heatmap.categories,
|
||||
y: heatmap.sources.map(s => s.toUpperCase()),
|
||||
type: 'heatmap',
|
||||
colorscale: [[0, '#0f172a'], [0.5, '#1e40af'], [1, '#3b82f6']],
|
||||
hovertemplate: '%{y} / %{x}<br>Drafts: %{z}<extra></extra>',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, tickangle: -45 },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, autorange: 'reversed' },
|
||||
margin: { t: 10, r: 10, b: 120, l: 60 },
|
||||
}, CFG);
|
||||
</script>
|
||||
{% endblock %}
|
||||
284
src/webui/templates/trends_analysis.html
Normal file
284
src/webui/templates/trends_analysis.html
Normal file
@@ -0,0 +1,284 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "trends" %}
|
||||
|
||||
{% block title %}Trends — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}<script src="/static/js/plotly.min.js"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Temporal Evolution</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">How the AI standards landscape is changing over time. Submission volume, rating trends, category shifts, and the safety-vs-capability balance.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Months Tracked</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.months | length }}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Total Submissions</div>
|
||||
<div class="text-2xl font-bold text-white mt-1">{{ data.monthly_table | sum(attribute='total') }}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Fastest Growing</div>
|
||||
<div class="text-lg font-bold text-green-400 mt-1">{{ data.stats.fastest_growing or 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wider">Newest Category</div>
|
||||
<div class="text-lg font-bold text-blue-400 mt-1">{{ data.stats.newest_active or 'N/A' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Submissions by Source -->
|
||||
<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">Monthly Draft Submissions by Source</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Stacked area chart showing submission volume over time, broken down by source (IETF, W3C, etc.).</p>
|
||||
<div id="submissionChart" style="height: 350px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Monthly Average Ratings -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Monthly Average Ratings</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Are drafts getting more mature? More novel? Track the five rating dimensions over time.</p>
|
||||
<div id="ratingsChart" style="height: 350px;"></div>
|
||||
</div>
|
||||
<!-- Safety Ratio -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Safety vs Capability Ratio</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Ratio of safety-related drafts (Security, Privacy, Trust, Safety, Governance) to capability drafts (Agents, Infrastructure, MCP, etc.). Higher = more safety focus.</p>
|
||||
<div id="safetyChart" style="height: 350px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Distribution Over Time -->
|
||||
<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">Category Distribution Over Time</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Stacked area showing which topics are growing or shrinking. Top 8 categories shown.</p>
|
||||
<div id="categoryChart" style="height: 400px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Cumulative Ideas -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Cumulative Idea Count</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Total number of unique technical ideas extracted from drafts over time.</p>
|
||||
<div id="ideasChart" style="height: 300px;"></div>
|
||||
</div>
|
||||
<!-- New Authors -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Monthly New Authors</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">First-time contributors entering the AI standards space each month.</p>
|
||||
<div id="authorsChart" style="height: 300px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Breakdown Table -->
|
||||
<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">Monthly Breakdown</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-800 text-left text-xs text-slate-500">
|
||||
<th class="px-4 py-3 font-medium">Month</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Total</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Avg Score</th>
|
||||
<th class="px-4 py-3 font-medium">Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/50">
|
||||
{% for row in data.monthly_table %}
|
||||
<tr class="hover:bg-slate-800/50 transition">
|
||||
<td class="px-4 py-2 font-mono text-xs text-slate-300">{{ row.month }}</td>
|
||||
<td class="px-4 py-2 text-right text-slate-300">{{ row.total }}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
{% if row.avg_score >= 3.0 %}
|
||||
<span class="text-green-400">{{ row.avg_score }}</span>
|
||||
{% elif row.avg_score >= 2.0 %}
|
||||
<span class="text-yellow-400">{{ row.avg_score }}</span>
|
||||
{% else %}
|
||||
<span class="text-slate-500">{{ row.avg_score }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
{% if loop.index > 1 %}
|
||||
{% set prev = data.monthly_table[loop.index0 - 1].total %}
|
||||
{% if row.total > prev %}
|
||||
<span class="text-green-400 text-xs font-mono">↑ +{{ row.total - prev }}</span>
|
||||
{% elif row.total < prev %}
|
||||
<span class="text-red-400 text-xs font-mono">↓ {{ row.total - prev }}</span>
|
||||
{% else %}
|
||||
<span class="text-slate-500 text-xs font-mono">→ 0</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const PLOTLY_LAYOUT = {
|
||||
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
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 data = {{ data | tojson }};
|
||||
|
||||
// 1. Monthly submissions by source (stacked area)
|
||||
(function() {
|
||||
const bySource = {};
|
||||
data.monthly_submissions.forEach(r => {
|
||||
if (!bySource[r.source]) bySource[r.source] = {};
|
||||
bySource[r.source][r.month] = r.count;
|
||||
});
|
||||
const months = data.months;
|
||||
const colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#a855f7', '#06b6d4'];
|
||||
const traces = Object.entries(bySource).map(([src, monthly], i) => ({
|
||||
x: months,
|
||||
y: months.map(m => monthly[m] || 0),
|
||||
name: src.toUpperCase(),
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
stackgroup: 'one',
|
||||
line: { color: colors[i % colors.length], width: 0 },
|
||||
fillcolor: colors[i % colors.length] + '80',
|
||||
}));
|
||||
Plotly.newPlot('submissionChart', traces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Month' },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Draft Count' },
|
||||
legend: { font: { size: 10, color: '#94a3b8' } },
|
||||
}, CFG);
|
||||
})();
|
||||
|
||||
// 2. Monthly average ratings (5 lines)
|
||||
(function() {
|
||||
const dims = ['novelty', 'maturity', 'overlap', 'momentum', 'relevance'];
|
||||
const labels = ['Novelty', 'Maturity', 'Overlap', 'Momentum', 'Relevance'];
|
||||
const colors = ['#3b82f6', '#22c55e', '#ef4444', '#f59e0b', '#a855f7'];
|
||||
const months = data.monthly_ratings.map(r => r.month);
|
||||
const traces = dims.map((d, i) => ({
|
||||
x: months,
|
||||
y: data.monthly_ratings.map(r => r[d]),
|
||||
name: labels[i],
|
||||
type: 'scatter',
|
||||
mode: 'lines+markers',
|
||||
line: { color: colors[i], width: 2 },
|
||||
marker: { size: 4 },
|
||||
}));
|
||||
Plotly.newPlot('ratingsChart', traces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Avg Rating (1-5)', range: [0.5, 5.5] },
|
||||
legend: { font: { size: 10, color: '#94a3b8' } },
|
||||
}, CFG);
|
||||
})();
|
||||
|
||||
// 3. Safety ratio
|
||||
(function() {
|
||||
const months = data.safety_ratio.map(r => r.month);
|
||||
Plotly.newPlot('safetyChart', [
|
||||
{
|
||||
x: months,
|
||||
y: data.safety_ratio.map(r => r.ratio),
|
||||
type: 'scatter',
|
||||
mode: 'lines+markers',
|
||||
line: { color: '#22c55e', width: 2 },
|
||||
marker: { size: 5 },
|
||||
name: 'Safety/Capability Ratio',
|
||||
hovertemplate: '%{x}<br>Ratio: %{y:.2f}<br>Safety: %{customdata[0]}<br>Capability: %{customdata[1]}<extra></extra>',
|
||||
customdata: data.safety_ratio.map(r => [r.safety, r.capability]),
|
||||
},
|
||||
{
|
||||
x: months,
|
||||
y: months.map(() => 1.0),
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
line: { color: '#64748b', width: 1, dash: 'dash' },
|
||||
name: 'Parity line',
|
||||
showlegend: false,
|
||||
}
|
||||
], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Ratio (safety / capability)' },
|
||||
showlegend: false,
|
||||
}, CFG);
|
||||
})();
|
||||
|
||||
// 4. Category distribution (stacked area)
|
||||
(function() {
|
||||
const months = data.months;
|
||||
const cats = data.top_categories;
|
||||
const colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#a855f7', '#06b6d4', '#f97316', '#ec4899'];
|
||||
// Build per-cat monthly data
|
||||
const catMonthly = {};
|
||||
data.monthly_categories.forEach(r => {
|
||||
if (!catMonthly[r.category]) catMonthly[r.category] = {};
|
||||
catMonthly[r.category][r.month] = r.count;
|
||||
});
|
||||
const traces = cats.map((cat, i) => ({
|
||||
x: months,
|
||||
y: months.map(m => (catMonthly[cat] || {})[m] || 0),
|
||||
name: cat,
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
stackgroup: 'one',
|
||||
line: { color: colors[i % colors.length], width: 0 },
|
||||
fillcolor: colors[i % colors.length] + '80',
|
||||
}));
|
||||
Plotly.newPlot('categoryChart', traces, {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: 'Month' },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Draft Count' },
|
||||
legend: { font: { size: 10, color: '#94a3b8' } },
|
||||
}, CFG);
|
||||
})();
|
||||
|
||||
// 5. Cumulative ideas
|
||||
(function() {
|
||||
Plotly.newPlot('ideasChart', [{
|
||||
x: data.cumulative_ideas.map(r => r.month),
|
||||
y: data.cumulative_ideas.map(r => r.total),
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
fill: 'tozeroy',
|
||||
line: { color: '#3b82f6', width: 2 },
|
||||
fillcolor: 'rgba(59,130,246,0.15)',
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'Total Ideas' },
|
||||
showlegend: false,
|
||||
}, CFG);
|
||||
})();
|
||||
|
||||
// 6. New authors per month
|
||||
(function() {
|
||||
Plotly.newPlot('authorsChart', [{
|
||||
x: data.monthly_new_authors.map(r => r.month),
|
||||
y: data.monthly_new_authors.map(r => r.count),
|
||||
type: 'bar',
|
||||
marker: { color: '#a855f7', opacity: 0.8 },
|
||||
}], {
|
||||
...PLOTLY_LAYOUT,
|
||||
xaxis: { ...PLOTLY_LAYOUT.xaxis },
|
||||
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: 'New Authors' },
|
||||
showlegend: false,
|
||||
}, CFG);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user