Gantt Chart with Dependencies — Pygal

A Gantt chart that visualizes project schedules with task dependencies and groupings. Beyond displaying task timelines, this chart shows relationships between tasks using connector arrows, indicating which tasks must complete before others can begin. Tasks can be organized into groups (phases or work packages) with aggregate timeline bars showing the span of each group. This visualization is essential for understanding critical paths and scheduling constraints in complex projects.

Gantt Chart with Dependencies rendered with Pygal

Python source (Pygal)

""" anyplot.ai
gantt-dependencies: Gantt Chart with Dependencies
Library: pygal 3.1.0 | Python 3.13.13
Quality: 87/100 | Updated: 2026-06-02
"""

import os
import re
import sys
from datetime import date, timedelta


# Strip script directory from sys.path so 'import pygal' finds the installed package, not this file
_script_dir = os.path.abspath(os.path.dirname(__file__))
sys.path = [p for p in sys.path if p and os.path.abspath(p) != _script_dir]

import cairosvg
import pygal
from pygal.style import Style


# Theme tokens — Imprint palette chrome
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint categorical palette — positions 1–5 mapped to phases
CAT_COLORS = {
    "Requirements": "#009E73",  # Imprint pos 1 — brand green
    "Design": "#C475FD",  # Imprint pos 2 — lavender
    "Development": "#4467A3",  # Imprint pos 3 — blue
    "Testing": "#BD8233",  # Imprint pos 4 — ochre
    "Deployment": "#2ABCCD",  # Imprint pos 6 — cyan
}
CRITICAL_COLOR = "#AE3030"  # Imprint pos 5 — matte red, critical path semantic anchor

# Data: Software Development Project with phase groupings and dependencies
tasks = [
    (1, "Requirements Gathering", "Requirements", date(2025, 1, 6), date(2025, 1, 17), []),
    (2, "Stakeholder Interviews", "Requirements", date(2025, 1, 20), date(2025, 1, 31), [1]),
    (3, "Requirements Document", "Requirements", date(2025, 2, 3), date(2025, 2, 14), [2]),
    (4, "Architecture Design", "Design", date(2025, 2, 17), date(2025, 2, 28), [3]),
    (5, "UI/UX Design", "Design", date(2025, 2, 17), date(2025, 3, 7), [3]),
    (6, "Database Schema", "Design", date(2025, 3, 3), date(2025, 3, 14), [4]),
    (7, "API Specification", "Design", date(2025, 3, 3), date(2025, 3, 14), [4]),
    (8, "Backend Development", "Development", date(2025, 3, 17), date(2025, 4, 11), [6, 7]),
    (9, "Frontend Development", "Development", date(2025, 3, 17), date(2025, 4, 11), [5, 7]),
    (10, "Integration", "Development", date(2025, 4, 14), date(2025, 4, 25), [8, 9]),
    (11, "Unit Testing", "Testing", date(2025, 4, 14), date(2025, 5, 2), [8]),
    (12, "Integration Testing", "Testing", date(2025, 4, 28), date(2025, 5, 9), [10]),
    (13, "User Acceptance Testing", "Testing", date(2025, 5, 12), date(2025, 5, 23), [12]),
    (14, "Deployment Prep", "Deployment", date(2025, 5, 12), date(2025, 5, 19), [12]),
    (15, "Production Deployment", "Deployment", date(2025, 5, 26), date(2025, 5, 30), [13, 14]),
    (16, "Post-Launch Support", "Deployment", date(2025, 6, 2), date(2025, 6, 13), [15]),
]

categories = ["Requirements", "Design", "Development", "Testing", "Deployment"]
critical_ids = {1, 2, 3, 4, 6, 7, 8, 9, 10, 12, 13, 15, 16}

reference = date(2025, 1, 1)
phase_spans = {}
for cat in categories:
    cat_tasks = [t for t in tasks if t[2] == cat]
    phase_spans[cat] = (min(t[3] for t in cat_tasks), max(t[4] for t in cat_tasks))

all_dates = [d for t in tasks for d in (t[3], t[4])]
start_day = (min(all_dates) - reference).days - 5
end_day = (max(all_dates) - reference).days + 5
day_range = end_day - start_day

# Display rows bottom-to-top (pygal x_labels[0] = bottom row)
display_rows = []
for cat in reversed(categories):
    for t in reversed([t for t in tasks if t[2] == cat]):
        display_rows.append(("task", t))
    display_rows.append(("phase", cat))

num_rows = len(display_rows)

# HorizontalStackedBar: transparent offset bar + per-category bars (pygal-native Gantt technique)
offset_data = []
cat_series = {c: [] for c in categories}

