Files
ietf-draft-analyzer/src/webui/templates/timeline.html
Christian Nennemann e166091388 fix(webui): timeline opens on full landscape, drop undated points
After the chronological-order fix the page opened on the earliest month
(1995-12), showing only a handful of same-colored dots while the legend
listed every category — looked broken.

- Initialise the plot on the LAST frame (full landscape); Play now replays
  the build-up from the start (fromcurrent: false), slider starts at the end.
- Make _extract_month robust: year-only / junk dates (ISO/ETSI 'time' like
  '2015/CD Amd 2', bare '2023', '') no longer yield malformed month labels
  ('2015-/C') or a garbled 'unknown' frame badge.
- Drop undated docs from the temporal animation (they remain on /landscape).

At the full initial frame every category has points, so the legend matches
what is drawn.
2026-05-23 11:52:13 +02:00

264 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% set active_page = "timeline" %}
{% block title %}Timeline — 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">Timeline Animation</h1>
<p class="text-slate-400 text-sm mt-1">Watch the AI/agent draft landscape evolve month by month</p>
</div>
<!-- Stats summary -->
<div class="grid grid-cols-3 gap-4 mb-6" id="statCards">
</div>
<!-- Animated t-SNE map -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6" id="tsneSection">
<h2 class="text-sm font-semibold text-slate-300 mb-1">Animated Embedding Landscape</h2>
<p class="text-xs text-slate-500 mb-3">t-SNE projection with cumulative drafts per month. Color = category, size = composite score. Press Play to animate.</p>
<div id="monthBadge" class="text-center mb-2">
<span class="inline-block bg-slate-800 border border-slate-700 rounded-lg px-4 py-1.5 text-sm font-mono text-blue-400"></span>
</div>
<div id="tsneAnim" style="height: 560px;"></div>
</div>
<!-- Stacked area 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">Category Submissions Over Time</h2>
<p class="text-xs text-slate-500 mb-3">Stacked area chart showing draft submissions by category per month.</p>
<div id="stackedArea" style="height: 400px;"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
const PLOTLY_LAYOUT = {
paper_bgcolor: 'transparent', plot_bgcolor: 'rgba(15,23,42,0.5)',
font: { color: '#94a3b8', family: 'Inter, system-ui, sans-serif', size: 12 },
margin: { t: 20, r: 20, b: 50, l: 50 },
xaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155' },
};
const CFG = { responsive: true, displayModeBar: false };
const PALETTE = [
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
];
const animData = {{ animation | tojson }};
const points = animData.points;
const months = animData.months;
const catMonthly = animData.category_monthly;
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtMonth(ym) {
if (!ym) return ym;
let y, m;
if (ym.includes('-')) {
[y, m] = ym.split('-');
} else if (ym.length >= 6) {
y = ym.slice(0, 4);
m = ym.slice(4, 6);
} else {
return ym;
}
const mi = parseInt(m, 10) - 1;
return (MONTH_NAMES[mi] || m) + ' ' + y;
}
if (points.length > 0 && months.length > 0) {
// --- Stat cards ---
const firstMonth = months[0];
const lastMonth = months[months.length - 1];
const allCats = [...new Set(points.map(p => p.category))];
document.getElementById('statCards').innerHTML = `
<div class="stat-card rounded-xl border border-slate-800 p-5 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-3xl font-bold text-blue-400">${months.length}</div>
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Months Span</div>
<div class="text-xs text-slate-500 mt-0.5">${fmtMonth(firstMonth)} ${fmtMonth(lastMonth)}</div>
</div>
<div class="stat-card rounded-xl border border-slate-800 p-5 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-3xl font-bold text-emerald-400">${points.length}</div>
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Total Drafts</div>
</div>
<div class="stat-card rounded-xl border border-slate-800 p-5 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-3xl font-bold text-purple-400">${allCats.length}</div>
<div class="text-xs text-slate-400 mt-1 uppercase tracking-wider">Categories</div>
</div>
`;
// --- Build category list sorted by frequency ---
const catCounts = {};
points.forEach(p => { catCounts[p.category] = (catCounts[p.category] || 0) + 1; });
const catList = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a]);
const catColor = {};
catList.forEach((c, i) => { catColor[c] = PALETTE[i % PALETTE.length]; });
// --- Helper: build traces for points up to a given month ---
function buildTraces(upToMonth) {
const filtered = points.filter(p => p.month <= upToMonth);
const groups = {};
filtered.forEach(p => {
if (!groups[p.category]) groups[p.category] = { x: [], y: [], size: [], text: [], names: [] };
groups[p.category].x.push(p.x);
groups[p.category].y.push(p.y);
groups[p.category].size.push(Math.max(p.score * 4, 6));
groups[p.category].text.push(p.title);
groups[p.category].names.push(p.name);
});
return catList.map(cat => {
const g = groups[cat] || { x: [], y: [], size: [], text: [], names: [] };
return {
x: g.x, y: g.y, text: g.text, name: cat,
customdata: g.names,
mode: 'markers', type: 'scatter',
marker: {
size: g.size,
color: catColor[cat],
opacity: 0.8,
line: { width: 0.5, color: 'rgba(255,255,255,0.15)' },
},
hovertemplate: '<b>%{text}</b><extra>' + cat + '</extra>',
};
});
}
// --- Build frames ---
const frames = months.map(month => {
const cumCount = points.filter(p => p.month <= month).length;
return {
name: month,
data: buildTraces(month),
};
});
// --- Initial plot: show the FULL landscape (last frame) so the page
// doesn't open on the near-empty earliest month. Play replays from
// the start to animate the build-up. ---
const initMonth = months[months.length - 1];
const initTraces = buildTraces(initMonth);
const initCount = points.length;
// Slider steps
const sliderSteps = months.map(month => ({
method: 'animate',
label: fmtMonth(month),
args: [[month], { frame: { duration: 500, redraw: true }, transition: { duration: 300 }, mode: 'immediate' }],
}));
const layout = {
...PLOTLY_LAYOUT,
xaxis: { visible: false, showgrid: false, zeroline: false },
yaxis: { visible: false, showgrid: false, zeroline: false },
legend: { font: { size: 10, color: '#94a3b8' }, bgcolor: 'transparent' },
hovermode: 'closest',
margin: { t: 40, r: 20, b: 60, l: 20 },
updatemenus: [{
type: 'buttons', showactive: false, x: 0.05, y: 1.08,
buttons: [
{
label: '&#9654; Play',
method: 'animate',
args: [null, { frame: { duration: 500, redraw: true }, transition: { duration: 300 }, fromcurrent: false }]
},
{
label: '&#9724; Pause',
method: 'animate',
args: [[null], { frame: { duration: 0, redraw: true }, mode: 'immediate' }]
}
]
}],
sliders: [{
active: months.length - 1,
steps: sliderSteps,
x: 0.05, len: 0.9,
xanchor: 'left',
y: -0.02,
yanchor: 'top',
pad: { t: 30, b: 10 },
currentvalue: { visible: false },
transition: { duration: 300 },
font: { size: 9, color: '#64748b' },
bgcolor: '#1e293b',
activebgcolor: '#3b82f6',
bordercolor: '#334155',
borderwidth: 1,
ticklen: 4,
tickcolor: '#475569',
}],
};
Plotly.newPlot('tsneAnim', initTraces, layout, CFG).then(() => {
Plotly.addFrames('tsneAnim', frames);
});
// Update badge on animation frame
const badge = document.querySelector('#monthBadge span');
badge.textContent = `${fmtMonth(initMonth)}${initCount} drafts`;
document.getElementById('tsneAnim').on('plotly_animatingframe', function(ev) {
const month = ev.name;
const cumCount = points.filter(p => p.month <= month).length;
badge.textContent = `${fmtMonth(month)}${cumCount} drafts`;
});
// Click to navigate
document.getElementById('tsneAnim').on('plotly_click', function(data) {
const pt = data.points[0];
if (pt.customdata) {
window.location.href = '/drafts/' + pt.customdata;
}
});
// --- Stacked area chart ---
// Collect all categories across all months
const areaCats = {};
Object.values(catMonthly).forEach(mc => {
Object.keys(mc).forEach(c => { areaCats[c] = true; });
});
// Sort by total count
const areaCatList = Object.keys(areaCats).sort((a, b) => {
const totalA = months.reduce((s, m) => s + ((catMonthly[m] || {})[a] || 0), 0);
const totalB = months.reduce((s, m) => s + ((catMonthly[m] || {})[b] || 0), 0);
return totalB - totalA;
});
const monthLabels = months.map(fmtMonth);
const areaTraces = areaCatList.map((cat, i) => ({
x: monthLabels,
y: months.map(m => (catMonthly[m] || {})[cat] || 0),
name: cat,
type: 'scatter',
mode: 'lines',
stackgroup: 'one',
line: { width: 0.5, color: catColor[cat] || PALETTE[i % PALETTE.length] },
fillcolor: (catColor[cat] || PALETTE[i % PALETTE.length]) + '80',
hovertemplate: '%{x}<br>' + cat + ': %{y}<extra></extra>',
}));
Plotly.newPlot('stackedArea', areaTraces, {
...PLOTLY_LAYOUT,
xaxis: { ...PLOTLY_LAYOUT.xaxis, title: { text: 'Month', font: { size: 11 } } },
yaxis: { ...PLOTLY_LAYOUT.yaxis, title: { text: 'Drafts', font: { size: 11 } } },
legend: { font: { size: 10, color: '#94a3b8' }, orientation: 'h', y: -0.25, x: 0.5, xanchor: 'center' },
hovermode: 'x unified',
margin: { t: 20, r: 20, b: 80, l: 50 },
}, CFG);
} else {
document.getElementById('tsneSection').innerHTML = '<p class="text-slate-500 text-sm text-center py-20">No timeline animation data available. Run the analysis pipeline first.</p>';
document.getElementById('stackedArea').innerHTML = '<p class="text-slate-500 text-sm text-center py-20">No data available.</p>';
document.getElementById('statCards').style.display = 'none';
}
</script>
{% endblock %}