{"spec_id":"gantt-dependencies","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\ngantt-dependencies: Gantt Chart with Dependencies\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-06-02\n\"\"\"\n\nimport os\nimport re\nimport sys\nfrom datetime import date, timedelta\n\n\n# Strip script directory from sys.path so 'import pygal' finds the installed package, not this file\n_script_dir = os.path.abspath(os.path.dirname(__file__))\nsys.path = [p for p in sys.path if p and os.path.abspath(p) != _script_dir]\n\nimport cairosvg\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens — Imprint palette chrome\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint categorical palette — positions 1–5 mapped to phases\nCAT_COLORS = {\n    \"Requirements\": \"#009E73\",  # Imprint pos 1 — brand green\n    \"Design\": \"#C475FD\",  # Imprint pos 2 — lavender\n    \"Development\": \"#4467A3\",  # Imprint pos 3 — blue\n    \"Testing\": \"#BD8233\",  # Imprint pos 4 — ochre\n    \"Deployment\": \"#2ABCCD\",  # Imprint pos 6 — cyan\n}\nCRITICAL_COLOR = \"#AE3030\"  # Imprint pos 5 — matte red, critical path semantic anchor\n\n# Data: Software Development Project with phase groupings and dependencies\ntasks = [\n    (1, \"Requirements Gathering\", \"Requirements\", date(2025, 1, 6), date(2025, 1, 17), []),\n    (2, \"Stakeholder Interviews\", \"Requirements\", date(2025, 1, 20), date(2025, 1, 31), [1]),\n    (3, \"Requirements Document\", \"Requirements\", date(2025, 2, 3), date(2025, 2, 14), [2]),\n    (4, \"Architecture Design\", \"Design\", date(2025, 2, 17), date(2025, 2, 28), [3]),\n    (5, \"UI/UX Design\", \"Design\", date(2025, 2, 17), date(2025, 3, 7), [3]),\n    (6, \"Database Schema\", \"Design\", date(2025, 3, 3), date(2025, 3, 14), [4]),\n    (7, \"API Specification\", \"Design\", date(2025, 3, 3), date(2025, 3, 14), [4]),\n    (8, \"Backend Development\", \"Development\", date(2025, 3, 17), date(2025, 4, 11), [6, 7]),\n    (9, \"Frontend Development\", \"Development\", date(2025, 3, 17), date(2025, 4, 11), [5, 7]),\n    (10, \"Integration\", \"Development\", date(2025, 4, 14), date(2025, 4, 25), [8, 9]),\n    (11, \"Unit Testing\", \"Testing\", date(2025, 4, 14), date(2025, 5, 2), [8]),\n    (12, \"Integration Testing\", \"Testing\", date(2025, 4, 28), date(2025, 5, 9), [10]),\n    (13, \"User Acceptance Testing\", \"Testing\", date(2025, 5, 12), date(2025, 5, 23), [12]),\n    (14, \"Deployment Prep\", \"Deployment\", date(2025, 5, 12), date(2025, 5, 19), [12]),\n    (15, \"Production Deployment\", \"Deployment\", date(2025, 5, 26), date(2025, 5, 30), [13, 14]),\n    (16, \"Post-Launch Support\", \"Deployment\", date(2025, 6, 2), date(2025, 6, 13), [15]),\n]\n\ncategories = [\"Requirements\", \"Design\", \"Development\", \"Testing\", \"Deployment\"]\ncritical_ids = {1, 2, 3, 4, 6, 7, 8, 9, 10, 12, 13, 15, 16}\n\nreference = date(2025, 1, 1)\nphase_spans = {}\nfor cat in categories:\n    cat_tasks = [t for t in tasks if t[2] == cat]\n    phase_spans[cat] = (min(t[3] for t in cat_tasks), max(t[4] for t in cat_tasks))\n\nall_dates = [d for t in tasks for d in (t[3], t[4])]\nstart_day = (min(all_dates) - reference).days - 5\nend_day = (max(all_dates) - reference).days + 5\nday_range = end_day - start_day\n\n# Display rows bottom-to-top (pygal x_labels[0] = bottom row)\ndisplay_rows = []\nfor cat in reversed(categories):\n    for t in reversed([t for t in tasks if t[2] == cat]):\n        display_rows.append((\"task\", t))\n    display_rows.append((\"phase\", cat))\n\nnum_rows = len(display_rows)\n\n# HorizontalStackedBar: transparent offset bar + per-category bars (pygal-native Gantt technique)\noffset_data = []\ncat_series = {c: [] for c in categories}\n\nfor row_type, row_data in display_rows:\n    if row_type == \"phase\":\n        cat = row_data\n        ps, pe = phase_spans[cat]\n        offset = (ps - reference).days - start_day\n        dur = (pe - ps).days\n        offset_data.append(offset)\n        for c in categories:\n            if c == cat:\n                cat_series[c].append(\n                    {\n                        \"value\": dur,\n                        \"style\": f\"fill-opacity:0.22;stroke:{CAT_COLORS[c]};stroke-width:2;stroke-opacity:0.7\",\n                    }\n                )\n            else:\n                cat_series[c].append(None)\n    else:\n        tid, name, category, s, e, deps = row_data\n        offset = (s - reference).days - start_day\n        dur = (e - s).days\n        offset_data.append(offset)\n        is_crit = tid in critical_ids\n        for c in categories:\n            if c == category:\n                if is_crit:\n                    style = f\"fill-opacity:0.92;stroke:{INK};stroke-width:1\"\n                else:\n                    style = f\"fill-opacity:0.62;stroke-dasharray:5,3;stroke-width:1;stroke:{INK_MUTED}\"\n                cat_series[c].append({\"value\": dur, \"style\": style})\n            else:\n                cat_series[c].append(None)\n\nrow_labels = []\nfor row_type, row_data in display_rows:\n    if row_type == \"phase\":\n        row_labels.append(f\"▶ {row_data}\")\n    else:\n        row_labels.append(f\"  {row_data[1]}\")\n\n# Title length-based font scaling (67-char baseline → 66px default)\ntitle = \"gantt-dependencies · python · pygal · anyplot.ai\"\ntitle_n = len(title)\ntitle_ratio = 67 / title_n if title_n > 67 else 1.0\ntitle_fs = max(44, round(66 * title_ratio))\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=(\"rgba(0,0,0,0)\",) + tuple(CAT_COLORS[c] for c in categories),\n    font_family=\"Consolas, monospace\",\n    title_font_size=title_fs,\n    label_font_size=38,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=22,\n    stroke_width=2,\n)\n\nmonth_positions = [(date(2025, m, 1) - reference).days - start_day for m in range(1, 7)]\n\nchart = pygal.HorizontalStackedBar(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=title,\n    show_legend=False,\n    print_values=False,\n    show_y_guides=True,\n    show_x_guides=False,\n    show_y_labels=True,\n    show_x_labels=True,\n    y_labels=month_positions,\n    value_formatter=lambda v: (reference + timedelta(days=int(round(v)) + start_day)).strftime(\"%b %Y\"),\n    margin=50,\n    margin_bottom=210,\n    spacing=4,\n    range=(0, day_range),\n    rounded_bars=4,\n)\n\nchart.x_labels = row_labels\nchart.add(\"\", offset_data)\nfor cat in categories:\n    chart.add(cat, cat_series[cat])\n\nsvg_string = chart.render().decode(\"utf-8\")\n\n# Extract plot area from rendered SVG — fallbacks calibrated to 3200×1800 canvas\nplot_left, plot_top, plot_w, plot_h = 410, 110, 2630, 1420\n\nm1 = re.search(r'class=\"plot[^\"]*\"[^>]*transform=\"translate\\(([\\d.]+)[, ]+([\\d.]+)\\)\"', svg_string)\nif not m1:\n    m1 = re.search(r'transform=\"translate\\(([\\d.]+)[, ]+([\\d.]+)\\)\"[^>]*class=\"plot', svg_string)\nif m1:\n    plot_left, plot_top = float(m1.group(1)), float(m1.group(2))\n\nm2 = re.search(r'class=\"plot_background\"[^>]*width=\"([\\d.]+)\"[^>]*height=\"([\\d.]+)\"', svg_string)\nif not m2:\n    m2 = re.search(r'width=\"([\\d.]+)\"[^>]*height=\"([\\d.]+)\"[^>]*class=\"plot_background\"', svg_string)\nif m2:\n    plot_w, plot_h = float(m2.group(1)), float(m2.group(2))\n\nrow_h = plot_h / num_rows\n\n# Map task IDs to bar pixel positions for dependency arrow rendering\nbar_pos = {}\nfor i, (row_type, row_data) in enumerate(display_rows):\n    if row_type != \"task\":\n        continue\n    tid, _, _, s, e, _ = row_data\n    off = (s - reference).days - start_day\n    dur = (e - s).days\n    bar_pos[tid] = {\n        \"xs\": plot_left + (off / day_range) * plot_w,\n        \"xe\": plot_left + ((off + dur) / day_range) * plot_w,\n        \"yc\": plot_top + plot_h - (i + 0.5) * row_h,\n    }\n\ncustom = []\n\n# SVG defs: arrowhead markers for standard and critical-path dependencies\ncustom.append(\n    \"<defs>\"\n    f'<marker id=\"arr_dep\" markerWidth=\"10\" markerHeight=\"7\" refX=\"9\" refY=\"3.5\" orient=\"auto\">'\n    f'<polygon points=\"0 0,10 3.5,0 7\" fill=\"{INK_SOFT}\"/>'\n    \"</marker>\"\n    f'<marker id=\"arr_crit\" markerWidth=\"10\" markerHeight=\"7\" refX=\"9\" refY=\"3.5\" orient=\"auto\">'\n    f'<polygon points=\"0 0,10 3.5,0 7\" fill=\"{CRITICAL_COLOR}\"/>'\n    \"</marker>\"\n    \"</defs>\"\n)\n\n# Alternating row backgrounds — theme-adaptive, ink-tinted\nfor i in range(num_rows):\n    y_top = plot_top + plot_h - (i + 1) * row_h\n    row_type = display_rows[i][0]\n    if row_type == \"phase\":\n        custom.append(\n            f'<rect x=\"{plot_left:.1f}\" y=\"{y_top:.1f}\" '\n            f'width=\"{plot_w:.1f}\" height=\"{row_h:.1f}\" '\n            f'fill=\"{INK}\" opacity=\"0.07\"/>'\n        )\n    elif i % 2 == 0:\n        custom.append(\n            f'<rect x=\"{plot_left:.1f}\" y=\"{y_top:.1f}\" '\n            f'width=\"{plot_w:.1f}\" height=\"{row_h:.1f}\" '\n            f'fill=\"{INK}\" opacity=\"0.03\"/>'\n        )\n\n# Diamond milestone markers at phase aggregate start/end\nfor i, (row_type, row_data) in enumerate(display_rows):\n    if row_type != \"phase\":\n        continue\n    cat = row_data\n    ps, pe = phase_spans[cat]\n    x_s = plot_left + ((ps - reference).days - start_day) / day_range * plot_w\n    x_e = plot_left + ((pe - reference).days - start_day) / day_range * plot_w\n    yc = plot_top + plot_h - (i + 0.5) * row_h\n    color = CAT_COLORS[cat]\n    ds = 15\n    for dx in [x_s, x_e]:\n        custom.append(\n            f'<polygon points=\"{dx:.1f},{yc - ds:.1f} {dx + ds:.1f},{yc:.1f} '\n            f'{dx:.1f},{yc + ds:.1f} {dx - ds:.1f},{yc:.1f}\" '\n            f'fill=\"{color}\" opacity=\"0.9\"/>'\n        )\n\n# Dependency arrows — elbow connectors, critical path in Imprint matte red\nfor tid, _, _, _, _, deps in tasks:\n    if not deps:\n        continue\n    tgt = bar_pos[tid]\n    for did in deps:\n        src = bar_pos[did]\n        x1, y1 = src[\"xe\"], src[\"yc\"]\n        x2, y2 = tgt[\"xs\"], tgt[\"yc\"]\n        mx = x1 + (x2 - x1) * 0.5\n        is_crit = tid in critical_ids and did in critical_ids\n        if abs(y1 - y2) < 4:\n            d = f\"M{x1:.1f},{y1:.1f} L{x2:.1f},{y2:.1f}\"\n        else:\n            d = f\"M{x1:.1f},{y1:.1f} L{mx:.1f},{y1:.1f} L{mx:.1f},{y2:.1f} L{x2:.1f},{y2:.1f}\"\n        if is_crit:\n            color, sw, marker, op = CRITICAL_COLOR, \"3.5\", \"url(#arr_crit)\", \"0.88\"\n        else:\n            color, sw, marker, op = INK_SOFT, \"2\", \"url(#arr_dep)\", \"0.55\"\n        custom.append(\n            f'<path d=\"{d}\" stroke=\"{color}\" stroke-width=\"{sw}\" fill=\"none\" opacity=\"{op}\" marker-end=\"{marker}\"/>'\n        )\n\n# X-axis timeline label (below axis tick labels)\ntl_x = plot_left + plot_w / 2\ntl_y = plot_top + plot_h + 90\ncustom.append(\n    f'<text x=\"{tl_x:.1f}\" y=\"{tl_y:.1f}\" font-size=\"42\" fill=\"{INK}\" '\n    f'font-family=\"Consolas, monospace\" text-anchor=\"middle\" font-weight=\"600\">'\n    \"Project Timeline (Jan – Jun 2025)</text>\"\n)\n\n# Bottom legend — category color swatches + critical path indicator\nly = plot_top + plot_h + 145\nlx = plot_left + 10\nsp = min(475, int((3200 - lx - 360) // 5))\n\nfor idx, cat in enumerate(categories):\n    x = lx + idx * sp\n    color = CAT_COLORS[cat]\n    custom.append(f'<rect x=\"{x:.1f}\" y=\"{ly:.1f}\" width=\"30\" height=\"30\" fill=\"{color}\" rx=\"3\"/>')\n    custom.append(\n        f'<text x=\"{x + 38:.1f}\" y=\"{ly + 24:.1f}\" font-family=\"Consolas, monospace\" '\n        f'font-size=\"42\" fill=\"{INK}\">{cat}</text>'\n    )\n\ncpx = lx + 5 * sp\ncustom.append(\n    f'<line x1=\"{cpx:.1f}\" y1=\"{ly + 13:.1f}\" x2=\"{cpx + 40:.1f}\" y2=\"{ly + 13:.1f}\" '\n    f'stroke=\"{CRITICAL_COLOR}\" stroke-width=\"3.5\" marker-end=\"url(#arr_crit)\"/>'\n)\ncustom.append(\n    f'<text x=\"{cpx + 52:.1f}\" y=\"{ly + 24:.1f}\" font-family=\"Consolas, monospace\" '\n    f'font-size=\"42\" fill=\"{INK}\">Critical Path</text>'\n)\n\n# Inject custom SVG elements into pygal's rendered output\nsvg_out = svg_string.replace(\"</svg>\", \"\\n\".join(custom) + \"\\n</svg>\")\nsvg_out = svg_out.replace(\">No data<\", \"><\")\n\ncairosvg.svg2png(bytestring=svg_out.encode(), write_to=f\"plot-{THEME}.png\")\n\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}