{"spec_id":"area-cumulative-flow","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\narea-cumulative-flow: Cumulative Flow Diagram for Workflow Analytics\nLibrary: pygal 3.1.3 | Python 3.13.15\nQuality: 93/100 | Updated: 2026-08-18\n\"\"\"\n\nimport os\n\nimport cairosvg\nimport numpy as np\nimport pandas as pd\nimport pygal\nfrom pygal.style import Style\n\n\n# pygal drops its internal layout state after render() unless this is set —\n# the QA-crunch annotation below needs chart.view/chart.margin_box afterwards\n# to place the callout at the exact pixel position of the widened Testing band.\nos.environ[\"PYGAL_KEEP_STATE\"] = \"1\"\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\")\n\n# Data: 90-day Kanban board for a software delivery team\nnp.random.seed(42)\nn_days = 90\ndates = pd.date_range(\"2024-01-02\", periods=n_days, freq=\"D\")\n\n# Daily new items entering the backlog (1–3 per day)\ndaily_new = np.random.randint(1, 4, n_days)\nbacklog_entered = np.cumsum(daily_new).astype(float)\n\n# Each stage has a lag from the previous stage boundary. Testing capacity\n# tightens during a mid-period bottleneck (QA understaffed vs. dev throughput),\n# so items linger longer in Development before Testing can absorb them. The\n# lag ramps by at most 1 day/day so the cumulative counts stay non-decreasing.\nanalysis_lag = 4  # days after entering backlog before analysis starts\ndev_lag = 8  # days in analysis before development starts\ntesting_lag_normal = 10  # days in development before testing starts (baseline)\ntesting_lag_bottleneck = 22  # days during the QA capacity crunch (peak)\ndone_lag = 6  # days in testing before done\n\nramp = np.arange(testing_lag_normal, testing_lag_bottleneck + 1)\ntesting_lag_schedule = np.concatenate(\n    [\n        np.full(30, testing_lag_normal),  # normal flow\n        ramp,  # QA backlog builds up\n        np.full(14, testing_lag_bottleneck),  # crunch plateau\n        ramp[::-1],  # extra QA capacity drains the backlog\n    ]\n)\ntesting_lag_schedule = np.concatenate(\n    [testing_lag_schedule, np.full(n_days - len(testing_lag_schedule), testing_lag_normal)]\n)\n\nanalysis_entered = np.zeros(n_days)\ndev_entered = np.zeros(n_days)\ntesting_entered = np.zeros(n_days)\ndone = np.zeros(n_days)\n\nfor d in range(n_days):\n    if d - analysis_lag >= 0:\n        analysis_entered[d] = backlog_entered[d - analysis_lag]\n    if d - dev_lag >= 0:\n        dev_entered[d] = analysis_entered[d - dev_lag]\n    lag = testing_lag_schedule[d]\n    if d - lag >= 0:\n        testing_entered[d] = dev_entered[d - lag]\n    if d - done_lag >= 0:\n        done[d] = testing_entered[d - done_lag]\n\n# Band widths (WIP per stage) — stacked bottom-to-top: Done → Backlog\ndone_band = done\ntesting_band = testing_entered - done\ndev_band = dev_entered - testing_entered\nanalysis_band = analysis_entered - dev_entered\nbacklog_band = backlog_entered - analysis_entered\n\n# X-axis labels — show every 15th day\nall_labels = [d.strftime(\"%b %d\") for d in dates]\nmajor_labels = [d.strftime(\"%b %d\") for d in dates[::15]]\n\n# Style\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=IMPRINT,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=3,\n)\n\n# Chart\nchart = pygal.StackedLine(\n    style=custom_style,\n    fill=True,\n    width=3200,\n    height=1800,\n    title=\"area-cumulative-flow · python · pygal · anyplot.ai\",\n    x_title=\"Date\",\n    y_title=\"Cumulative Item Count\",\n    show_minor_x_labels=False,\n    x_label_rotation=30,\n    margin=80,\n    show_dots=False,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=5,\n    legend_box_size=32,\n    spacing=24,\n)\n\nchart.x_labels = all_labels\nchart.x_labels_major = major_labels\n\n# Add series from bottom (Done) to top (Backlog) — workflow order reversed.\n# Testing (the QA-bottleneck stage) gets a bolder boundary stroke than the\n# other bands: an intentional visual hierarchy that draws the eye straight\n# to the widening band instead of leaving every stage equally weighted.\nchart.add(\"Done\", done_band.tolist())\nchart.add(\"Testing\", testing_band.tolist(), stroke_style={\"width\": 6})\nchart.add(\"Development\", dev_band.tolist())\nchart.add(\"Analysis\", analysis_band.tolist())\nchart.add(\"Backlog\", backlog_band.tolist())\n\n# Render once, keeping pygal's internal view/margin state (see\n# PYGAL_KEEP_STATE above) so the QA-crunch callout below can be placed at\n# the exact pixel position of the Testing band's widest point.\nsvg = chart.render()\nsvg_text = svg.decode(\"utf-8\") if isinstance(svg, bytes) else svg\n\n# Annotate the QA-capacity crunch: the day the Testing band is widest.\npeak_day = int(np.argmax(testing_band))\nanchor_value = (done[peak_day] + testing_entered[peak_day]) / 2\nanchor_xf = peak_day / (n_days - 1)\nanchor_x = chart.margin_box.left + chart.view.x(anchor_xf)\nanchor_y = chart.margin_box.top + chart.view.y(anchor_value)\n\n# Callout box sits in the empty canvas above the pre-crunch ramp-up (offsets\n# tuned for this fixed-seed dataset so the box and its leader line never\n# cross into the filled bands).\nbox_w, box_h = 520, 118\nbox_right = anchor_x - 150\nbox_left = box_right - box_w\nbox_bottom = anchor_y - 434\nbox_top = box_bottom - box_h\ntext_x = (box_left + box_right) / 2\ntext_y = box_top + box_h / 2 + 13\n\nannotation = f\"\"\"\n<g class=\"qa-crunch-annotation\">\n  <line x1=\"{box_right - 20:.1f}\" y1=\"{box_bottom:.1f}\" x2=\"{anchor_x:.1f}\" y2=\"{anchor_y:.1f}\"\n        stroke=\"{INK_MUTED}\" stroke-width=\"3\" stroke-dasharray=\"10,8\" />\n  <circle cx=\"{anchor_x:.1f}\" cy=\"{anchor_y:.1f}\" r=\"9\" fill=\"{INK}\" stroke=\"{PAGE_BG}\" stroke-width=\"3\" />\n  <rect x=\"{box_left:.1f}\" y=\"{box_top:.1f}\" width=\"{box_w}\" height=\"{box_h}\" rx=\"14\"\n        fill=\"{PAGE_BG}\" fill-opacity=\"0.94\" stroke=\"{INK_MUTED}\" stroke-width=\"2\" />\n  <text x=\"{text_x:.1f}\" y=\"{text_y:.1f}\" text-anchor=\"middle\" font-weight=\"bold\" font-size=\"38\"\n        font-family='Consolas, \"Liberation Mono\", Menlo, Courier, monospace' fill=\"{INK}\">QA capacity crunch</text>\n</g>\n\"\"\"\nsvg_text = svg_text.replace(\"</svg>\", annotation + \"</svg>\", 1)\n\n# Save\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(svg_text.encode(\"utf-8\"))\ncairosvg.svg2png(bytestring=svg_text.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\", dpi=72)\n"}