Spaces:
Configuration error
Configuration error
File size: 5,648 Bytes
03e7fda | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | """
visualization/gantt.py
-----------------------
Interactive Plotly Gantt chart: planned vs actual vs forecasted timelines.
"""
import pandas as pd
import plotly.graph_objects as go
from datetime import datetime
from typing import Optional
from data_loader import DataLoader
STATUS_COLORS = {
"completed": "#22c55e", # green
"in_progress": "#f59e0b", # amber
"not_started": "#94a3b8", # slate
"critical": "#ef4444", # red (critical path)
}
TIMELINE_COLORS = {
"planned": "rgba(99,102,241,0.6)", # indigo
"actual": "rgba(34,197,94,0.7)", # green
"forecasted": "rgba(245,158,11,0.7)", # amber
}
def build_gantt(project_id: str,
loader: Optional[DataLoader] = None,
predictions_df: Optional[pd.DataFrame] = None,
critical_path_ids: Optional[list] = None,
today: Optional[datetime] = None) -> go.Figure:
"""
Build a Plotly Gantt chart for the given project.
Bars per activity (3 tracks):
- Top: Planned (planned_start β planned_end)
- Mid: Actual (actual_start β actual_end or today)
- Bot: Forecasted (today β ensemble_end from predictions)
"""
if loader is None:
loader = DataLoader()
if today is None:
today = datetime(2024, 6, 1)
if critical_path_ids is None:
critical_path_ids = []
today_ts = pd.Timestamp(today)
acts = loader.get_project_activities(project_id)
if acts.empty:
return go.Figure().add_annotation(text="No activities found", showarrow=False)
# Merge predictions
pred_map = {}
if predictions_df is not None and not predictions_df.empty:
for _, row in predictions_df.iterrows():
pred_map[row.get("activity_id", "")] = row.to_dict()
# Sort by planned start
acts = acts.sort_values("planned_start_date", na_position="last")
fig = go.Figure()
y_labels = []
legend_added = set()
def add_bar(name, x0, x1, y_pos, color, showlegend=False):
if pd.isna(x0) or pd.isna(x1) or x0 >= x1:
return
leg = name not in legend_added
if leg:
legend_added.add(name)
fig.add_trace(go.Bar(
x=[(x1 - x0).total_seconds() * 1000],
base=[x0],
y=[y_pos],
orientation="h",
name=name,
marker_color=color,
showlegend=showlegend or leg,
hovertemplate=(
f"<b>{name}</b><br>"
f"Start: {x0.strftime('%Y-%m-%d')}<br>"
f"End: {x1.strftime('%Y-%m-%d')}<br>"
f"Duration: {(x1-x0).days} days<extra></extra>"
),
width=0.25,
))
for idx, (_, row) in enumerate(acts.iterrows()):
act_id = str(row["id"])
act_name = row.get("name", act_id)
status = str(row.get("status", "not_started"))
is_critical = act_id in critical_path_ids
# Y position: 3 tracks per activity
base_y = idx * 3
y_label = f"{'π΄ ' if is_critical else ''}{act_name[:35]}"
y_labels.extend([
f"{y_label} (planned)",
f"{y_label} (actual)",
f"{y_label} (forecast)",
])
planned_s = pd.to_datetime(row.get("planned_start_date"), errors="coerce")
planned_e = pd.to_datetime(row.get("planned_end_date"), errors="coerce")
actual_s = pd.to_datetime(row.get("actual_start_date"), errors="coerce")
actual_e = pd.to_datetime(row.get("actual_end_date"), errors="coerce")
# Planned bar
if not pd.isna(planned_s) and not pd.isna(planned_e):
color = TIMELINE_COLORS["planned"]
add_bar("Planned", planned_s, planned_e, base_y, color, showlegend=True)
# Actual bar
if not pd.isna(actual_s):
act_end_display = actual_e if not pd.isna(actual_e) else today_ts
color = STATUS_COLORS.get(status, STATUS_COLORS["not_started"])
add_bar("Actual", actual_s, act_end_display, base_y + 1, color, showlegend=True)
# Forecasted bar (from predictions)
pred = pred_map.get(act_id, {})
ens_end = pred.get("ensemble_end")
if ens_end and status in ("in_progress", "not_started"):
ens_ts = pd.Timestamp(ens_end)
fc_start = today_ts
if not pd.isna(actual_s):
fc_start = actual_s if pd.isna(actual_e) else actual_e
add_bar("Forecasted", fc_start, ens_ts, base_y + 2,
TIMELINE_COLORS["forecasted"], showlegend=True)
# Today line
fig.add_vline(
x=today_ts.timestamp() * 1000,
line_color="red", line_dash="dash", line_width=2,
annotation_text="Today",
annotation_position="top right",
)
n_acts = len(acts)
fig.update_layout(
title=dict(text=f"π
Project Schedule β {project_id}", font=dict(size=18)),
xaxis=dict(title="Date", type="date"),
yaxis=dict(
title="",
tickvals=list(range(0, n_acts * 3, 3)),
ticktext=[acts.iloc[i].get("name", "")[:35]
for i in range(min(n_acts, len(acts)))],
autorange="reversed",
),
height=max(400, n_acts * 55),
barmode="overlay",
legend=dict(orientation="h", y=1.05),
plot_bgcolor="#0f172a",
paper_bgcolor="#0f172a",
font=dict(color="#e2e8f0"),
)
return fig
|