for row_type, row_data in display_rows:
    if row_type == "phase":
        cat = row_data
        ps, pe = phase_spans[cat]
        offset = (ps - reference).days - start_day
        dur = (pe - ps).days
        offset_data.append(offset)
        for c in categories:
            if c == cat:
                cat_series[c].append(
                    {
                        "value": dur,
                        "style": f"fill-opacity:0.22;stroke:{CAT_COLORS[c]};stroke-width:2;stroke-opacity:0.7",
                    }
                )
            else:
                cat_series[c].append(None)
    else:
        tid, name, category, s, e, deps = row_data
        offset = (s - reference).days - start_day
        dur = (e - s).days
        offset_data.append(offset)
        is_crit = tid in critical_ids
        for c in categories:
            if c == category:
                if is_crit:
                    style = f"fill-opacity:0.92;stroke:{INK};stroke-width:1"
                else:
                    style = f"fill-opacity:0.62;stroke-dasharray:5,3;stroke-width:1;stroke:{INK_MUTED}"
                cat_series[c].append({"value": dur, "style": style})
            else:
                cat_series[c].append(None)

row_labels = []
for row_type, row_data in display_rows:
    if row_type == "phase":
        row_labels.append(f"▶ {row_data}")
    else:
        row_labels.append(f"  {row_data[1]}")

# Title length-based font scaling (67-char baseline → 66px default)
title = "gantt-dependencies · python · pygal · anyplot.ai"
title_n = len(title)
title_ratio = 67 / title_n if title_n > 67 else 1.0
title_fs = max(44, round(66 * title_ratio))

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=("rgba(0,0,0,0)",) + tuple(CAT_COLORS[c] for c in categories),
    font_family="Consolas, monospace",
    title_font_size=title_fs,
    label_font_size=38,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=22,
    stroke_width=2,
)

month_positions = [(date(2025, m, 1) - reference).days - start_day for m in range(1, 7)]

chart = pygal.HorizontalStackedBar(
    width=3200,
    height=1800,
    style=custom_style,
    title=title,
    show_legend=False,
    print_values=False,
    show_y_guides=True,
    show_x_guides=False,
    show_y_labels=True,
    show_x_labels=True,
    y_labels=month_positions,
    value_formatter=lambda v: (reference + timedelta(days=int(round(v)) + start_day)).strftime("%b %Y"),
    margin=50,
    margin_bottom=210,
    spacing=4,
    range=(0, day_range),
    rounded_bars=4,
)

chart.x_labels = row_labels
chart.add("", offset_data)
for cat in categories:
    chart.add(cat, cat_series[cat])

svg_string = chart.render().decode("utf-8")

# Extract plot area from rendered SVG — fallbacks calibrated to 3200×1800 canvas
plot_left, plot_top, plot_w, plot_h = 410, 110, 2630, 1420

m1 = re.search(r'class="plot[^"]*"[^>]*transform="translate\(([\d.]+)[, ]+([\d.]+)\)"', svg_string)
if not m1:
    m1 = re.search(r'transform="translate\(([\d.]+)[, ]+([\d.]+)\)"[^>]*class="plot', svg_string)
if m1:
    plot_left, plot_top = float(m1.group(1)), float(m1.group(2))

m2 = re.search(r'class="plot_background"[^>]*width="([\d.]+)"[^>]*height="([\d.]+)"', svg_string)
if not m2:
    m2 = re.search(r'width="([\d.]+)"[^>]*height="([\d.]+)"[^>]*class="plot_background"', svg_string)
if m2:
    plot_w, plot_h = float(m2.group(1)), float(m2.group(2))

row_h = plot_h / num_rows

# Map task IDs to bar pixel positions for dependency arrow rendering
bar_pos = {}
for i, (row_type, row_data) in enumerate(display_rows):
    if row_type != "task":
        continue
    tid, _, _, s, e, _ = row_data
    off = (s - reference).days - start_day
    dur = (e - s).days
    bar_pos[tid] = {
        "xs": plot_left + (off / day_range) * plot_w,
        "xe": plot_left + ((off + dur) / day_range) * plot_w,
        "yc": plot_top + plot_h - (i + 0.5) * row_h,
    }

custom = []

# SVG defs: arrowhead markers for standard and critical-path dependencies
custom.append(
    "<defs>"
    f'<marker id="arr_dep" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">'
    f'<polygon points="0 0,10 3.5,0 7" fill="{INK_SOFT}"/>'
    "</marker>"
    f'<marker id="arr_crit" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">'
    f'<polygon points="0 0,10 3.5,0 7" fill="{CRITICAL_COLOR}"/>'
    "</marker>"
    "</defs>"
)

