Spaces:
Sleeping
Sleeping
Improve H2EPR Explorer event navigation and timeline UX
Browse files- README.md +1 -1
- app.py +112 -11
- src/h2epr_explorer/constants.py +4 -3
- src/h2epr_explorer/navigation.py +47 -0
- src/h2epr_explorer/render_gantt.py +33 -8
README.md
CHANGED
|
@@ -9,7 +9,7 @@ license: cc-by-nc-4.0
|
|
| 9 |
|
| 10 |
# H²EPR-Bench Explorer
|
| 11 |
|
| 12 |
-
H²EPR-Bench Explorer is the
|
| 13 |
|
| 14 |
**Release boundary:** public records are intended for browsing, reuse, and presentation. Official scoring uses the [manual-gated Gold companion](https://huggingface.co/datasets/AgenticFinLab/H2EPR-Bench-Gold). Public FinalCascade and Gantt views are supplementary inspection assets, not official scoring references.
|
| 15 |
|
|
|
|
| 9 |
|
| 10 |
# H²EPR-Bench Explorer
|
| 11 |
|
| 12 |
+
H²EPR-Bench Explorer is the interactive browsing layer for `AgenticFinLab/H2EPR-Bench`. It is separate from the canonical dataset repository: the dataset repo remains the release package, while this Docker Space runs a Streamlit app for search, event detail, stage inspection, public FinalCascade JSON browsing, and Gantt-style timelines.
|
| 13 |
|
| 14 |
**Release boundary:** public records are intended for browsing, reuse, and presentation. Official scoring uses the [manual-gated Gold companion](https://huggingface.co/datasets/AgenticFinLab/H2EPR-Bench-Gold). Public FinalCascade and Gantt views are supplementary inspection assets, not official scoring references.
|
| 15 |
|
app.py
CHANGED
|
@@ -17,6 +17,12 @@ from h2epr_explorer.constants import (
|
|
| 17 |
)
|
| 18 |
from h2epr_explorer.data_loader import load_catalog, load_event_graph, load_finalcascade_summary, load_stages
|
| 19 |
from h2epr_explorer.filters import event_description, event_display_label, event_name, filter_catalog
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
from h2epr_explorer.render_gantt import build_timeline_figure
|
| 21 |
|
| 22 |
|
|
@@ -29,10 +35,57 @@ def _select_columns(frame, columns):
|
|
| 29 |
return frame[present] if present else frame
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
st.set_page_config(page_title="H2EPR-Bench Explorer", layout="wide")
|
| 33 |
|
| 34 |
-
st.
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
st.info(RELEASE_BOUNDARY_NOTICE)
|
| 37 |
|
| 38 |
catalog = load_catalog()
|
|
@@ -48,6 +101,9 @@ with st.sidebar:
|
|
| 48 |
categories = st.multiselect("Category", sorted(catalog["event_category"].dropna().unique().tolist()))
|
| 49 |
min_source_count = st.slider("Minimum sources", 0, int(catalog["source_count"].max()), 0)
|
| 50 |
min_stage_count = st.slider("Minimum stages", 0, int(catalog["stage_count"].max()), 0)
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
filtered_rows = filter_catalog(
|
| 53 |
catalog_rows,
|
|
@@ -63,35 +119,80 @@ if not filtered_rows:
|
|
| 63 |
st.stop()
|
| 64 |
|
| 65 |
event_labels = {row["event_id"]: event_display_label(row) for row in catalog_rows}
|
|
|
|
|
|
|
| 66 |
|
| 67 |
selected_event = st.selectbox(
|
| 68 |
"Selected event",
|
| 69 |
[row["event_id"] for row in filtered_rows],
|
|
|
|
| 70 |
format_func=lambda event_id: event_labels.get(event_id, event_id),
|
| 71 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
event_row = catalog[catalog["event_id"] == selected_event].iloc[0]
|
| 74 |
event_record = event_row.to_dict()
|
| 75 |
-
event_stages = stages[stages["event_id"] == selected_event]
|
| 76 |
summary_row = summary[summary["event_id"] == selected_event]
|
|
|
|
| 77 |
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
|
| 80 |
with tabs[0]:
|
| 81 |
-
st.subheader(
|
| 82 |
st.dataframe(_select_columns(catalog[catalog["event_id"].isin([row["event_id"] for row in filtered_rows])], CATALOG_COLUMNS), use_container_width=True, height=520)
|
| 83 |
|
| 84 |
with tabs[1]:
|
| 85 |
st.subheader(event_name(event_record))
|
| 86 |
st.write(event_description(event_record))
|
| 87 |
-
c1, c2, c3, c4 = st.columns(
|
| 88 |
-
c1.metric("
|
| 89 |
-
c2.metric("
|
| 90 |
-
c3.metric("
|
| 91 |
-
c4.metric("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
if not summary_row.empty:
|
| 93 |
st.markdown("#### Public FinalCascade summary")
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
with tabs[2]:
|
| 97 |
figure = build_timeline_figure(_as_records(event_stages), selected_event)
|
|
|
|
| 17 |
)
|
| 18 |
from h2epr_explorer.data_loader import load_catalog, load_event_graph, load_finalcascade_summary, load_stages
|
| 19 |
from h2epr_explorer.filters import event_description, event_display_label, event_name, filter_catalog
|
| 20 |
+
from h2epr_explorer.navigation import (
|
| 21 |
+
build_event_links,
|
| 22 |
+
filter_summary_text,
|
| 23 |
+
query_param_event_id,
|
| 24 |
+
resolve_selected_event_index,
|
| 25 |
+
)
|
| 26 |
from h2epr_explorer.render_gantt import build_timeline_figure
|
| 27 |
|
| 28 |
|
|
|
|
| 35 |
return frame[present] if present else frame
|
| 36 |
|
| 37 |
|
| 38 |
+
def _sort_stage_frame(frame):
|
| 39 |
+
sort_columns = [column for column in ("stage_index", "stage_order", "stage_id") if column in frame.columns]
|
| 40 |
+
return frame.sort_values(sort_columns) if sort_columns else frame
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _safe_int(value, default=0):
|
| 44 |
+
try:
|
| 45 |
+
return int(value)
|
| 46 |
+
except (TypeError, ValueError):
|
| 47 |
+
return default
|
| 48 |
+
|
| 49 |
+
|
| 50 |
st.set_page_config(page_title="H2EPR-Bench Explorer", layout="wide")
|
| 51 |
|
| 52 |
+
st.markdown(
|
| 53 |
+
"""
|
| 54 |
+
<style>
|
| 55 |
+
div[data-testid="stMetric"] {
|
| 56 |
+
border: 1px solid #e5e7eb;
|
| 57 |
+
border-radius: 8px;
|
| 58 |
+
padding: 0.35rem 0.6rem;
|
| 59 |
+
background: #fbfbf8;
|
| 60 |
+
}
|
| 61 |
+
.h2epr-kicker {
|
| 62 |
+
color: #4b5563;
|
| 63 |
+
font-size: 0.92rem;
|
| 64 |
+
letter-spacing: 0;
|
| 65 |
+
margin-bottom: 0.25rem;
|
| 66 |
+
}
|
| 67 |
+
.h2epr-title {
|
| 68 |
+
font-size: 2.15rem;
|
| 69 |
+
font-weight: 760;
|
| 70 |
+
line-height: 1.12;
|
| 71 |
+
margin-bottom: 0.25rem;
|
| 72 |
+
}
|
| 73 |
+
.h2epr-subtitle {
|
| 74 |
+
color: #374151;
|
| 75 |
+
max-width: 920px;
|
| 76 |
+
margin-bottom: 0.75rem;
|
| 77 |
+
}
|
| 78 |
+
</style>
|
| 79 |
+
""",
|
| 80 |
+
unsafe_allow_html=True,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
st.markdown('<div class="h2epr-kicker">H²EPR-Bench · public release explorer</div>', unsafe_allow_html=True)
|
| 84 |
+
st.markdown('<div class="h2epr-title">Event-process graph browser</div>', unsafe_allow_html=True)
|
| 85 |
+
st.markdown(
|
| 86 |
+
'<div class="h2epr-subtitle">Browse public event metadata, stage rows, FinalCascade summaries, and Gantt-style timelines for the H²EPR-Bench release.</div>',
|
| 87 |
+
unsafe_allow_html=True,
|
| 88 |
+
)
|
| 89 |
st.info(RELEASE_BOUNDARY_NOTICE)
|
| 90 |
|
| 91 |
catalog = load_catalog()
|
|
|
|
| 101 |
categories = st.multiselect("Category", sorted(catalog["event_category"].dropna().unique().tolist()))
|
| 102 |
min_source_count = st.slider("Minimum sources", 0, int(catalog["source_count"].max()), 0)
|
| 103 |
min_stage_count = st.slider("Minimum stages", 0, int(catalog["stage_count"].max()), 0)
|
| 104 |
+
st.divider()
|
| 105 |
+
st.link_button("Dataset repository", f"https://huggingface.co/datasets/{PUBLIC_DATASET_REPO}", use_container_width=True)
|
| 106 |
+
st.link_button("Request Gold access", f"https://huggingface.co/datasets/{GOLD_COMPANION_REPO}", use_container_width=True)
|
| 107 |
|
| 108 |
filtered_rows = filter_catalog(
|
| 109 |
catalog_rows,
|
|
|
|
| 119 |
st.stop()
|
| 120 |
|
| 121 |
event_labels = {row["event_id"]: event_display_label(row) for row in catalog_rows}
|
| 122 |
+
requested_event_id = query_param_event_id(st.query_params)
|
| 123 |
+
selected_index = resolve_selected_event_index(filtered_rows, requested_event_id)
|
| 124 |
|
| 125 |
selected_event = st.selectbox(
|
| 126 |
"Selected event",
|
| 127 |
[row["event_id"] for row in filtered_rows],
|
| 128 |
+
index=selected_index,
|
| 129 |
format_func=lambda event_id: event_labels.get(event_id, event_id),
|
| 130 |
)
|
| 131 |
+
try:
|
| 132 |
+
st.query_params["event_id"] = selected_event
|
| 133 |
+
except Exception:
|
| 134 |
+
pass
|
| 135 |
|
| 136 |
event_row = catalog[catalog["event_id"] == selected_event].iloc[0]
|
| 137 |
event_record = event_row.to_dict()
|
| 138 |
+
event_stages = _sort_stage_frame(stages[stages["event_id"] == selected_event])
|
| 139 |
summary_row = summary[summary["event_id"] == selected_event]
|
| 140 |
+
event_links = build_event_links(selected_event, str(event_record.get("gantt_html_path") or ""))
|
| 141 |
|
| 142 |
+
st.caption(filter_summary_text(len(filtered_rows), len(catalog_rows)))
|
| 143 |
+
|
| 144 |
+
tabs = st.tabs(["Catalog", "Event detail", "Timeline", "Stages", "FinalCascade JSON", "Access and boundary"])
|
| 145 |
|
| 146 |
with tabs[0]:
|
| 147 |
+
st.subheader("Event catalog")
|
| 148 |
st.dataframe(_select_columns(catalog[catalog["event_id"].isin([row["event_id"] for row in filtered_rows])], CATALOG_COLUMNS), use_container_width=True, height=520)
|
| 149 |
|
| 150 |
with tabs[1]:
|
| 151 |
st.subheader(event_name(event_record))
|
| 152 |
st.write(event_description(event_record))
|
| 153 |
+
c1, c2, c3, c4, c5 = st.columns(5)
|
| 154 |
+
c1.metric("Sources", _safe_int(event_row.get("source_count", 0)))
|
| 155 |
+
c2.metric("Stages", _safe_int(event_row.get("stage_count", 0)))
|
| 156 |
+
c3.metric("Episodes", _safe_int(event_row.get("episode_count", 0)))
|
| 157 |
+
c4.metric("Participants", _safe_int(event_row.get("participant_count", 0)))
|
| 158 |
+
c5.metric("Relations", _safe_int(event_row.get("relation_count", 0)))
|
| 159 |
+
|
| 160 |
+
st.markdown("#### Event profile")
|
| 161 |
+
profile_columns = [
|
| 162 |
+
"event_id",
|
| 163 |
+
"display_name",
|
| 164 |
+
"domain",
|
| 165 |
+
"event_category",
|
| 166 |
+
"event_scope_label",
|
| 167 |
+
"keywords",
|
| 168 |
+
"event_boundary_time_status",
|
| 169 |
+
"temporal_anchor_summary",
|
| 170 |
+
"gold_reference_access_level",
|
| 171 |
+
"finalcascade_access_level",
|
| 172 |
+
]
|
| 173 |
+
st.dataframe(_select_columns(catalog[catalog["event_id"] == selected_event], profile_columns), use_container_width=True)
|
| 174 |
+
|
| 175 |
+
link_cols = st.columns(4)
|
| 176 |
+
link_cols[0].link_button("Open dataset", event_links["public_dataset"], use_container_width=True)
|
| 177 |
+
link_cols[1].link_button("Gold access", event_links["gold_request"], use_container_width=True)
|
| 178 |
+
link_cols[2].link_button("FinalCascade file", event_links["finalcascade_jsonl"], use_container_width=True)
|
| 179 |
+
if "gantt_html" in event_links:
|
| 180 |
+
link_cols[3].link_button("Gantt artifact", event_links["gantt_html"], use_container_width=True)
|
| 181 |
+
|
| 182 |
if not summary_row.empty:
|
| 183 |
st.markdown("#### Public FinalCascade summary")
|
| 184 |
+
summary_columns = [
|
| 185 |
+
"event_id",
|
| 186 |
+
"stage_count",
|
| 187 |
+
"episode_count",
|
| 188 |
+
"participant_count",
|
| 189 |
+
"transaction_count",
|
| 190 |
+
"relation_count",
|
| 191 |
+
"event_boundary_time_status",
|
| 192 |
+
"known_action_time_anchor_count",
|
| 193 |
+
"not_gold_warning",
|
| 194 |
+
]
|
| 195 |
+
st.dataframe(_select_columns(summary_row, summary_columns), use_container_width=True)
|
| 196 |
|
| 197 |
with tabs[2]:
|
| 198 |
figure = build_timeline_figure(_as_records(event_stages), selected_event)
|
src/h2epr_explorer/constants.py
CHANGED
|
@@ -17,13 +17,14 @@ RELEASE_BOUNDARY_NOTICE = (
|
|
| 17 |
|
| 18 |
CATALOG_COLUMNS = [
|
| 19 |
"event_id",
|
| 20 |
-
"
|
| 21 |
"domain",
|
| 22 |
"event_category",
|
| 23 |
-
"
|
| 24 |
"keywords",
|
| 25 |
"source_count",
|
| 26 |
"stage_count",
|
|
|
|
|
|
|
| 27 |
"gantt_html_path",
|
| 28 |
]
|
| 29 |
-
|
|
|
|
| 17 |
|
| 18 |
CATALOG_COLUMNS = [
|
| 19 |
"event_id",
|
| 20 |
+
"display_name",
|
| 21 |
"domain",
|
| 22 |
"event_category",
|
| 23 |
+
"event_descriptor_en",
|
| 24 |
"keywords",
|
| 25 |
"source_count",
|
| 26 |
"stage_count",
|
| 27 |
+
"event_boundary_time_status",
|
| 28 |
+
"known_action_time_anchor_count",
|
| 29 |
"gantt_html_path",
|
| 30 |
]
|
|
|
src/h2epr_explorer/navigation.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Mapping
|
| 4 |
+
|
| 5 |
+
from .constants import GOLD_COMPANION_REPO, PUBLIC_DATASET_REPO
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
SPACE_URL = "https://huggingface.co/spaces/AgenticFinLab/H2EPR-Bench-Explorer"
|
| 9 |
+
PUBLIC_DATASET_URL = f"https://huggingface.co/datasets/{PUBLIC_DATASET_REPO}"
|
| 10 |
+
GOLD_COMPANION_URL = f"https://huggingface.co/datasets/{GOLD_COMPANION_REPO}"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _first_value(value: Any) -> str:
|
| 14 |
+
if isinstance(value, (list, tuple)):
|
| 15 |
+
return str(value[0]).strip() if value else ""
|
| 16 |
+
return str(value or "").strip()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def query_param_event_id(query_params: Mapping[str, Any]) -> str:
|
| 20 |
+
return _first_value(query_params.get("event_id"))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def resolve_selected_event_index(rows: list[dict[str, Any]], requested_event_id: str = "") -> int:
|
| 24 |
+
if not rows:
|
| 25 |
+
return 0
|
| 26 |
+
if requested_event_id:
|
| 27 |
+
for index, row in enumerate(rows):
|
| 28 |
+
if str(row.get("event_id", "")).strip() == requested_event_id:
|
| 29 |
+
return index
|
| 30 |
+
return 0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def build_event_links(event_id: str, gantt_html_path: str = "") -> dict[str, str]:
|
| 34 |
+
event_id = event_id.strip()
|
| 35 |
+
links = {
|
| 36 |
+
"explorer": f"{SPACE_URL}?event_id={event_id}",
|
| 37 |
+
"public_dataset": PUBLIC_DATASET_URL,
|
| 38 |
+
"gold_request": GOLD_COMPANION_URL,
|
| 39 |
+
"finalcascade_jsonl": f"{PUBLIC_DATASET_URL}/blob/main/data/finmycelium_finalcascade_public.jsonl",
|
| 40 |
+
}
|
| 41 |
+
if gantt_html_path:
|
| 42 |
+
links["gantt_html"] = f"{PUBLIC_DATASET_URL}/blob/main/{gantt_html_path.lstrip('/')}"
|
| 43 |
+
return links
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def filter_summary_text(filtered_count: int, total_count: int) -> str:
|
| 47 |
+
return f"Showing {filtered_count:,} of {total_count:,} events"
|
src/h2epr_explorer/render_gantt.py
CHANGED
|
@@ -7,18 +7,42 @@ def _is_known_time(value: Any) -> bool:
|
|
| 7 |
return bool(value) and str(value).strip().lower() not in {"unknown", "none", "nan", "nat"}
|
| 8 |
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
def prepare_gantt_rows(stage_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 11 |
-
ordered = sorted(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
prepared: list[dict[str, Any]] = []
|
| 13 |
for fallback_index, row in enumerate(ordered, start=1):
|
| 14 |
start = row.get("stage_start_time")
|
| 15 |
end = row.get("stage_end_time")
|
| 16 |
-
|
|
|
|
| 17 |
display_start = start
|
| 18 |
display_end = end
|
| 19 |
axis_mode = "calendar"
|
| 20 |
else:
|
| 21 |
-
display_start =
|
| 22 |
display_end = display_start + 0.85
|
| 23 |
axis_mode = "relative_order"
|
| 24 |
|
|
@@ -29,6 +53,8 @@ def prepare_gantt_rows(stage_rows: list[dict[str, Any]]) -> list[dict[str, Any]]
|
|
| 29 |
prepared.append(
|
| 30 |
{
|
| 31 |
**row,
|
|
|
|
|
|
|
| 32 |
"display_start": display_start,
|
| 33 |
"display_end": display_end,
|
| 34 |
"axis_mode": axis_mode,
|
|
@@ -52,8 +78,8 @@ def build_timeline_figure(stage_rows: list[dict[str, Any]], event_id: str):
|
|
| 52 |
frame,
|
| 53 |
x_start="display_start",
|
| 54 |
x_end="display_end",
|
| 55 |
-
y="
|
| 56 |
-
color="
|
| 57 |
hover_data=["stage_id", "stage_order", "time_note"],
|
| 58 |
title=f"{event_id}: public stage timeline",
|
| 59 |
)
|
|
@@ -63,14 +89,13 @@ def build_timeline_figure(stage_rows: list[dict[str, Any]], event_id: str):
|
|
| 63 |
fig = px.bar(
|
| 64 |
frame,
|
| 65 |
x=[row["display_end"] - row["display_start"] for row in prepared],
|
| 66 |
-
y="
|
| 67 |
base="display_start",
|
| 68 |
orientation="h",
|
| 69 |
-
color="
|
| 70 |
hover_data=["stage_id", "stage_order", "time_note"],
|
| 71 |
title=f"{event_id}: relative stage order",
|
| 72 |
)
|
| 73 |
fig.update_yaxes(autorange="reversed")
|
| 74 |
fig.update_layout(xaxis_title="Relative stage order")
|
| 75 |
return fig
|
| 76 |
-
|
|
|
|
| 7 |
return bool(value) and str(value).strip().lower() not in {"unknown", "none", "nan", "nat"}
|
| 8 |
|
| 9 |
|
| 10 |
+
def _stage_order(row: dict[str, Any], fallback_index: int = 0) -> int:
|
| 11 |
+
value = row.get("stage_index", row.get("stage_order", fallback_index))
|
| 12 |
+
try:
|
| 13 |
+
return int(value)
|
| 14 |
+
except (TypeError, ValueError):
|
| 15 |
+
return fallback_index
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _stage_label(row: dict[str, Any]) -> str:
|
| 19 |
+
for field in ("stage_title", "stage_label_public", "stage_label", "stage_id"):
|
| 20 |
+
value = str(row.get(field) or "").strip()
|
| 21 |
+
if value:
|
| 22 |
+
return value
|
| 23 |
+
return "Unnamed stage"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
def prepare_gantt_rows(stage_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 27 |
+
ordered = sorted(
|
| 28 |
+
stage_rows,
|
| 29 |
+
key=lambda row: (_stage_order(row), str(row.get("stage_id", ""))),
|
| 30 |
+
)
|
| 31 |
+
calendar_axis = all(
|
| 32 |
+
_is_known_time(row.get("stage_start_time")) and _is_known_time(row.get("stage_end_time"))
|
| 33 |
+
for row in ordered
|
| 34 |
+
)
|
| 35 |
prepared: list[dict[str, Any]] = []
|
| 36 |
for fallback_index, row in enumerate(ordered, start=1):
|
| 37 |
start = row.get("stage_start_time")
|
| 38 |
end = row.get("stage_end_time")
|
| 39 |
+
stage_order = _stage_order(row, fallback_index)
|
| 40 |
+
if calendar_axis:
|
| 41 |
display_start = start
|
| 42 |
display_end = end
|
| 43 |
axis_mode = "calendar"
|
| 44 |
else:
|
| 45 |
+
display_start = stage_order
|
| 46 |
display_end = display_start + 0.85
|
| 47 |
axis_mode = "relative_order"
|
| 48 |
|
|
|
|
| 53 |
prepared.append(
|
| 54 |
{
|
| 55 |
**row,
|
| 56 |
+
"stage_label": _stage_label(row),
|
| 57 |
+
"stage_order": stage_order,
|
| 58 |
"display_start": display_start,
|
| 59 |
"display_end": display_end,
|
| 60 |
"axis_mode": axis_mode,
|
|
|
|
| 78 |
frame,
|
| 79 |
x_start="display_start",
|
| 80 |
x_end="display_end",
|
| 81 |
+
y="stage_label",
|
| 82 |
+
color="stage_label",
|
| 83 |
hover_data=["stage_id", "stage_order", "time_note"],
|
| 84 |
title=f"{event_id}: public stage timeline",
|
| 85 |
)
|
|
|
|
| 89 |
fig = px.bar(
|
| 90 |
frame,
|
| 91 |
x=[row["display_end"] - row["display_start"] for row in prepared],
|
| 92 |
+
y="stage_label",
|
| 93 |
base="display_start",
|
| 94 |
orientation="h",
|
| 95 |
+
color="stage_label",
|
| 96 |
hover_data=["stage_id", "stage_order", "time_note"],
|
| 97 |
title=f"{event_id}: relative stage order",
|
| 98 |
)
|
| 99 |
fig.update_yaxes(autorange="reversed")
|
| 100 |
fig.update_layout(xaxis_title="Relative stage order")
|
| 101 |
return fig
|
|
|