fix(webui): timeline opens on full landscape, drop undated points
Some checks failed
CI / test (3.11) (push) Failing after 9s
CI / test (3.12) (push) Failing after 9s

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.
This commit is contained in:
2026-05-23 11:52:13 +02:00
parent c3af38e0f9
commit d11e980a6a
3 changed files with 21 additions and 13 deletions

View File

@@ -20,14 +20,17 @@ _CACHE_TTL = 300 # 5 minutes
def _extract_month(time_str: str | None) -> str: def _extract_month(time_str: str | None) -> str:
"""Normalize a date string to YYYY-MM format.""" """Normalize a date string to YYYY-MM (or "unknown" if unparseable)."""
if not time_str: if not time_str:
return "unknown" return "unknown"
if len(time_str) >= 7 and time_str[4] == '-': s = time_str.strip()
return time_str[:7] # Already YYYY-MM-DD if len(s) >= 7 and s[4] == '-' and s[5:7].isdigit():
if len(time_str) >= 6 and time_str[:4].isdigit(): return s[:7] # ISO 8601: YYYY-MM-DD...
return time_str[:4] + '-' + time_str[4:6] # YYYYMMDD → YYYY-MM if len(s) >= 6 and s[:6].isdigit():
return time_str[:7] return s[:4] + '-' + s[4:6] # Compact: YYYYMMDD → YYYY-MM
if len(s) >= 4 and s[:4].isdigit():
return s[:4] + '-01' # Year-only / year-prefixed junk (e.g. "2015/CD Amd 2")
return "unknown"
def _cached(key: str, fn, ttl: float = _CACHE_TTL): def _cached(key: str, fn, ttl: float = _CACHE_TTL):
"""Return cached result or compute and cache it.""" """Return cached result or compute and cache it."""

View File

@@ -546,6 +546,8 @@ def _compute_timeline_animation_data(db: Database) -> dict:
r = rating_map[name] r = rating_map[name]
d = draft_map.get(name) d = draft_map.get(name)
month = _extract_month(d.time if d else None) month = _extract_month(d.time if d else None)
if month == "unknown":
continue # Undated docs (e.g. ISO/ETSI) can't be placed on a temporal animation
cat = r.categories[0] if r.categories else "Other" cat = r.categories[0] if r.categories else "Other"
month_set.add(month) month_set.add(month)
category_monthly[month][cat] += 1 category_monthly[month][cat] += 1

View File

@@ -141,9 +141,12 @@ if (points.length > 0 && months.length > 0) {
}; };
}); });
// --- Initial plot (first month) --- // --- Initial plot: show the FULL landscape (last frame) so the page
const firstTraces = buildTraces(months[0]); // doesn't open on the near-empty earliest month. Play replays from
const firstCount = points.filter(p => p.month <= months[0]).length; // the start to animate the build-up. ---
const initMonth = months[months.length - 1];
const initTraces = buildTraces(initMonth);
const initCount = points.length;
// Slider steps // Slider steps
const sliderSteps = months.map(month => ({ const sliderSteps = months.map(month => ({
@@ -165,7 +168,7 @@ if (points.length > 0 && months.length > 0) {
{ {
label: '&#9654; Play', label: '&#9654; Play',
method: 'animate', method: 'animate',
args: [null, { frame: { duration: 500, redraw: true }, transition: { duration: 300 }, fromcurrent: true }] args: [null, { frame: { duration: 500, redraw: true }, transition: { duration: 300 }, fromcurrent: false }]
}, },
{ {
label: '&#9724; Pause', label: '&#9724; Pause',
@@ -175,7 +178,7 @@ if (points.length > 0 && months.length > 0) {
] ]
}], }],
sliders: [{ sliders: [{
active: 0, active: months.length - 1,
steps: sliderSteps, steps: sliderSteps,
x: 0.05, len: 0.9, x: 0.05, len: 0.9,
xanchor: 'left', xanchor: 'left',
@@ -194,13 +197,13 @@ if (points.length > 0 && months.length > 0) {
}], }],
}; };
Plotly.newPlot('tsneAnim', firstTraces, layout, CFG).then(() => { Plotly.newPlot('tsneAnim', initTraces, layout, CFG).then(() => {
Plotly.addFrames('tsneAnim', frames); Plotly.addFrames('tsneAnim', frames);
}); });
// Update badge on animation frame // Update badge on animation frame
const badge = document.querySelector('#monthBadge span'); const badge = document.querySelector('#monthBadge span');
badge.textContent = `${fmtMonth(months[0])}${firstCount} drafts`; badge.textContent = `${fmtMonth(initMonth)}${initCount} drafts`;
document.getElementById('tsneAnim').on('plotly_animatingframe', function(ev) { document.getElementById('tsneAnim').on('plotly_animatingframe', function(ev) {
const month = ev.name; const month = ev.name;