|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | gantt-dependencies: Gantt Chart with Dependencies |
3 | | -Library: bokeh 3.8.2 | Python 3.13.11 |
4 | | -Quality: 90/100 | Created: 2026-01-15 |
| 3 | +Library: bokeh 3.8.2 | Python 3.14 |
| 4 | +Quality: /100 | Updated: 2026-02-25 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import pandas as pd |
8 | 8 | from bokeh.io import export_png, output_file, save |
9 | | -from bokeh.models import ColumnDataSource, LabelSet, Legend, LegendItem |
| 9 | +from bokeh.models import BoxAnnotation, ColumnDataSource, LabelSet, Legend, LegendItem |
10 | 10 | from bokeh.plotting import figure |
11 | 11 |
|
12 | 12 |
|
|
120 | 120 | group_df = df[df["group"] == group] |
121 | 121 | group_spans[group] = {"start": group_df["start"].min(), "end": group_df["end"].max()} |
122 | 122 |
|
123 | | -# Build y-positions using numeric indices |
| 123 | +# Build y-positions with group headers |
124 | 124 | y_positions = {} |
125 | 125 | y_labels = [] |
| 126 | +y_is_group = [] |
126 | 127 | current_y = 0 |
127 | 128 |
|
128 | 129 | for group in groups: |
129 | | - # Group header |
130 | 130 | y_positions[f"__group__{group}"] = current_y |
131 | 131 | y_labels.append((current_y, group)) |
| 132 | + y_is_group.append(True) |
132 | 133 | current_y += 1 |
133 | | - # Tasks in this group (indented) |
134 | 134 | group_tasks = df[df["group"] == group]["task"].tolist() |
135 | 135 | for task in group_tasks: |
136 | 136 | y_positions[task] = current_y |
137 | | - y_labels.append((current_y, f" {task}")) |
| 137 | + y_labels.append((current_y, f" {task}")) |
| 138 | + y_is_group.append(False) |
138 | 139 | current_y += 1 |
139 | 140 |
|
140 | 141 | max_y = current_y |
141 | 142 |
|
142 | | -# Create figure with numeric y-axis |
| 143 | +# Create figure |
143 | 144 | p = figure( |
144 | 145 | width=4800, |
145 | 146 | height=2700, |
146 | 147 | x_axis_type="datetime", |
147 | 148 | y_range=(max_y + 0.5, -0.5), |
148 | | - title="gantt-dependencies · bokeh · pyplots.ai", |
149 | | - x_axis_label="Timeline", |
| 149 | + title="gantt-dependencies \u00b7 bokeh \u00b7 pyplots.ai", |
| 150 | + x_axis_label="Timeline (Weeks)", |
150 | 151 | tools="", |
151 | 152 | ) |
152 | 153 |
|
153 | | -# Style - larger fonts for 4800x2700 canvas |
| 154 | +# Style |
154 | 155 | p.title.text_font_size = "42pt" |
| 156 | +p.title.text_color = "#333333" |
155 | 157 | p.xaxis.axis_label_text_font_size = "28pt" |
156 | 158 | p.xaxis.major_label_text_font_size = "22pt" |
157 | 159 | p.yaxis.visible = False |
158 | | -p.xgrid.grid_line_alpha = 0.3 |
| 160 | +p.xgrid.grid_line_alpha = 0.15 |
| 161 | +p.xgrid.grid_line_dash = [6, 4] |
159 | 162 | p.ygrid.grid_line_alpha = 0.0 |
| 163 | +p.outline_line_color = None |
| 164 | +p.background_fill_color = "#FAFAFA" |
| 165 | + |
| 166 | +# Alternating row bands for readability |
| 167 | +for i in range(max_y): |
| 168 | + if i % 2 == 0: |
| 169 | + p.add_layout( |
| 170 | + BoxAnnotation( |
| 171 | + bottom=i - 0.5, top=i + 0.5, fill_color="#F0F0F0", fill_alpha=0.5, level="underlay", line_color=None |
| 172 | + ) |
| 173 | + ) |
160 | 174 |
|
161 | 175 | # Draw group span bars (lighter background) |
162 | 176 | group_renderers = {} |
163 | 177 | for group in groups: |
164 | 178 | span = group_spans[group] |
165 | 179 | y = y_positions[f"__group__{group}"] |
166 | 180 | source = ColumnDataSource(data={"y": [y], "left": [span["start"]], "right": [span["end"]], "height": [0.7]}) |
167 | | - r = p.hbar(y="y", left="left", right="right", height="height", color=group_colors[group], alpha=0.35, source=source) |
| 181 | + r = p.hbar(y="y", left="left", right="right", height="height", color=group_colors[group], alpha=0.3, source=source) |
168 | 182 | group_renderers[group] = r |
169 | 183 |
|
170 | 184 | # Draw task bars |
171 | 185 | for _, row in df.iterrows(): |
172 | 186 | y = y_positions[row["task"]] |
173 | | - source = ColumnDataSource(data={"y": [y], "left": [row["start"]], "right": [row["end"]], "height": [0.55]}) |
| 187 | + source = ColumnDataSource(data={"y": [y], "left": [row["start"]], "right": [row["end"]], "height": [0.5]}) |
174 | 188 | p.hbar( |
175 | | - y="y", left="left", right="right", height="height", color=group_colors[row["group"]], alpha=0.9, source=source |
| 189 | + y="y", |
| 190 | + left="left", |
| 191 | + right="right", |
| 192 | + height="height", |
| 193 | + color=group_colors[row["group"]], |
| 194 | + alpha=0.9, |
| 195 | + line_color="white", |
| 196 | + line_width=1, |
| 197 | + source=source, |
176 | 198 | ) |
177 | 199 |
|
178 | | -# Draw dependency arrows using multi_line with arrowheads |
179 | | -# Route arrows to avoid passing through task bars by going below/above them |
| 200 | +# Draw dependency arrows (finish-to-start) |
180 | 201 | arrow_xs = [] |
181 | 202 | arrow_ys = [] |
182 | 203 | arrowhead_xs = [] |
183 | 204 | arrowhead_ys = [] |
184 | 205 |
|
185 | | -bar_offset = 0.4 # Offset to route around bars |
| 206 | +dep_color = "#666666" |
186 | 207 |
|
187 | 208 | for _, row in df.iterrows(): |
188 | 209 | task_name = row["task"] |
189 | 210 | task_y = y_positions[task_name] |
190 | | - task_start = row["start"].value / 1e6 # Convert to ms for plotting |
| 211 | + task_start_ms = row["start"].value / 1e6 |
191 | 212 |
|
192 | 213 | for dep_name in row["depends_on"]: |
193 | 214 | if dep_name in task_lookup: |
194 | 215 | dep_row = df.iloc[task_lookup[dep_name]] |
195 | | - dep_end = dep_row["end"].value / 1e6 |
| 216 | + dep_end_ms = dep_row["end"].value / 1e6 |
196 | 217 | dep_y = y_positions[dep_name] |
197 | 218 |
|
198 | | - # Route around bars: go from end of dep bar, drop down/up outside bar area, then connect |
199 | | - if task_y > dep_y: |
200 | | - # Target is below: route via bottom edges of bars |
201 | | - route_y = max(dep_y, task_y) + bar_offset |
202 | | - arrow_xs.append([dep_end, dep_end, task_start, task_start]) |
203 | | - arrow_ys.append([dep_y + bar_offset * 0.7, route_y, route_y, task_y]) |
204 | | - elif task_y < dep_y: |
205 | | - # Target is above: route via top edges |
206 | | - route_y = min(dep_y, task_y) - bar_offset |
207 | | - arrow_xs.append([dep_end, dep_end, task_start, task_start]) |
208 | | - arrow_ys.append([dep_y - bar_offset * 0.7, route_y, route_y, task_y]) |
| 219 | + # Horizontal offset for the vertical drop segment |
| 220 | + h_offset = 1.0 * 24 * 60 * 60 * 1000 # 1 day in ms |
| 221 | + |
| 222 | + if task_y != dep_y: |
| 223 | + # Route: right from dep end → down/up → horizontal → into successor start |
| 224 | + mid_x = dep_end_ms + h_offset |
| 225 | + arrow_xs.append([dep_end_ms, mid_x, mid_x, task_start_ms]) |
| 226 | + arrow_ys.append([dep_y, dep_y, task_y, task_y]) |
209 | 227 | else: |
210 | | - # Same row (shouldn't happen) |
211 | | - arrow_xs.append([dep_end, task_start]) |
| 228 | + arrow_xs.append([dep_end_ms, task_start_ms]) |
212 | 229 | arrow_ys.append([dep_y, task_y]) |
213 | 230 |
|
214 | | - # Arrowhead (small triangle pointing right) |
215 | | - arrow_size = 2 * 24 * 60 * 60 * 1000 # 2 days in ms for arrowhead |
216 | | - arrowhead_xs.append([task_start - arrow_size, task_start, task_start - arrow_size]) |
| 231 | + # Arrowhead pointing right into the successor bar |
| 232 | + arrow_size = 1.5 * 24 * 60 * 60 * 1000 # 1.5 days in ms |
| 233 | + arrowhead_xs.append([task_start_ms - arrow_size, task_start_ms, task_start_ms - arrow_size]) |
217 | 234 | arrowhead_ys.append([task_y - 0.12, task_y, task_y + 0.12]) |
218 | 235 |
|
219 | 236 | # Draw dependency lines |
| 237 | +dep_renderer = None |
220 | 238 | if arrow_xs: |
221 | | - p.multi_line(xs=arrow_xs, ys=arrow_ys, line_color="#555555", line_width=2.5, line_alpha=0.7) |
| 239 | + dep_renderer = p.multi_line(xs=arrow_xs, ys=arrow_ys, line_color=dep_color, line_width=3, line_alpha=0.6) |
222 | 240 |
|
223 | 241 | # Draw arrowheads |
224 | 242 | if arrowhead_xs: |
225 | 243 | p.patches( |
226 | | - xs=arrowhead_xs, ys=arrowhead_ys, fill_color="#555555", fill_alpha=0.8, line_color="#555555", line_width=1 |
| 244 | + xs=arrowhead_xs, ys=arrowhead_ys, fill_color=dep_color, fill_alpha=0.7, line_color=dep_color, line_width=1 |
| 245 | + ) |
| 246 | + |
| 247 | +# Y-axis labels — group headers bold, task names regular |
| 248 | +group_label_ys = [] |
| 249 | +group_label_texts = [] |
| 250 | +task_label_ys = [] |
| 251 | +task_label_texts = [] |
| 252 | + |
| 253 | +for (y, label), is_group in zip(y_labels, y_is_group, strict=True): |
| 254 | + if is_group: |
| 255 | + group_label_ys.append(y) |
| 256 | + group_label_texts.append(label) |
| 257 | + else: |
| 258 | + task_label_ys.append(y) |
| 259 | + task_label_texts.append(label) |
| 260 | + |
| 261 | +label_x = df["start"].min() - pd.Timedelta(days=1) |
| 262 | + |
| 263 | +# Group header labels (bold, larger) |
| 264 | +group_label_source = ColumnDataSource( |
| 265 | + data={"y": group_label_ys, "text": group_label_texts, "x": [label_x] * len(group_label_ys)} |
| 266 | +) |
| 267 | +p.add_layout( |
| 268 | + LabelSet( |
| 269 | + x="x", |
| 270 | + y="y", |
| 271 | + text="text", |
| 272 | + source=group_label_source, |
| 273 | + text_font_size="24pt", |
| 274 | + text_font_style="bold", |
| 275 | + text_align="right", |
| 276 | + x_offset=-10, |
| 277 | + text_baseline="middle", |
| 278 | + text_color="#222222", |
227 | 279 | ) |
| 280 | +) |
228 | 281 |
|
229 | | -# Add y-axis labels manually with larger font for 4800x2700 canvas |
230 | | -label_source = ColumnDataSource( |
231 | | - data={ |
232 | | - "y": [y for y, _ in y_labels], |
233 | | - "text": [label for _, label in y_labels], |
234 | | - "x": [df["start"].min() - pd.Timedelta(days=1)] * len(y_labels), |
235 | | - } |
| 282 | +# Task labels (regular, slightly smaller) |
| 283 | +task_label_source = ColumnDataSource( |
| 284 | + data={"y": task_label_ys, "text": task_label_texts, "x": [label_x] * len(task_label_ys)} |
236 | 285 | ) |
237 | | -labels = LabelSet( |
238 | | - x="x", |
239 | | - y="y", |
240 | | - text="text", |
241 | | - source=label_source, |
242 | | - text_font_size="22pt", |
243 | | - text_align="right", |
244 | | - x_offset=-10, |
245 | | - y_offset=0, |
246 | | - text_baseline="middle", |
| 286 | +p.add_layout( |
| 287 | + LabelSet( |
| 288 | + x="x", |
| 289 | + y="y", |
| 290 | + text="text", |
| 291 | + source=task_label_source, |
| 292 | + text_font_size="20pt", |
| 293 | + text_align="right", |
| 294 | + x_offset=-10, |
| 295 | + text_baseline="middle", |
| 296 | + text_color="#444444", |
| 297 | + ) |
247 | 298 | ) |
248 | | -p.add_layout(labels) |
249 | 299 |
|
250 | | -# Adjust x range to make room for labels |
251 | | -x_min = df["start"].min() - pd.Timedelta(days=12) |
| 300 | +# Adjust x range for labels |
| 301 | +x_min = df["start"].min() - pd.Timedelta(days=14) |
252 | 302 | x_max = df["end"].max() + pd.Timedelta(days=3) |
253 | 303 | p.x_range.start = x_min |
254 | 304 | p.x_range.end = x_max |
255 | 305 |
|
256 | | -# Add legend inside the plot area (top right corner) for better proximity to content |
| 306 | +# Legend with phase colors and dependency line |
257 | 307 | legend_items = [] |
258 | 308 | for group in groups: |
259 | 309 | legend_items.append(LegendItem(label=group, renderers=[group_renderers[group]])) |
| 310 | +if dep_renderer: |
| 311 | + legend_items.append(LegendItem(label="Dependency (finish-to-start)", renderers=[dep_renderer])) |
260 | 312 |
|
261 | 313 | legend = Legend( |
262 | 314 | items=legend_items, |
263 | 315 | location="top_right", |
264 | | - label_text_font_size="22pt", |
| 316 | + label_text_font_size="20pt", |
265 | 317 | spacing=12, |
266 | 318 | padding=20, |
267 | 319 | background_fill_alpha=0.85, |
|
277 | 329 | export_png(p, filename="plot.png") |
278 | 330 |
|
279 | 331 | # Save HTML (interactive) |
280 | | -output_file("plot.html", title="gantt-dependencies · bokeh · pyplots.ai") |
| 332 | +output_file("plot.html", title="gantt-dependencies \u00b7 bokeh \u00b7 pyplots.ai") |
281 | 333 | save(p) |
0 commit comments