| from __future__ import annotations | |
| from typing import Any | |
| def _is_known_time(value: Any) -> bool: | |
| return bool(value) and str(value).strip().lower() not in {"unknown", "none", "nan", "nat"} | |
| def _stage_order(row: dict[str, Any], fallback_index: int = 0) -> int: | |
| value = row.get("stage_index", row.get("stage_order", fallback_index)) | |
| try: | |
| return int(value) | |
| except (TypeError, ValueError): | |
| return fallback_index | |
| def _stage_label(row: dict[str, Any]) -> str: | |
| for field in ("stage_title", "stage_label_public", "stage_label", "stage_id"): | |
| value = str(row.get(field) or "").strip() | |
| if value: | |
| return value | |
| return "Unnamed stage" | |
| def prepare_gantt_rows(stage_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| ordered = sorted( | |
| stage_rows, | |
| key=lambda row: (_stage_order(row), str(row.get("stage_id", ""))), | |
| ) | |
| calendar_axis = all( | |
| _is_known_time(row.get("stage_start_time")) and _is_known_time(row.get("stage_end_time")) | |
| for row in ordered | |
| ) | |
| prepared: list[dict[str, Any]] = [] | |
| for fallback_index, row in enumerate(ordered, start=1): | |
| start = row.get("stage_start_time") | |
| end = row.get("stage_end_time") | |
| stage_order = _stage_order(row, fallback_index) | |
| if calendar_axis: | |
| display_start = start | |
| display_end = end | |
| axis_mode = "calendar" | |
| else: | |
| display_start = stage_order | |
| display_end = display_start + 0.85 | |
| axis_mode = "relative_order" | |
| time_note = row.get("temporal_anchor_summary") or "" | |
| if not time_note and int(row.get("known_action_time_anchor_count") or 0) > 0: | |
| time_note = "Action-level time anchors available" | |
| prepared.append( | |
| { | |
| **row, | |
| "stage_label": _stage_label(row), | |
| "stage_order": stage_order, | |
| "display_start": display_start, | |
| "display_end": display_end, | |
| "axis_mode": axis_mode, | |
| "time_note": time_note, | |
| } | |
| ) | |
| return prepared | |
| def build_timeline_figure(stage_rows: list[dict[str, Any]], event_id: str): | |
| import pandas as pd | |
| import plotly.express as px | |
| prepared = prepare_gantt_rows(stage_rows) | |
| if not prepared: | |
| return None | |
| frame = pd.DataFrame(prepared) | |
| if set(frame["axis_mode"]) == {"calendar"}: | |
| fig = px.timeline( | |
| frame, | |
| x_start="display_start", | |
| x_end="display_end", | |
| y="stage_label", | |
| color="stage_label", | |
| hover_data=["stage_id", "stage_order", "time_note"], | |
| title=f"{event_id}: public stage timeline", | |
| ) | |
| fig.update_yaxes(autorange="reversed") | |
| return fig | |
| fig = px.bar( | |
| frame, | |
| x=[row["display_end"] - row["display_start"] for row in prepared], | |
| y="stage_label", | |
| base="display_start", | |
| orientation="h", | |
| color="stage_label", | |
| hover_data=["stage_id", "stage_order", "time_note"], | |
| title=f"{event_id}: relative stage order", | |
| ) | |
| fig.update_yaxes(autorange="reversed") | |
| fig.update_layout(xaxis_title="Relative stage order") | |
| return fig | |