{"spec_id":"waterfall-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nwaterfall-basic: Basic Waterfall Chart\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 89/100 | Updated: 2026-08-04\n\"\"\"\n\nimport os\n\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens (Imprint palette — see default-style-guide.md)\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\n# Semantic exception (default-style-guide.md \"Color Philosophy\"): sentiment/polarity\n# categories map to their expected colors rather than plain ordinal position.\nBRAND_GREEN = \"#009E73\"  # Imprint position 1 — gain / increase\nSEMANTIC_RED = \"#AE3030\"  # Imprint position 5 — deferred loss/error anchor — decrease\nNEUTRAL = INK  # theme-adaptive semantic anchor — totals / baseline\n\n# Data: quarterly financial breakdown from revenue to net income\ncategories = [\"Q1 Revenue\", \"Product Sales\", \"Services\", \"COGS\", \"Operating Exp\", \"Other Income\", \"Taxes\", \"Net Income\"]\nchanges = [500, 150, 80, -180, -120, 25, -68, None]\n\n# Running totals + per-bar geometry for the waterfall effect\nrunning_total = 0\nbar_bottoms, bar_heights, bar_types, display_values, running_totals = [], [], [], [], []\n\nfor i, val in enumerate(changes):\n    if i == 0:\n        bar_bottoms.append(0)\n        bar_heights.append(val)\n        bar_types.append(\"total\")\n        display_values.append(val)\n        running_total = val\n    elif val is None:\n        bar_bottoms.append(0)\n        bar_heights.append(running_total)\n        bar_types.append(\"total\")\n        display_values.append(running_total)\n    elif val >= 0:\n        bar_bottoms.append(running_total)\n        bar_heights.append(val)\n        bar_types.append(\"positive\")\n        display_values.append(val)\n        running_total += val\n    else:\n        running_total += val\n        bar_bottoms.append(running_total)\n        bar_heights.append(abs(val))\n        bar_types.append(\"negative\")\n        display_values.append(val)\n    running_totals.append(running_total)\n\n\nclass WaterfallChart(pygal.StackedBar):\n    \"\"\"StackedBar with dashed connector lines between waterfall steps.\n\n    pygal has no native bar+line combo chart, so the connectors are drawn\n    directly on the SVG plot layer, reusing the exact view/margin math\n    StackedBar._bar() uses internally so the connector endpoints land\n    pixel-exact on the bar edges.\n    \"\"\"\n\n    def __init__(self, *args, connector_levels=None, connector_color=\"#000\", **kwargs):\n        self._connector_levels = connector_levels or []\n        self._connector_color = connector_color\n        super().__init__(*args, **kwargs)\n\n    def _plot(self):\n        super()._plot()\n        n = self._len\n        width_full = (self.view.x(1) - self.view.x(0)) / n\n        margin = width_full * self._series_margin\n        bar_width = width_full - 2 * margin\n        node = self.svg.node(self.nodes[\"plot\"], class_=\"waterfall-connectors\")\n        for i, level in enumerate(self._connector_levels):\n            if level is None:\n                continue\n            x_right = self.view.x(i / n) + margin + bar_width\n            x_left = self.view.x((i + 1) / n) + margin\n            y = self.view.y(level)\n            self.svg.line(\n                node,\n                [(x_right, y), (x_left, y)],\n                style=(f\"stroke:{self._connector_color};stroke-width:3;stroke-dasharray:14,10;fill:none;opacity:0.85\"),\n            )\n\n\n# Theme-adaptive Style — first categorical series is always Imprint position 1\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=(\"transparent\", NEUTRAL, BRAND_GREEN, SEMANTIC_RED),\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    value_label_font_size=36,  # pygal renders print_labels() text (our \"+$150K\"\n    # strings) through this separate key, NOT value_font_size — easy to miss since\n    # it defaults to 10px regardless of the other sizes\n    stroke_width=2.5,\n)\n\nchart = WaterfallChart(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=\"waterfall-basic · python · pygal · anyplot.ai\",\n    x_title=\"Category\",\n    y_title=\"Amount ($K)\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=3,  # force Total/Increase/Decrease onto one row\n    # instead of pygal's default ceil(sqrt(n)) grid, which scattered them 2x2\n    show_y_guides=True,\n    show_x_guides=False,\n    print_labels=True,  # show the \"+$150K\" / \"-$180K\" strings from each point's\n    # \"label\" key — plain print_values would print the raw stacked-segment height\n    # instead (and, via StackedBar's none_to_zero adapter, a stray \"0\" for every\n    # other series' empty slot at that category)\n    print_values_position=\"center\",\n    truncate_legend=-1,\n    truncate_label=-1,\n    x_label_rotation=25,\n    margin=60,\n    margin_bottom=380,\n    spacing=34,  # extra breathing room so the legend row doesn't crowd the x_title\n    connector_levels=running_totals[:-1],\n    connector_color=INK_MUTED,\n)\n\n# Set x-axis labels (category + running total)\nlabels_with_totals = [f\"{cat} (${running_totals[i]}K)\" for i, cat in enumerate(categories)]\nchart.x_labels = labels_with_totals\n\n# Build data series: spacer (invisible), totals, positive changes, negative changes\nspacer_data, total_data, positive_data, negative_data = [], [], [], []\n\nfor i in range(len(categories)):\n    bottom = bar_bottoms[i]\n    height = bar_heights[i]\n    btype = bar_types[i]\n    disp_val = display_values[i]\n\n    spacer_data.append({\"value\": bottom if bottom > 0 else None, \"label\": \"\"})\n\n    if btype == \"total\":\n        total_data.append({\"value\": height, \"label\": f\"${disp_val}K\"})\n        positive_data.append({\"value\": None})\n        negative_data.append({\"value\": None})\n    elif btype == \"positive\":\n        total_data.append({\"value\": None})\n        positive_data.append({\"value\": height, \"label\": f\"+${disp_val}K\"})\n        negative_data.append({\"value\": None})\n    else:\n        total_data.append({\"value\": None})\n        positive_data.append({\"value\": None})\n        negative_data.append({\"value\": height, \"label\": f\"-${abs(disp_val)}K\"})\n\n# Add series in stack order (bottom to top). Series order fixes the Style.colors\n# index: spacer(0)=transparent, Total(1)=neutral, Increase(2)=brand green\n# (Imprint position 1 — semantic \"gain\"), Decrease(3)=matte red (Imprint\n# position 5 — semantic \"loss\"), per default-style-guide.md Semantic Exception.\nchart.add(None, spacer_data, stroke_style={\"width\": 0}, show_legend=False)\nchart.add(\"Total\", total_data)\nchart.add(\"Increase\", positive_data)\nchart.add(\"Decrease\", negative_data)\n\n# Save outputs\nchart.render_to_png(f\"plot-{THEME}.png\")\nchart.render_to_file(f\"plot-{THEME}.html\")\n"}