# Alternating row backgrounds — theme-adaptive, ink-tinted
for i in range(num_rows):
    y_top = plot_top + plot_h - (i + 1) * row_h
    row_type = display_rows[i][0]
    if row_type == "phase":
        custom.append(
            f'<rect x="{plot_left:.1f}" y="{y_top:.1f}" '
            f'width="{plot_w:.1f}" height="{row_h:.1f}" '
            f'fill="{INK}" opacity="0.07"/>'
        )
    elif i % 2 == 0:
        custom.append(
            f'<rect x="{plot_left:.1f}" y="{y_top:.1f}" '
            f'width="{plot_w:.1f}" height="{row_h:.1f}" '
            f'fill="{INK}" opacity="0.03"/>'
        )

# Diamond milestone markers at phase aggregate start/end
for i, (row_type, row_data) in enumerate(display_rows):
    if row_type != "phase":
        continue
    cat = row_data
    ps, pe = phase_spans[cat]
    x_s = plot_left + ((ps - reference).days - start_day) / day_range * plot_w
    x_e = plot_left + ((pe - reference).days - start_day) / day_range * plot_w
    yc = plot_top + plot_h - (i + 0.5) * row_h
    color = CAT_COLORS[cat]
    ds = 15
    for dx in [x_s, x_e]:
        custom.append(
            f'<polygon points="{dx:.1f},{yc - ds:.1f} {dx + ds:.1f},{yc:.1f} '
            f'{dx:.1f},{yc + ds:.1f} {dx - ds:.1f},{yc:.1f}" '
            f'fill="{color}" opacity="0.9"/>'
        )

# Dependency arrows — elbow connectors, critical path in Imprint matte red
for tid, _, _, _, _, deps in tasks:
    if not deps:
        continue
    tgt = bar_pos[tid]
    for did in deps:
        src = bar_pos[did]
        x1, y1 = src["xe"], src["yc"]
        x2, y2 = tgt["xs"], tgt["yc"]
        mx = x1 + (x2 - x1) * 0.5
        is_crit = tid in critical_ids and did in critical_ids
        if abs(y1 - y2) < 4:
            d = f"M{x1:.1f},{y1:.1f} L{x2:.1f},{y2:.1f}"
        else:
            d = f"M{x1:.1f},{y1:.1f} L{mx:.1f},{y1:.1f} L{mx:.1f},{y2:.1f} L{x2:.1f},{y2:.1f}"
        if is_crit:
            color, sw, marker, op = CRITICAL_COLOR, "3.5", "url(#arr_crit)", "0.88"
        else:
            color, sw, marker, op = INK_SOFT, "2", "url(#arr_dep)", "0.55"
        custom.append(
            f'<path d="{d}" stroke="{color}" stroke-width="{sw}" fill="none" opacity="{op}" marker-end="{marker}"/>'
        )

# X-axis timeline label (below axis tick labels)
tl_x = plot_left + plot_w / 2
tl_y = plot_top + plot_h + 90
custom.append(
    f'<text x="{tl_x:.1f}" y="{tl_y:.1f}" font-size="42" fill="{INK}" '
    f'font-family="Consolas, monospace" text-anchor="middle" font-weight="600">'
    "Project Timeline (Jan – Jun 2025)</text>"
)

# Bottom legend — category color swatches + critical path indicator
ly = plot_top + plot_h + 145
lx = plot_left + 10
sp = min(475, int((3200 - lx - 360) // 5))

for idx, cat in enumerate(categories):
    x = lx + idx * sp
    color = CAT_COLORS[cat]
    custom.append(f'<rect x="{x:.1f}" y="{ly:.1f}" width="30" height="30" fill="{color}" rx="3"/>')
    custom.append(
        f'<text x="{x + 38:.1f}" y="{ly + 24:.1f}" font-family="Consolas, monospace" '
        f'font-size="42" fill="{INK}">{cat}</text>'
    )

cpx = lx + 5 * sp
custom.append(
    f'<line x1="{cpx:.1f}" y1="{ly + 13:.1f}" x2="{cpx + 40:.1f}" y2="{ly + 13:.1f}" '
    f'stroke="{CRITICAL_COLOR}" stroke-width="3.5" marker-end="url(#arr_crit)"/>'
)
custom.append(
    f'<text x="{cpx + 52:.1f}" y="{ly + 24:.1f}" font-family="Consolas, monospace" '
    f'font-size="42" fill="{INK}">Critical Path</text>'
)

# Inject custom SVG elements into pygal's rendered output
svg_out = svg_string.replace("</svg>", "\n".join(custom) + "\n</svg>")
svg_out = svg_out.replace(">No data<", "><")

cairosvg.svg2png(bytestring=svg_out.encode(), write_to=f"plot-{THEME}.png")

with open(f"plot-{THEME}.html", "wb") as f:
    f.write(chart.render())

Part of Gantt Chart with Dependencies on anyplot.ai.

Other implementations